diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index b805077..16f19ab 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -49,6 +49,22 @@ $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): vo
$routes->get('local-governments/create', 'Admin\LocalGovernment::create');
$routes->post('local-governments/store', 'Admin\LocalGovernment::store');
+ // 기본코드 종류 관리 (P2-01)
+ $routes->get('code-kinds', 'Admin\CodeKind::index');
+ $routes->get('code-kinds/create', 'Admin\CodeKind::create');
+ $routes->post('code-kinds/store', 'Admin\CodeKind::store');
+ $routes->get('code-kinds/edit/(:num)', 'Admin\CodeKind::edit/$1');
+ $routes->post('code-kinds/update/(:num)', 'Admin\CodeKind::update/$1');
+ $routes->post('code-kinds/delete/(:num)', 'Admin\CodeKind::delete/$1');
+
+ // 세부코드 관리 (P2-02)
+ $routes->get('code-details/(:num)', 'Admin\CodeDetail::index/$1');
+ $routes->get('code-details/(:num)/create', 'Admin\CodeDetail::create/$1');
+ $routes->post('code-details/store', 'Admin\CodeDetail::store');
+ $routes->get('code-details/edit/(:num)', 'Admin\CodeDetail::edit/$1');
+ $routes->post('code-details/update/(:num)', 'Admin\CodeDetail::update/$1');
+ $routes->post('code-details/delete/(:num)', 'Admin\CodeDetail::delete/$1');
+
$routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store');
diff --git a/app/Controllers/Admin/CodeDetail.php b/app/Controllers/Admin/CodeDetail.php
new file mode 100644
index 0000000..d8a6ed3
--- /dev/null
+++ b/app/Controllers/Admin/CodeDetail.php
@@ -0,0 +1,134 @@
+kindModel = model(CodeKindModel::class);
+ $this->detailModel = model(CodeDetailModel::class);
+ }
+
+ public function index(int $ckIdx)
+ {
+ $kind = $this->kindModel->find($ckIdx);
+ if ($kind === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
+ }
+
+ $list = $this->detailModel->getByKind($ckIdx);
+
+ return view('admin/layout', [
+ 'title' => '세부코드 관리 — ' . $kind->ck_name . ' (' . $kind->ck_code . ')',
+ 'content' => view('admin/code_detail/index', [
+ 'kind' => $kind,
+ 'list' => $list,
+ ]),
+ ]);
+ }
+
+ public function create(int $ckIdx)
+ {
+ $kind = $this->kindModel->find($ckIdx);
+ if ($kind === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
+ }
+
+ return view('admin/layout', [
+ 'title' => '세부코드 등록 — ' . $kind->ck_name,
+ 'content' => view('admin/code_detail/create', ['kind' => $kind]),
+ ]);
+ }
+
+ public function store()
+ {
+ $rules = [
+ 'cd_ck_idx' => 'required|is_natural_no_zero',
+ 'cd_code' => 'required|max_length[50]',
+ 'cd_name' => 'required|max_length[100]',
+ 'cd_sort' => 'permit_empty|is_natural',
+ ];
+
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $ckIdx = (int) $this->request->getPost('cd_ck_idx');
+
+ $this->detailModel->insert([
+ 'cd_ck_idx' => $ckIdx,
+ 'cd_code' => $this->request->getPost('cd_code'),
+ 'cd_name' => $this->request->getPost('cd_name'),
+ 'cd_sort' => (int) ($this->request->getPost('cd_sort') ?: 0),
+ 'cd_state' => 1,
+ 'cd_regdate' => date('Y-m-d H:i:s'),
+ ]);
+
+ return redirect()->to(site_url('admin/code-details/' . $ckIdx))->with('success', '세부코드가 등록되었습니다.');
+ }
+
+ public function edit(int $id)
+ {
+ $item = $this->detailModel->find($id);
+ if ($item === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
+ }
+
+ $kind = $this->kindModel->find($item->cd_ck_idx);
+
+ return view('admin/layout', [
+ 'title' => '세부코드 수정 — ' . ($kind->ck_name ?? ''),
+ 'content' => view('admin/code_detail/edit', [
+ 'item' => $item,
+ 'kind' => $kind,
+ ]),
+ ]);
+ }
+
+ public function update(int $id)
+ {
+ $item = $this->detailModel->find($id);
+ if ($item === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
+ }
+
+ $rules = [
+ 'cd_name' => 'required|max_length[100]',
+ 'cd_sort' => 'permit_empty|is_natural',
+ 'cd_state' => 'required|in_list[0,1]',
+ ];
+
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $this->detailModel->update($id, [
+ 'cd_name' => $this->request->getPost('cd_name'),
+ 'cd_sort' => (int) ($this->request->getPost('cd_sort') ?: 0),
+ 'cd_state' => (int) $this->request->getPost('cd_state'),
+ ]);
+
+ return redirect()->to(site_url('admin/code-details/' . $item->cd_ck_idx))->with('success', '세부코드가 수정되었습니다.');
+ }
+
+ public function delete(int $id)
+ {
+ $item = $this->detailModel->find($id);
+ if ($item === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '세부코드를 찾을 수 없습니다.');
+ }
+
+ $ckIdx = $item->cd_ck_idx;
+ $this->detailModel->delete($id);
+
+ return redirect()->to(site_url('admin/code-details/' . $ckIdx))->with('success', '세부코드가 삭제되었습니다.');
+ }
+}
diff --git a/app/Controllers/Admin/CodeKind.php b/app/Controllers/Admin/CodeKind.php
new file mode 100644
index 0000000..181be5e
--- /dev/null
+++ b/app/Controllers/Admin/CodeKind.php
@@ -0,0 +1,122 @@
+kindModel = model(CodeKindModel::class);
+ }
+
+ public function index()
+ {
+ $list = $this->kindModel->orderBy('ck_code', 'ASC')->findAll();
+
+ // 세부코드 수 매핑
+ $detailModel = model(CodeDetailModel::class);
+ $countMap = [];
+ foreach ($list as $row) {
+ $countMap[$row->ck_idx] = $detailModel->where('cd_ck_idx', $row->ck_idx)->countAllResults(false);
+ }
+
+ return view('admin/layout', [
+ 'title' => '기본코드 종류 관리',
+ 'content' => view('admin/code_kind/index', [
+ 'list' => $list,
+ 'countMap' => $countMap,
+ ]),
+ ]);
+ }
+
+ public function create()
+ {
+ return view('admin/layout', [
+ 'title' => '기본코드 종류 등록',
+ 'content' => view('admin/code_kind/create'),
+ ]);
+ }
+
+ public function store()
+ {
+ $rules = [
+ 'ck_code' => 'required|max_length[20]|is_unique[code_kind.ck_code]',
+ 'ck_name' => 'required|max_length[100]',
+ ];
+
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $this->kindModel->insert([
+ 'ck_code' => $this->request->getPost('ck_code'),
+ 'ck_name' => $this->request->getPost('ck_name'),
+ 'ck_state' => 1,
+ 'ck_regdate' => date('Y-m-d H:i:s'),
+ ]);
+
+ return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 등록되었습니다.');
+ }
+
+ public function edit(int $id)
+ {
+ $item = $this->kindModel->find($id);
+ if ($item === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
+ }
+
+ return view('admin/layout', [
+ 'title' => '기본코드 종류 수정',
+ 'content' => view('admin/code_kind/edit', ['item' => $item]),
+ ]);
+ }
+
+ public function update(int $id)
+ {
+ $item = $this->kindModel->find($id);
+ if ($item === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
+ }
+
+ $rules = [
+ 'ck_name' => 'required|max_length[100]',
+ 'ck_state' => 'required|in_list[0,1]',
+ ];
+
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ $this->kindModel->update($id, [
+ 'ck_name' => $this->request->getPost('ck_name'),
+ 'ck_state' => (int) $this->request->getPost('ck_state'),
+ ]);
+
+ return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 수정되었습니다.');
+ }
+
+ public function delete(int $id)
+ {
+ $item = $this->kindModel->find($id);
+ if ($item === null) {
+ return redirect()->to(site_url('admin/code-kinds'))->with('error', '코드 종류를 찾을 수 없습니다.');
+ }
+
+ // 세부코드가 있으면 삭제 불가
+ $detailCount = model(CodeDetailModel::class)->where('cd_ck_idx', $id)->countAllResults();
+ if ($detailCount > 0) {
+ return redirect()->to(site_url('admin/code-kinds'))
+ ->with('error', '세부코드가 ' . $detailCount . '개 존재하여 삭제할 수 없습니다. 먼저 세부코드를 삭제해 주세요.');
+ }
+
+ $this->kindModel->delete($id);
+ return redirect()->to(site_url('admin/code-kinds'))->with('success', '코드 종류가 삭제되었습니다.');
+ }
+}
diff --git a/app/Models/CodeDetailModel.php b/app/Models/CodeDetailModel.php
new file mode 100644
index 0000000..268069c
--- /dev/null
+++ b/app/Models/CodeDetailModel.php
@@ -0,0 +1,33 @@
+where('cd_ck_idx', $ckIdx);
+ if ($activeOnly) {
+ $builder->where('cd_state', 1);
+ }
+ return $builder->orderBy('cd_sort', 'ASC')->findAll();
+ }
+}
diff --git a/app/Models/CodeKindModel.php b/app/Models/CodeKindModel.php
new file mode 100644
index 0000000..c34bb93
--- /dev/null
+++ b/app/Models/CodeKindModel.php
@@ -0,0 +1,19 @@
+
+
+
+
diff --git a/app/Views/admin/code_detail/edit.php b/app/Views/admin/code_detail/edit.php
new file mode 100644
index 0000000..0244fcf
--- /dev/null
+++ b/app/Views/admin/code_detail/edit.php
@@ -0,0 +1,45 @@
+
+
diff --git a/app/Views/admin/code_detail/index.php b/app/Views/admin/code_detail/index.php
new file mode 100644
index 0000000..3e5cde3
--- /dev/null
+++ b/app/Views/admin/code_detail/index.php
@@ -0,0 +1,44 @@
+
+
+
+
← 코드 종류
+
|
+
세부코드 — = esc($kind->ck_name) ?> (= esc($kind->ck_code) ?>)
+
+
세부코드 등록
+
+
+
+
+
+
+ | 번호 |
+ 코드 |
+ 코드명 |
+ 정렬순서 |
+ 상태 |
+ 등록일 |
+ 작업 |
+
+
+
+
+
+ | = esc($row->cd_idx) ?> |
+ = esc($row->cd_code) ?> |
+ = esc($row->cd_name) ?> |
+ = (int) $row->cd_sort ?> |
+ = (int) $row->cd_state === 1 ? '사용' : '미사용' ?> |
+ = esc($row->cd_regdate ?? '') ?> |
+
+ 수정
+
+ |
+
+
+
+
+
diff --git a/app/Views/admin/code_kind/create.php b/app/Views/admin/code_kind/create.php
new file mode 100644
index 0000000..a1ff7b1
--- /dev/null
+++ b/app/Views/admin/code_kind/create.php
@@ -0,0 +1,23 @@
+
+
diff --git a/app/Views/admin/code_kind/edit.php b/app/Views/admin/code_kind/edit.php
new file mode 100644
index 0000000..7825d9e
--- /dev/null
+++ b/app/Views/admin/code_kind/edit.php
@@ -0,0 +1,31 @@
+
+
diff --git a/app/Views/admin/code_kind/index.php b/app/Views/admin/code_kind/index.php
new file mode 100644
index 0000000..35c8525
--- /dev/null
+++ b/app/Views/admin/code_kind/index.php
@@ -0,0 +1,43 @@
+
+
+
+
+
+ | 번호 |
+ 코드 |
+ 코드명 |
+ 세부코드 수 |
+ 상태 |
+ 등록일 |
+ 작업 |
+
+
+
+
+
+ | = esc($row->ck_idx) ?> |
+ = esc($row->ck_code) ?> |
+ = esc($row->ck_name) ?> |
+
+ = (int) ($countMap[$row->ck_idx] ?? 0) ?>개
+ |
+ = (int) $row->ck_state === 1 ? '사용' : '미사용' ?> |
+ = esc($row->ck_regdate ?? '') ?> |
+
+ 세부코드
+ 수정
+
+ |
+
+
+
+
+
diff --git a/e2e/admin.spec.js b/e2e/admin.spec.js
index 72c0b15..78ec52c 100644
--- a/e2e/admin.spec.js
+++ b/e2e/admin.spec.js
@@ -16,8 +16,7 @@ test.describe('관리자 패널 — 지자체관리자', () => {
test('회원 관리 목록 접근', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/admin\/users/);
- const content = await page.content();
- expect(content).toContain('tester_');
+ await expect(page.locator('td:has-text("tester_")')).toBeVisible({ timeout: 10000 });
});
test('로그인 이력 접근', async ({ page }) => {
diff --git a/e2e/code-management.spec.js b/e2e/code-management.spec.js
new file mode 100644
index 0000000..ced9a66
--- /dev/null
+++ b/e2e/code-management.spec.js
@@ -0,0 +1,71 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const { login } = require('./helpers/auth');
+
+/** Super Admin 로그인 + 지자체 선택 */
+async function loginAsAdmin(page) {
+ await login(page, 'admin');
+ const radio = page.locator('input[name="lg_idx"]').first();
+ await radio.check();
+ await page.click('button[type="submit"]');
+ await page.waitForURL(url => !url.pathname.includes('select-local-government'), { timeout: 15000 });
+}
+
+test.describe('P2-01: 기본코드 종류 관리', () => {
+
+ test.beforeEach(async ({ page }) => {
+ await loginAsAdmin(page);
+ });
+
+ test('코드 종류 목록 접근', async ({ page }) => {
+ await page.goto('/admin/code-kinds');
+ await expect(page).toHaveURL(/\/admin\/code-kinds/);
+ await expect(page.locator('td:has-text("도/특별시/광역시 구분")').first()).toBeVisible({ timeout: 10000 });
+ });
+
+ test('코드 종류 등록 폼 표시', async ({ page }) => {
+ await page.goto('/admin/code-kinds/create');
+ await expect(page.locator('input[name="ck_code"]')).toBeVisible();
+ await expect(page.locator('input[name="ck_name"]')).toBeVisible();
+ });
+
+ test('코드 종류 수정', async ({ page }) => {
+ // 기존 코드 A의 수정 테스트
+ await page.goto('/admin/code-kinds/edit/1');
+ await expect(page.locator('input[name="ck_name"]')).toBeVisible();
+ // 값 확인만 (실제 수정은 하지 않음 - 기존 데이터 보존)
+ });
+});
+
+test.describe('P2-02: 세부코드 관리', () => {
+
+ test.beforeEach(async ({ page }) => {
+ await loginAsAdmin(page);
+ });
+
+ test('세부코드 목록 접근 (봉투구분 E)', async ({ page }) => {
+ await page.goto('/admin/code-details/5');
+ await expect(page).toHaveURL(/\/admin\/code-details\/5/);
+ await expect(page.locator('td:has-text("일반용")').first()).toBeVisible({ timeout: 10000 });
+ });
+
+ test('세부코드 등록 폼 표시', async ({ page }) => {
+ await page.goto('/admin/code-details/5/create');
+ await expect(page.locator('input[name="cd_code"]')).toBeVisible();
+ await expect(page.locator('input[name="cd_name"]')).toBeVisible();
+ });
+
+ test('세부코드 수정 폼', async ({ page }) => {
+ // 기존 세부코드 35(일반용)의 수정 폼 확인
+ await page.goto('/admin/code-details/edit/35');
+ await expect(page.locator('input[name="cd_name"]')).toBeVisible();
+ });
+
+ test('코드 종류에서 세부코드 링크 이동', async ({ page }) => {
+ await page.goto('/admin/code-kinds');
+ // "세부코드" 링크 클릭
+ const link = page.locator('a:has-text("세부코드")').first();
+ await link.click();
+ await expect(page).toHaveURL(/\/admin\/code-details\/\d+/);
+ });
+});
diff --git a/screenshots/23_admin_code_kinds.png b/screenshots/23_admin_code_kinds.png
new file mode 100644
index 0000000..c2187ef
Binary files /dev/null and b/screenshots/23_admin_code_kinds.png differ
diff --git a/screenshots/24_admin_code_details.png b/screenshots/24_admin_code_details.png
new file mode 100644
index 0000000..a93f6a8
Binary files /dev/null and b/screenshots/24_admin_code_details.png differ