Initial project import for team collaboration.

Exclude local docs, MCP, and secrets via gitignore.

Made-with: Cursor
This commit is contained in:
taekyoungc
2026-03-25 12:05:33 +09:00
commit 4e557d4be1
153 changed files with 16198 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\LocalGovernmentModel;
use App\Models\MemberApprovalRequestModel;
use App\Models\MemberModel;
use Config\Roles;
use App\Models\MemberLogModel;
class Access extends BaseController
{
private MemberLogModel $memberLogModel;
private MemberApprovalRequestModel $approvalModel;
private MemberModel $memberModel;
private Roles $roles;
public function __construct()
{
$this->memberLogModel = model(MemberLogModel::class);
$this->approvalModel = model(MemberApprovalRequestModel::class);
$this->memberModel = model(MemberModel::class);
$this->roles = config('Roles');
}
/**
* 로그인 이력 (기간 조회)
*/
public function loginHistory(): string
{
$start = $this->request->getGet('start');
$end = $this->request->getGet('end');
$builder = $this->memberLogModel->builder();
$builder->select('member_log.*');
$builder->orderBy('mll_regdate', 'DESC');
if ($start !== null && $start !== '') {
$builder->where('mll_regdate >=', $start . ' 00:00:00');
}
if ($end !== null && $end !== '') {
$builder->where('mll_regdate <=', $end . ' 23:59:59');
}
$list = $builder->get()->getResult();
return view('admin/layout', [
'title' => '로그인 이력',
'content' => view('admin/access/login_history', ['list' => $list, 'start' => $start, 'end' => $end]),
]);
}
public function approvals(): string
{
$status = (string) ($this->request->getGet('status') ?? MemberApprovalRequestModel::STATUS_PENDING);
$allowedStatus = [
MemberApprovalRequestModel::STATUS_PENDING,
MemberApprovalRequestModel::STATUS_APPROVED,
MemberApprovalRequestModel::STATUS_REJECTED,
];
if (! in_array($status, $allowedStatus, true)) {
$status = MemberApprovalRequestModel::STATUS_PENDING;
}
$builder = $this->approvalModel->builder();
$builder->select(
'member_approval_request.*, member.mb_id, member.mb_name, member.mb_lg_idx, local_government.lg_name'
);
$builder->join('member', 'member.mb_idx = member_approval_request.mb_idx', 'left');
$builder->join('local_government', 'local_government.lg_idx = member.mb_lg_idx', 'left');
$builder->where('member_approval_request.mar_status', $status);
$builder->orderBy('member_approval_request.mar_requested_at', 'DESC');
$list = $builder->get()->getResult();
return view('admin/layout', [
'title' => '승인 대기',
'content' => view('admin/access/approvals', [
'list' => $list,
'status' => $status,
'roles' => $this->roles,
]),
]);
}
public function approve(int $id)
{
$requestRow = $this->approvalModel->find($id);
if (! $requestRow) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', '승인 요청을 찾을 수 없습니다.');
}
if ($requestRow->mar_status !== MemberApprovalRequestModel::STATUS_PENDING) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', '이미 처리된 요청입니다.');
}
$requestedLevel = (int) $requestRow->mar_requested_level;
if ($requestedLevel === Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', 'super admin 역할 요청은 승인할 수 없습니다.');
}
$db = db_connect();
$db->transStart();
$this->memberModel->update((int) $requestRow->mb_idx, [
'mb_level' => $requestedLevel,
]);
$this->approvalModel->update($id, [
'mar_status' => MemberApprovalRequestModel::STATUS_APPROVED,
'mar_processed_at' => date('Y-m-d H:i:s'),
'mar_processed_by' => (int) (session()->get('mb_idx') ?? 0),
'mar_reject_reason' => null,
]);
$db->transComplete();
if (! $db->transStatus()) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', '승인 처리 중 오류가 발생했습니다.');
}
return redirect()->to(site_url('admin/access/approvals'))->with('success', '승인 처리되었습니다.');
}
public function reject(int $id)
{
$requestRow = $this->approvalModel->find($id);
if (! $requestRow) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', '승인 요청을 찾을 수 없습니다.');
}
if ($requestRow->mar_status !== MemberApprovalRequestModel::STATUS_PENDING) {
return redirect()->to(site_url('admin/access/approvals'))->with('error', '이미 처리된 요청입니다.');
}
$reason = trim((string) $this->request->getPost('reject_reason'));
if ($reason === '') {
$reason = '관리자 반려';
}
$this->approvalModel->update($id, [
'mar_status' => MemberApprovalRequestModel::STATUS_REJECTED,
'mar_reject_reason' => mb_substr($reason, 0, 255),
'mar_processed_at' => date('Y-m-d H:i:s'),
'mar_processed_by' => (int) (session()->get('mb_idx') ?? 0),
]);
return redirect()->to(site_url('admin/access/approvals'))->with('success', '반려 처리되었습니다.');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
class Dashboard extends BaseController
{
public function index(): string
{
return view('admin/layout', [
'title' => '대시보드',
'content' => view('admin/dashboard/index'),
]);
}
}

View File

@@ -0,0 +1,307 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\DesignatedShopModel;
use App\Models\LocalGovernmentModel;
use Config\Roles;
class DesignatedShop extends BaseController
{
private DesignatedShopModel $shopModel;
private LocalGovernmentModel $lgModel;
private Roles $roles;
public function __construct()
{
$this->shopModel = model(DesignatedShopModel::class);
$this->lgModel = model(LocalGovernmentModel::class);
$this->roles = config('Roles');
}
private function isSuperAdmin(): bool
{
return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN;
}
private function isLocalAdmin(): bool
{
return (int) session()->get('mb_level') === Roles::LEVEL_LOCAL_ADMIN;
}
/**
* 지정판매소 목록 (효과 지자체 기준: super admin = 선택 지자체, 지자체관리자 = mb_lg_idx)
*/
public function index()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin'))
->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
}
$list = $this->shopModel
->where('ds_lg_idx', $lgIdx)
->orderBy('ds_idx', 'DESC')
->findAll();
// 지자체 이름 매핑용
$lgMap = [];
foreach ($this->lgModel->findAll() as $lg) {
$lgMap[$lg->lg_idx] = $lg->lg_name;
}
return view('admin/layout', [
'title' => '지정판매소 관리',
'content' => view('admin/designated_shop/index', [
'list' => $list,
'lgMap' => $lgMap,
]),
]);
}
/**
* 지정판매소 등록 폼 (효과 지자체 기준)
*/
public function create()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '작업할 지자체가 선택되지 않았습니다. 지자체를 선택해 주세요.');
}
$currentLg = $this->lgModel->find($lgIdx);
if ($currentLg === null) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '선택한 지자체 정보를 찾을 수 없습니다.');
}
return view('admin/layout', [
'title' => '지정판매소 등록',
'content' => view('admin/designated_shop/create', [
'localGovs' => [],
'currentLg' => $currentLg,
]),
]);
}
/**
* 지정판매소 등록 처리
*/
public function store()
{
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '지정판매소 등록은 관리자만 가능합니다.');
}
$rules = [
'ds_name' => 'required|max_length[100]',
'ds_biz_no' => 'required|max_length[20]',
'ds_rep_name' => 'required|max_length[50]',
'ds_va_number' => 'permit_empty|max_length[50]',
'ds_email' => 'permit_empty|valid_email|max_length[100]',
];
if (! $this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->back()
->withInput()
->with('error', '소속 지자체가 올바르지 않습니다.');
}
$lg = $this->lgModel->find($lgIdx);
if ($lg === null || (string) $lg->lg_code === '') {
return redirect()->back()
->withInput()
->with('error', '지자체 코드 정보를 찾을 수 없습니다.');
}
$dsShopNo = $this->generateNextShopNo($lgIdx, (string) $lg->lg_code);
$data = [
'ds_lg_idx' => $lgIdx,
'ds_mb_idx' => null,
'ds_shop_no' => $dsShopNo,
'ds_name' => (string) $this->request->getPost('ds_name'),
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
'ds_rep_name' => (string) $this->request->getPost('ds_rep_name'),
'ds_va_number' => (string) $this->request->getPost('ds_va_number'),
'ds_zip' => (string) $this->request->getPost('ds_zip'),
'ds_addr' => (string) $this->request->getPost('ds_addr'),
'ds_addr_jibun' => (string) $this->request->getPost('ds_addr_jibun'),
'ds_tel' => (string) $this->request->getPost('ds_tel'),
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
'ds_email' => (string) $this->request->getPost('ds_email'),
'ds_gugun_code' => (string) $lg->lg_code,
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
'ds_state' => 1,
'ds_regdate' => date('Y-m-d H:i:s'),
];
$this->shopModel->insert($data);
return redirect()->to(site_url('admin/designated-shops'))
->with('success', '지정판매소가 등록되었습니다.');
}
/**
* 지정판매소 수정 폼 (효과 지자체 소속만 허용)
* 문서: docs/기본 개발계획/23-지정판매소_수정_삭제_기능.md
*/
public function edit(int $id)
{
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '권한이 없습니다.');
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '작업할 지자체가 선택되지 않았습니다.');
}
$shop = $this->shopModel->find($id);
if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.');
}
$currentLg = $this->lgModel->find($lgIdx);
return view('admin/layout', [
'title' => '지정판매소 수정',
'content' => view('admin/designated_shop/edit', [
'shop' => $shop,
'currentLg' => $currentLg,
]),
]);
}
/**
* 지정판매소 수정 처리
*/
public function update(int $id)
{
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '권한이 없습니다.');
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '작업할 지자체가 선택되지 않았습니다.');
}
$shop = $this->shopModel->find($id);
if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '해당 지정판매소를 찾을 수 없거나 수정할 수 없습니다.');
}
$rules = [
'ds_name' => 'required|max_length[100]',
'ds_biz_no' => 'required|max_length[20]',
'ds_rep_name' => 'required|max_length[50]',
'ds_va_number' => 'permit_empty|max_length[50]',
'ds_email' => 'permit_empty|valid_email|max_length[100]',
'ds_state' => 'permit_empty|in_list[1,2,3]',
];
if (! $this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
$data = [
'ds_name' => (string) $this->request->getPost('ds_name'),
'ds_biz_no' => (string) $this->request->getPost('ds_biz_no'),
'ds_rep_name' => (string) $this->request->getPost('ds_rep_name'),
'ds_va_number' => (string) $this->request->getPost('ds_va_number'),
'ds_zip' => (string) $this->request->getPost('ds_zip'),
'ds_addr' => (string) $this->request->getPost('ds_addr'),
'ds_addr_jibun' => (string) $this->request->getPost('ds_addr_jibun'),
'ds_tel' => (string) $this->request->getPost('ds_tel'),
'ds_rep_phone' => (string) $this->request->getPost('ds_rep_phone'),
'ds_email' => (string) $this->request->getPost('ds_email'),
'ds_designated_at' => $this->request->getPost('ds_designated_at') ?: null,
'ds_state' => (int) ($this->request->getPost('ds_state') ?: 1),
];
$this->shopModel->update($id, $data);
return redirect()->to(site_url('admin/designated-shops'))
->with('success', '지정판매소 정보가 수정되었습니다.');
}
/**
* 지정판매소 삭제 (물리 삭제, 효과 지자체 소속만 허용)
* 문서: docs/기본 개발계획/23-지정판매소_수정_삭제_기능.md
*/
public function delete(int $id)
{
if (! $this->isSuperAdmin() && ! $this->isLocalAdmin()) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '권한이 없습니다.');
}
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '작업할 지자체가 선택되지 않았습니다.');
}
$shop = $this->shopModel->find($id);
if ($shop === null || (int) $shop->ds_lg_idx !== $lgIdx) {
return redirect()->to(site_url('admin/designated-shops'))
->with('error', '해당 지정판매소를 찾을 수 없거나 삭제할 수 없습니다.');
}
$this->shopModel->delete($id);
return redirect()->to(site_url('admin/designated-shops'))
->with('success', '지정판매소가 삭제되었습니다.');
}
/**
* 지자체별 다음 판매소번호 생성 (lg_code + 3자리 일련번호)
* 문서: docs/기본 개발계획/22-판매소번호_일련번호_결정.md §3
*/
private function generateNextShopNo(int $lgIdx, string $lgCode): string
{
$prefixLen = strlen($lgCode);
$existing = $this->shopModel->where('ds_lg_idx', $lgIdx)->findAll();
$maxSerial = 0;
foreach ($existing as $row) {
$no = $row->ds_shop_no;
if (strlen($no) === $prefixLen + 3 && str_starts_with($no, $lgCode)) {
$n = (int) substr($no, -3);
if ($n > $maxSerial) {
$maxSerial = $n;
}
}
}
return $lgCode . sprintf('%03d', $maxSerial + 1);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\LocalGovernmentModel;
use Config\Roles;
class LocalGovernment extends BaseController
{
private LocalGovernmentModel $lgModel;
private Roles $roles;
public function __construct()
{
$this->lgModel = model(LocalGovernmentModel::class);
$this->roles = config('Roles');
}
private function isSuperAdmin(): bool
{
return (int) session()->get('mb_level') === Roles::LEVEL_SUPER_ADMIN;
}
/**
* 지자체 목록
*/
public function index()
{
if (! $this->isSuperAdmin()) {
return redirect()->to(site_url('admin'))
->with('error', '지자체 관리는 super admin만 접근할 수 있습니다.');
}
$list = $this->lgModel->orderBy('lg_idx', 'DESC')->findAll();
return view('admin/layout', [
'title' => '지자체 관리',
'content' => view('admin/local_government/index', ['list' => $list]),
]);
}
/**
* 지자체 등록 폼
*/
public function create()
{
if (! $this->isSuperAdmin()) {
return redirect()->to(site_url('admin/local-governments'))
->with('error', '지자체 등록은 super admin만 가능합니다.');
}
return view('admin/layout', [
'title' => '지자체 등록',
'content' => view('admin/local_government/create'),
]);
}
/**
* 지자체 등록 처리
*/
public function store()
{
if (! $this->isSuperAdmin()) {
return redirect()->to(site_url('admin/local-governments'))
->with('error', '지자체 등록은 super admin만 가능합니다.');
}
$rules = [
'lg_name' => 'required|max_length[100]',
'lg_code' => 'required|max_length[20]|is_unique[local_government.lg_code]',
'lg_sido' => 'required|max_length[50]',
'lg_gugun' => 'required|max_length[50]',
'lg_addr' => 'permit_empty|max_length[255]',
];
if (! $this->validate($rules)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
$data = [
'lg_name' => (string) $this->request->getPost('lg_name'),
'lg_code' => (string) $this->request->getPost('lg_code'),
'lg_sido' => (string) $this->request->getPost('lg_sido'),
'lg_gugun' => (string) $this->request->getPost('lg_gugun'),
'lg_addr' => (string) $this->request->getPost('lg_addr'),
'lg_state' => 1,
'lg_regdate' => date('Y-m-d H:i:s'),
];
$this->lgModel->insert($data);
return redirect()->to(site_url('admin/local-governments'))
->with('success', '지자체가 등록되었습니다.');
}
}

View File

@@ -0,0 +1,213 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\MenuModel;
use App\Models\MenuTypeModel;
use Config\Roles;
class Menu extends BaseController
{
private MenuModel $menuModel;
private MenuTypeModel $typeModel;
public function __construct()
{
$this->menuModel = model(MenuModel::class);
$this->typeModel = model(MenuTypeModel::class);
}
/**
* 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리.
*/
public function index()
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '메뉴를 관리하려면 먼저 지자체를 선택하세요.');
}
$types = $this->typeModel->orderBy('mt_sort', 'ASC')->findAll();
$mtIdx = (int) ($this->request->getGet('mt_idx') ?? 0);
if ($mtIdx <= 0 && ! empty($types)) {
// 기본 선택: 사이트 메뉴(mt_code=site), 없으면 첫 번째 타입
$siteType = $this->typeModel->where('mt_code', 'site')->first();
$mtIdx = $siteType ? (int) $siteType->mt_idx : (int) $types[0]->mt_idx;
}
$list = $mtIdx > 0 ? $this->menuModel->getAllByType($mtIdx, $lgIdx) : [];
// 현재 지자체에 메뉴가 없으면, mt_idx별로 기본 지자체(lg_idx=1)의 메뉴를 한 번 복사한다.
if ($mtIdx > 0 && empty($list)) {
$this->menuModel->copyDefaultsFromLg($mtIdx, 1, $lgIdx);
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
}
// 트리 순서대로 상위 메뉴 바로 아래에 하위 메뉴가 오도록 평탄화
if (! empty($list)) {
$tree = build_menu_tree($list);
$list = flatten_menu_tree($tree);
}
$currentType = $mtIdx > 0 ? $this->typeModel->find($mtIdx) : null;
return view('admin/layout', [
'title' => '메뉴 관리',
'content' => view('admin/menu/index', [
'types' => $types,
'mtIdx' => $mtIdx,
'mtCode' => $currentType->mt_code ?? '',
'list' => $list,
'levelNames' => config('Roles')->levelNames,
]),
]);
}
/**
* 메뉴 목록 JSON (트리 정렬된 평면 배열). 현재 지자체만.
*/
public function list()
{
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']);
}
$mtIdx = (int) $this->request->getGet('mt_idx');
if ($mtIdx <= 0) {
return $this->response->setJSON(['status' => 0, 'msg' => 'mt_idx required']);
}
$list = $this->menuModel->getAllByType($mtIdx, $lgIdx);
return $this->response->setJSON(['status' => 1, 'data' => $list]);
}
/**
* 메뉴 등록 (현재 지자체 소속으로 저장)
*/
public function store()
{
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '메뉴를 등록하려면 먼저 지자체를 선택하세요.');
}
$mtIdx = (int) $this->request->getPost('mt_idx');
$mmPidx = (int) $this->request->getPost('mm_pidx');
$mmDep = (int) $this->request->getPost('mm_dep');
$mmName = trim((string) $this->request->getPost('mm_name'));
if ($mtIdx <= 0) {
return redirect()->back()->with('error', '메뉴 종류를 선택하세요.');
}
if ($mmName === '') {
return redirect()->back()->with('error', '메뉴명을 입력하세요.');
}
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
$data = [
'mt_idx' => $mtIdx,
'lg_idx' => $lgIdx,
'mm_name' => $mmName,
'mm_link' => (string) $this->request->getPost('mm_link'),
'mm_pidx' => $mmPidx,
'mm_dep' => $mmDep,
'mm_num' => $mmNum,
'mm_cnode' => 0,
'mm_level' => $this->normalizeMmLevel($mtIdx),
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
];
$this->menuModel->insert($data);
if ($mmPidx > 0) {
$this->menuModel->updateCnode($mmPidx, 1);
}
return redirect()->back()->with('success', '메뉴가 등록되었습니다.');
}
/**
* 메뉴 수정 (현재 지자체 소속 메뉴만 허용)
*/
public function update(int $id)
{
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.');
}
$row = $this->menuModel->find($id);
if (! $row) {
return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.');
}
if ((int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
}
$data = [
'mm_name' => (string) $this->request->getPost('mm_name'),
'mm_link' => (string) $this->request->getPost('mm_link'),
'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx),
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
];
$this->menuModel->update($id, $data);
return redirect()->back()->with('success', '메뉴가 수정되었습니다.');
}
/**
* 메뉴 삭제 (현재 지자체 소속만 허용, 하위 있으면 불가)
*/
public function delete(int $id)
{
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.');
}
$row = $this->menuModel->find($id);
if (! $row || (int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.');
}
$result = $this->menuModel->deleteSafe($id);
if ($result['ok']) {
return redirect()->back()->with('success', '메뉴가 삭제되었습니다.');
}
return redirect()->back()->with('error', $result['msg']);
}
/**
* 순서 변경 (mm_idx[] 순서대로 mm_num 부여). 현재 지자체 메뉴만.
*/
public function move()
{
$lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.');
}
$ids = $this->request->getPost('mm_idx');
if (! is_array($ids) || empty($ids)) {
return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.');
}
$this->menuModel->setOrder($ids, $lgIdx);
return redirect()->back()->with('success', '순서가 적용되었습니다.');
}
/**
* 노출 대상: 전체(mm_level_all)이면 빈 문자열, 아니면 선택한 레벨을 쉼표 구분 문자열로
*/
private function normalizeMmLevel(int $mtIdx): string
{
// 관리자 메뉴(admin)는 시민/판매소 노출을 허용하지 않음 → 지자체관리자(3)로 고정
$type = $this->typeModel->find($mtIdx);
if ($type && (string) $type->mt_code === 'admin') {
return (string) Roles::LEVEL_LOCAL_ADMIN;
}
if ($this->request->getPost('mm_level_all')) {
return '';
}
$levels = $this->request->getPost('mm_level');
if (! is_array($levels) || empty($levels)) {
return '';
}
$levels = array_map('intval', $levels);
// super admin(4)은 DB 저장 대상 아님. 1,2,3은 그대로 저장
$levels = array_filter($levels, static fn ($v) => $v > 0 && $v !== \Config\Roles::LEVEL_SUPER_ADMIN);
return implode(',', array_values($levels));
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use Config\Roles;
/**
* 역할(mb_level) 관리.
* 현재는 Config\Roles 기반 목록만 제공. 추후 role 테이블 연동 시 CRUD 확장.
*/
class Role extends BaseController
{
public function index(): string
{
$roles = config('Roles');
return view('admin/layout', [
'title' => '역할',
'content' => view('admin/role/index', ['roles' => $roles]),
]);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\LocalGovernmentModel;
use Config\Roles;
class SelectLocalGovernment extends BaseController
{
/**
* 지자체 선택 화면 (super admin 전용)
*/
public function index()
{
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
}
$list = model(LocalGovernmentModel::class)
->where('lg_state', 1)
->orderBy('lg_name', 'ASC')
->findAll();
return view('admin/layout', [
'title' => '지자체 선택',
'content' => view('admin/select_local_government/index', [
'list' => $list,
]),
]);
}
/**
* 선택 처리: admin_selected_lg_idx 저장 후 관리자 대시보드로 이동
*/
public function store()
{
if ((int) session()->get('mb_level') !== Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin'))->with('error', '지자체 선택은 super admin만 사용할 수 있습니다.');
}
$lgIdx = (int) $this->request->getPost('lg_idx');
if ($lgIdx <= 0) {
return redirect()->back()
->with('error', '지자체를 선택해 주세요.');
}
$exists = model(LocalGovernmentModel::class)->find($lgIdx);
if ($exists === null) {
return redirect()->back()
->with('error', '선택한 지자체를 찾을 수 없습니다.');
}
session()->set('admin_selected_lg_idx', $lgIdx);
return redirect()->to(site_url('admin'))->with('success', $exists->lg_name . ' 지자체로 전환되었습니다.');
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Controllers\Admin;
use App\Controllers\BaseController;
use App\Models\MemberApprovalRequestModel;
use App\Models\MemberModel;
use Config\Roles;
class User extends BaseController
{
private MemberModel $memberModel;
private MemberApprovalRequestModel $approvalModel;
private Roles $roles;
public function __construct()
{
$this->memberModel = model(MemberModel::class);
$this->approvalModel = model(MemberApprovalRequestModel::class);
$this->roles = config('Roles');
helper('pii_encryption');
}
/**
* 회원 목록
*/
public function index(): string
{
$list = $this->memberModel->orderBy('mb_idx', 'DESC')->findAll();
$approvalMap = [];
try {
$memberIds = array_map(static fn ($row) => (int) $row->mb_idx, $list);
if (! empty($memberIds)) {
$approvalRows = $this->approvalModel
->whereIn('mb_idx', $memberIds)
->orderBy('mar_idx', 'DESC')
->findAll();
foreach ($approvalRows as $approvalRow) {
$mbIdx = (int) $approvalRow->mb_idx;
if (! isset($approvalMap[$mbIdx])) {
$approvalMap[$mbIdx] = (string) $approvalRow->mar_status;
}
}
}
} catch (\Throwable $e) {
// 승인요청 테이블 미생성 등 예외 시 기존 상태 표시로 폴백
}
foreach ($list as $row) {
$row->mb_email = pii_decrypt($row->mb_email ?? '');
$row->mb_phone = pii_decrypt($row->mb_phone ?? '');
}
return view('admin/layout', [
'title' => '회원 관리',
'content' => view('admin/user/index', [
'list' => $list,
'roles' => $this->roles,
'approvalMap' => $approvalMap,
]),
]);
}
/**
* 회원 등록 폼
*/
public function create(): string
{
return view('admin/layout', [
'title' => '회원 등록',
'content' => view('admin/user/create', [
'roles' => $this->roles,
'assignableLevels' => $this->getAssignableLevels(),
]),
]);
}
/**
* 회원 등록 처리
*/
public function store()
{
$rules = [
'mb_id' => 'required|min_length[2]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]',
'mb_name' => 'required|max_length[50]',
'mb_email' => 'permit_empty|valid_email|max_length[100]',
'mb_phone' => 'permit_empty|max_length[20]',
'mb_level' => 'required',
];
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$allowedLevels = array_keys($this->getAssignableLevels());
$requestedLevel = (int) $this->request->getPost('mb_level');
if (! in_array($requestedLevel, $allowedLevels, true)) {
return redirect()->back()->withInput()->with('error', '현재 권한으로는 해당 역할을 등록할 수 없습니다.');
}
$data = [
'mb_id' => $this->request->getPost('mb_id'),
'mb_passwd' => password_hash((string) $this->request->getPost('mb_passwd'), PASSWORD_DEFAULT),
'mb_name' => $this->request->getPost('mb_name'),
'mb_email' => pii_encrypt($this->request->getPost('mb_email')),
'mb_phone' => pii_encrypt($this->request->getPost('mb_phone')),
'mb_level' => $requestedLevel,
'mb_state' => 1,
'mb_regdate' => date('Y-m-d H:i:s'),
'mb_latestdate' => null,
];
$this->memberModel->insert($data);
return redirect()->to(site_url('admin/users'))->with('success', '회원이 등록되었습니다.');
}
/**
* 회원 수정 폼
*/
public function edit(int $id): string
{
$member = $this->memberModel->find($id);
if (! $member) {
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
}
$member->mb_email = pii_decrypt($member->mb_email ?? '');
$member->mb_phone = pii_decrypt($member->mb_phone ?? '');
return view('admin/layout', [
'title' => '회원 수정',
'content' => view('admin/user/edit', [
'member' => $member,
'roles' => $this->roles,
'assignableLevels' => $this->getAssignableLevels(),
]),
]);
}
/**
* 회원 수정 처리
*/
public function update(int $id)
{
$member = $this->memberModel->find($id);
if (! $member) {
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
}
$rules = [
'mb_id' => "required|min_length[2]|max_length[50]|is_unique[member.mb_id,mb_idx,{$id}]",
'mb_name' => 'required|max_length[50]',
'mb_email' => 'permit_empty|valid_email|max_length[100]',
'mb_phone' => 'permit_empty|max_length[20]',
'mb_level' => 'required',
'mb_state' => 'required|in_list[0,1,2]',
];
$passwd = $this->request->getPost('mb_passwd');
if ($passwd !== '') {
$rules['mb_passwd'] = 'min_length[4]|max_length[255]';
}
if (! $this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
$allowedLevels = array_keys($this->getAssignableLevels());
$requestedLevel = (int) $this->request->getPost('mb_level');
if (! in_array($requestedLevel, $allowedLevels, true)) {
return redirect()->back()->withInput()->with('error', '현재 권한으로는 해당 역할로 수정할 수 없습니다.');
}
$data = [
'mb_id' => $this->request->getPost('mb_id'),
'mb_name' => $this->request->getPost('mb_name'),
'mb_email' => pii_encrypt($this->request->getPost('mb_email')),
'mb_phone' => pii_encrypt($this->request->getPost('mb_phone')),
'mb_level' => $requestedLevel,
'mb_state' => (int) $this->request->getPost('mb_state'),
];
if ($passwd !== '') {
$data['mb_passwd'] = password_hash($passwd, PASSWORD_DEFAULT);
}
$this->memberModel->update($id, $data);
return redirect()->to(site_url('admin/users'))->with('success', '회원 정보가 수정되었습니다.');
}
/**
* 현재 로그인한 관리자가 부여 가능한 역할 목록.
* super admin만 super admin(4) 부여 가능, 그 외는 1~3만 허용.
*
* @return array<int,string>
*/
private function getAssignableLevels(): array
{
$levelNames = $this->roles->levelNames;
$myLevel = (int) session()->get('mb_level');
if ($myLevel === Roles::LEVEL_SUPER_ADMIN) {
return $levelNames;
}
unset($levelNames[Roles::LEVEL_SUPER_ADMIN]);
return $levelNames;
}
/**
* 회원 삭제(탈퇴 처리: mb_state=0, mb_leavedate 기록)
*/
public function delete(int $id)
{
$member = $this->memberModel->find($id);
if (! $member) {
return redirect()->to(site_url('admin/users'))->with('error', '회원을 찾을 수 없습니다.');
}
$this->memberModel->update($id, [
'mb_state' => 0,
'mb_leavedate' => date('Y-m-d H:i:s'),
]);
return redirect()->to(site_url('admin/users'))->with('success', '회원이 탈퇴 처리되었습니다.');
}
}

300
app/Controllers/Auth.php Normal file
View File

@@ -0,0 +1,300 @@
<?php
namespace App\Controllers;
use App\Models\LocalGovernmentModel;
use App\Models\MemberApprovalRequestModel;
use App\Models\MemberLogModel;
use App\Models\MemberModel;
class Auth extends BaseController
{
/** mb_state: 1=정상, 2=정지, 0=탈퇴 */
private const MB_STATE_NORMAL = 1;
private const MB_STATE_BANNED = 2;
private const MB_STATE_LEAVE = 0;
public function showLoginForm()
{
if (session()->get('logged_in')) {
return redirect()->to('/');
}
return view('auth/login');
}
public function login()
{
$rules = [
'login_id' => 'required|max_length[50]',
'password' => 'required|max_length[255]',
];
$messages = [
'login_id' => [
'required' => '아이디를 입력해 주세요.',
'max_length' => '아이디는 50자 이하여야 합니다.',
],
'password' => [
'required' => '비밀번호를 입력해 주세요.',
'max_length' => '비밀번호는 255자 이하여야 합니다.',
],
];
if (! $this->validate($rules, $messages)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
$loginId = trim($this->request->getPost('login_id'));
$password = $this->request->getPost('password');
$memberModel = model(MemberModel::class);
$member = $memberModel->findByLoginId($loginId);
$approvalModel = model(MemberApprovalRequestModel::class);
$logData = $this->buildLogData($loginId, $member?->mb_idx);
if ($member === null) {
$this->insertMemberLog($logData, false, '회원 정보를 찾을 수 없습니다.');
return redirect()->back()
->withInput()
->with('error', '아이디 또는 비밀번호가 올바르지 않습니다.');
}
if ($member->mb_state === self::MB_STATE_LEAVE) {
$this->insertMemberLog($logData, false, '탈퇴한 회원입니다.');
return redirect()->back()
->withInput()
->with('error', '탈퇴한 회원입니다.');
}
if ($member->mb_state === self::MB_STATE_BANNED) {
$this->insertMemberLog($logData, false, '정지된 회원입니다.');
return redirect()->back()
->withInput()
->with('error', '정지된 회원입니다.');
}
if (! password_verify($password, $member->mb_passwd)) {
$this->insertMemberLog($logData, false, '비밀번호 불일치');
return redirect()->back()
->withInput()
->with('error', '아이디 또는 비밀번호가 올바르지 않습니다.');
}
// 승인 요청 상태 확인(공개 회원가입 사용자)
$latestApproval = $approvalModel->getLatestByMember((int) $member->mb_idx);
if ($latestApproval !== null) {
if ($latestApproval->mar_status === MemberApprovalRequestModel::STATUS_PENDING) {
$this->insertMemberLog($logData, false, '승인 대기 상태');
return redirect()->back()
->withInput()
->with('error', '관리자 승인 후 로그인 가능합니다.');
}
if ($latestApproval->mar_status === MemberApprovalRequestModel::STATUS_REJECTED) {
$this->insertMemberLog($logData, false, '승인 반려 상태');
return redirect()->back()
->withInput()
->with('error', '승인이 반려되었습니다. 관리자에게 문의해 주세요.');
}
}
// 로그인 성공
$sessionData = [
'mb_idx' => $member->mb_idx,
'mb_id' => $member->mb_id,
'mb_name' => $member->mb_name,
'mb_level' => $member->mb_level,
'mb_lg_idx' => $member->mb_lg_idx ?? null,
'logged_in' => true,
];
session()->set($sessionData);
$memberModel->update($member->mb_idx, [
'mb_latestdate' => date('Y-m-d H:i:s'),
]);
$this->insertMemberLog($logData, true, '로그인 성공', $member->mb_idx);
// 지자체 관리자 → 관리자 대시보드로 이동
if ((int) $member->mb_level === \Config\Roles::LEVEL_LOCAL_ADMIN) {
return redirect()->to(site_url('admin'))->with('success', '로그인되었습니다.');
}
// super admin → 지자체 선택 페이지로 이동 (선택 후 관리자 페이지 사용)
if ((int) $member->mb_level === \Config\Roles::LEVEL_SUPER_ADMIN) {
return redirect()->to(site_url('admin/select-local-government'))->with('success', '로그인되었습니다.');
}
return redirect()->to(site_url('/'))->with('success', '로그인되었습니다.');
}
public function logout()
{
if (session()->get('logged_in')) {
$mbIdx = session()->get('mb_idx');
$mbId = session()->get('mb_id');
$log = model(MemberLogModel::class)
->where('mb_idx', $mbIdx)
->where('mll_success', 1)
->orderBy('mll_idx', 'DESC')
->first();
if ($log !== null) {
model(MemberLogModel::class)->update($log->mll_idx, [
'mll_logout_date' => date('Y-m-d H:i:s'),
]);
} else {
model(MemberLogModel::class)->insert([
'mll_success' => 1,
'mb_idx' => $mbIdx,
'mb_id' => $mbId ?? '',
'mll_regdate' => date('Y-m-d H:i:s'),
'mll_ip' => $this->request->getIPAddress(),
'mll_msg' => '로그아웃',
'mll_useragent' => substr((string) $this->request->getUserAgent(), 0, 500),
'mll_logout_date' => date('Y-m-d H:i:s'),
]);
}
}
session()->destroy();
return redirect()->to('login')->with('success', '로그아웃되었습니다.');
}
public function showRegisterForm()
{
$localGovernments = model(LocalGovernmentModel::class)
->where('lg_state', 1)
->orderBy('lg_name', 'ASC')
->findAll();
return view('auth/register', [
'localGovernments' => $localGovernments,
]);
}
public function register()
{
$rules = [
'mb_id' => 'required|min_length[4]|max_length[50]|is_unique[member.mb_id]',
'mb_passwd' => 'required|min_length[4]|max_length[255]',
'mb_passwd_confirm' => 'required|matches[mb_passwd]',
'mb_name' => 'required|max_length[100]',
'mb_email' => 'permit_empty|valid_email|max_length[100]',
'mb_phone' => 'permit_empty|max_length[20]',
'mb_lg_idx' => 'permit_empty|is_natural_no_zero',
'mb_level' => 'required|in_list[1,2,3]',
];
$messages = [
'mb_id' => [
'required' => '아이디를 입력해 주세요.',
'min_length' => '아이디는 4자 이상이어야 합니다.',
'max_length' => '아이디는 50자 이하여야 합니다.',
'is_unique' => '이미 사용 중인 아이디입니다.',
],
'mb_passwd' => [
'required' => '비밀번호를 입력해 주세요.',
'min_length' => '비밀번호는 4자 이상이어야 합니다.',
'max_length' => '비밀번호는 255자 이하여야 합니다.',
],
'mb_passwd_confirm' => [
'required' => '비밀번호 확인을 입력해 주세요.',
'matches' => '비밀번호가 일치하지 않습니다.',
],
'mb_name' => [
'required' => '이름을 입력해 주세요.',
'max_length' => '이름은 100자 이하여야 합니다.',
],
'mb_email' => [
'valid_email' => '올바른 이메일 형식이 아닙니다.',
'max_length' => '이메일은 100자 이하여야 합니다.',
],
'mb_phone' => [
'max_length' => '연락처는 20자 이하여야 합니다.',
],
'mb_level' => [
'required' => '사용자 역할을 선택해 주세요.',
'in_list' => '유효하지 않은 역할입니다.',
],
];
if (! $this->validate($rules, $messages)) {
return redirect()->back()
->withInput()
->with('errors', $this->validator->getErrors());
}
$mbLevel = (int) $this->request->getPost('mb_level');
if (! config('Roles')->isValidLevel($mbLevel)) {
$mbLevel = config('Roles')->defaultLevelForSelfRegister;
}
$lgIdx = $this->request->getPost('mb_lg_idx');
$mbLgIdx = ($lgIdx !== null && $lgIdx !== '' && (int) $lgIdx > 0) ? (int) $lgIdx : null;
helper('pii_encryption');
$data = [
'mb_id' => $this->request->getPost('mb_id'),
'mb_passwd' => password_hash($this->request->getPost('mb_passwd'), PASSWORD_DEFAULT),
'mb_name' => $this->request->getPost('mb_name'),
'mb_email' => pii_encrypt($this->request->getPost('mb_email') ?? ''),
'mb_phone' => pii_encrypt($this->request->getPost('mb_phone') ?? ''),
'mb_lang' => 'ko',
// 공개 회원가입 시점에는 역할을 활성화하지 않고 기본 레벨로 생성(승인 후 requested_level 반영)
'mb_level' => \Config\Roles::LEVEL_CITIZEN,
'mb_group' => '',
'mb_lg_idx' => $mbLgIdx,
'mb_state' => 1,
'mb_regdate' => date('Y-m-d H:i:s'),
];
$memberModel = model(MemberModel::class);
if (! $memberModel->insert($data)) {
return redirect()->back()
->withInput()
->with('error', '회원가입 처리 중 오류가 발생했습니다. 다시 시도해 주세요.');
}
$newMemberIdx = (int) $memberModel->getInsertID();
$approvalModel = model(MemberApprovalRequestModel::class);
$approvalModel->insert([
'mb_idx' => $newMemberIdx,
'mar_requested_level' => $mbLevel,
'mar_status' => MemberApprovalRequestModel::STATUS_PENDING,
'mar_request_note' => '',
'mar_reject_reason' => null,
'mar_requested_at' => date('Y-m-d H:i:s'),
'mar_requested_by' => $newMemberIdx,
'mar_processed_at' => null,
'mar_processed_by' => null,
]);
return redirect()->to('login')->with('success', '회원가입이 완료되었습니다. 관리자 승인 후 로그인 가능합니다.');
}
private function buildLogData(string $mbId, ?int $mbIdx): array
{
return [
'mb_idx' => $mbIdx,
'mb_id' => $mbId,
'mll_regdate' => date('Y-m-d H:i:s'),
'mll_ip' => $this->request->getIPAddress(),
'mll_useragent' => substr((string) $this->request->getUserAgent(), 0, 500),
'mll_url' => current_url(),
'mll_referer' => $this->request->getServer('HTTP_REFERER'),
];
}
private function insertMemberLog(array $data, bool $success, string $msg, ?int $mbIdx = null): void
{
$data['mll_success'] = $success ? 1 : 0;
$data['mll_msg'] = $msg;
if ($mbIdx !== null) {
$data['mb_idx'] = $mbIdx;
}
model(MemberLogModel::class)->insert($data);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* BaseController provides a convenient place for loading components
* and performing functions that are needed by all your controllers.
*
* Extend this class in any new controllers:
* ```
* class Home extends BaseController
* ```
*
* For security, be sure to declare any new methods as protected or private.
*/
abstract class BaseController extends Controller
{
/**
* Be sure to declare properties for any property fetch you initialized.
* The creation of dynamic property is deprecated in PHP 8.2.
*/
// protected $session;
/**
* @return void
*/
public function initController(RequestInterface $request, ResponseInterface $response, LoggerInterface $logger)
{
// Load here all helpers you want to be available in your controllers that extend BaseController.
// Caution: Do not put the this below the parent::initController() call below.
// $this->helpers = ['form', 'url'];
// Caution: Do not edit this line.
parent::initController($request, $response, $logger);
// Preload any models, libraries, etc, here.
// $this->session = service('session');
}
}

107
app/Controllers/Home.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
namespace App\Controllers;
use App\Models\LocalGovernmentModel;
class Home extends BaseController
{
public function index()
{
if (session()->get('logged_in')) {
return $this->dashboard();
}
return view('welcome_message');
}
/**
* 로그인 후 원래 메인 화면 (admin 유사 레이아웃 + site 메뉴 호버 드롭다운)
*/
public function dashboard()
{
return view('bag/daily_inventory');
}
/**
* 디자인 시안(기존 /dashboard 연결 화면)
*/
public function dashboardClassicMock()
{
return $this->renderDashboard();
}
/**
* 로그인 후 메인 — 모던형(세로 사이드바) 레이아웃. URL: /dashboard/modern
*/
public function dashboardModern()
{
return view('bag/lg_dashboard_modern', [
'lgLabel' => $this->resolveLgLabel(),
]);
}
/**
* 로그인 후 메인 — 정보 집약형 종합 현황. URL: /dashboard/dense
*/
public function dashboardDense()
{
return view('bag/lg_dashboard_dense', [
'lgLabel' => $this->resolveLgLabel(),
]);
}
/**
* 로그인 후 메인 — 그래프 중심(Chart.js). URL: /dashboard/charts
*/
public function dashboardCharts()
{
return view('bag/lg_dashboard_charts', [
'lgLabel' => $this->resolveLgLabel(),
]);
}
/**
* 재고 조회(수불) 화면 (목업)
*/
public function inventoryInquiry()
{
return view('bag/inventory_inquiry');
}
/**
* 종량제 수불 그리드 (엔터프라이즈형 목업, 상단 가로 메뉴 + 병합 헤더 표)
*/
public function wasteSuibalEnterprise()
{
return view('bag/waste_suibal_enterprise');
}
protected function renderDashboard()
{
return view('bag/lg_dashboard', [
'lgLabel' => $this->resolveLgLabel(),
]);
}
/**
* 세션 mb_lg_idx 기준 지자체명 (DB 없거나 실패 시 데모용 문구)
*/
protected function resolveLgLabel(): string
{
try {
$idx = session()->get('mb_lg_idx');
if ($idx === null || $idx === '') {
return '로그인 지자체 (미지정)';
}
$row = model(LocalGovernmentModel::class)->find((int) $idx);
if ($row && isset($row->lg_name) && $row->lg_name !== '') {
return (string) $row->lg_name;
}
} catch (\Throwable $e) {
// 테이블 미생성 등
}
return '북구 (데모)';
}
}