Compare commits

...

15 Commits

Author SHA1 Message Date
taekyoungc
5c89c963ee 단가 기간이 겹칠 때 최신 등록 단가를 우선 적용한다.
단가 조회 공통 로직을 모델로 통합하고 발주·판매·주문·사이트 화면의 단가 계산이 모두 최신 등록 순서(bp_regdate, bp_idx DESC)를 따르도록 맞춘다.

Made-with: Cursor
2026-04-22 15:35:36 +09:00
taekyoungc
05c479397b 업체·담당자·단가·지정판매소 관리 화면의 조회 및 표시를 개선한다.
관리 화면에서 유형별 조회와 순번 표기를 통일하고, 지정판매소 주소/구군 표시와 포장단위 이력 표현을 사용자 관점으로 정리한다.

Made-with: Cursor
2026-04-22 15:35:28 +09:00
taekyoungc
647d5f919d 지정판매소 주소·지도 연동과 관련 설정을 반영
지정판매소 등록/수정/목록에 카카오 주소 검색 및 지도 연동 컴포넌트를 적용하고, 관련 모델·SQL 스크립트·테스트 설정을 함께 정리해 기능 동작 기반을 맞췄다.

Made-with: Cursor
2026-04-14 14:55:12 +09:00
taekyoungc
0b4c622b99 기본코드관리 2분할 조회와 무료용 목록 컬럼 정리
기본코드관리에서 코드종류 선택 시 같은 화면 우측에 세부코드가 즉시 보이도록 2분할 UI로 전환하고, 무료용 대상자 목록의 불필요한 구분 컬럼을 숨겨 화면 구성을 단순화했다.

Made-with: Cursor
2026-04-14 14:49:15 +09:00
taekyoungc
40db578e85 지정판매소 소메뉴 활성 상태를 단일 선택으로 보정
지정판매소 관련 형제 소메뉴가 동시에 활성화되던 문제를 해결하고, bag/admin 레이아웃 모두에서 현재 경로 기준으로 가장 구체적인 하위 메뉴 하나만 활성화되도록 통일했다.

Made-with: Cursor
2026-04-14 11:59:33 +09:00
taekyoungc
5d733ac0d8 Revert "운영 메뉴에서 지정판매소 활성 상태가 중복되지 않도록 보정"
This reverts commit 48e5578611.
2026-04-14 00:41:14 +09:00
taekyoungc
2629644f90 Revert "운영 Whoops 방지를 위해 메뉴 활성 계산 의존성을 단순화"
This reverts commit c8d1612f0e.
2026-04-14 00:41:14 +09:00
taekyoungc
c8d1612f0e 운영 Whoops 방지를 위해 메뉴 활성 계산 의존성을 단순화
레이아웃에서 내부 헬퍼 함수를 직접 호출하지 않고 공개 메뉴 매칭 함수만 사용하도록 변경해 운영 환경 차이에 따른 오류 가능성을 줄였습니다.
2026-04-14 00:38:51 +09:00
taekyoungc
48e5578611 운영 메뉴에서 지정판매소 활성 상태가 중복되지 않도록 보정
상단 메뉴 활성 판정을 최장 경로 1건 우선으로 통일해 조회 화면에서 관리 메뉴가 함께 활성화되는 문제를 막았습니다.
2026-04-14 00:33:24 +09:00
taekyoungc
078fa5d0c2 운영 URL에서도 bag 화면은 사이트 메뉴 레이아웃을 사용하도록 수정
요청 경로를 정규화해 bag 접두를 판별하도록 변경하고 지정판매소 경로의 관리자 레이아웃 강제 분기를 제거했습니다.
2026-04-14 00:28:02 +09:00
taekyoungc
734a55833b 지정판매소 현황·바코드 출력 기능을 전용 화면으로 확장
지정판매소 신규/취소 현황을 사용자 지자체 기준으로 고정 조회하도록 정리하고, 동별 요약과 컬럼 설명 툴팁을 추가했습니다.
또한 지정판매소 바코드 출력 메뉴를 전용 URL로 분리하고 선택 인쇄/출력 레이아웃을 GBMS 형태에 맞춰 구현했습니다.
2026-04-14 00:14:53 +09:00
taekyoungc
72578f200c chore: remove temporary db_diag from packaging units
Drop the public db diagnostic panel now that the runtime DB endpoint was identified.
2026-04-09 13:01:31 +09:00
taekyoungc
8e859f420d chore: show runtime DB server identity in db_diag
Add config and server-level DB identity fields (host/port/user, @@hostname/@@port/@@version) to packaging-units db_diag so production can verify the exact runtime DB endpoint.
2026-04-09 12:54:35 +09:00
taekyoungc
cd2d41b3d7 chore: add db diagnostic mode on packaging units page
Expose a temporary db_diag=1 view for /bag/packaging-units so we can verify runtime DB connectivity and required table counts directly on production.
2026-04-09 12:48:37 +09:00
taekyoungc
f22b1480a3 fix: add runtime logging for code-kind lookup failures
Capture detailed runtime context when /bag/code-kinds and /bag/code-details fail so production logs reveal the exact exception source and request/session scope.
2026-04-09 12:48:37 +09:00
57 changed files with 6185 additions and 482 deletions

View File

@@ -0,0 +1,10 @@
---
description: 패키지 설치 전 승인·안정성 확인 및 공급망 보안 습관
alwaysApply: true
---
# 의존성·패키지 보안
- **새 패키지(npm, Composer 등)를 설치·추가하기 전에 반드시 사용자에게 먼저 물어본다.** 자동으로 `npm install`, `composer require` 등을 실행하지 않는다(사용자가 명시적으로 요청한 경우만).
- 새 버전을 제안할 때는 **공식 레지스트리(npmjs.org, packagist.org) 출처**인지 확인하고, **출시된 지 최소 며칠(가이드: 7일) 이상 지난 안정(stable) 버전**을 우선 제안한다. 방금 출시된 버전은 typosquat·피싱 패키지 위험이 있어 사용자에게 그 점을 짚어 준다.
- 락 파일(`package-lock.json`, `composer.lock`)을 대량 수정하거나 생소한 패키지를 넣지 않는다. 이상하면 사용자에게 중단하고 확인을 요청한다.

26
app/Config/Kakao.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 카카오 Developers — 내 애플리케이션 — 앱 키 — JavaScript 키
* .env 예: kakao.javascriptKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
*/
class Kakao extends BaseConfig
{
public string $javascriptKey = '';
public function __construct()
{
parent::__construct();
$v = env('kakao.javascriptKey');
if (is_string($v) && $v !== '') {
$this->javascriptKey = $v;
}
}
}

View File

@@ -83,7 +83,13 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('designated-shops/export', 'Admin\DesignatedShop::export'); $routes->get('designated-shops/export', 'Admin\DesignatedShop::export');
$routes->get('designated-shops/map', 'Admin\DesignatedShop::map'); $routes->get('designated-shops/map', 'Admin\DesignatedShop::map');
$routes->get('designated-shops/status/export', 'Admin\DesignatedShop::statusExport');
$routes->get('designated-shops/status', 'Admin\DesignatedShop::status'); $routes->get('designated-shops/status', 'Admin\DesignatedShop::status');
$routes->get('designated-shops/barcode', 'Admin\DesignatedShop::barcode');
$routes->post('designated-shops/barcode/print', 'Admin\DesignatedShop::barcodePrint');
$routes->get('designated-shops/district-new-cancel/export', 'Admin\DesignatedShop::districtNewCancelExport');
$routes->get('designated-shops/district-new-cancel', 'Admin\DesignatedShop::districtNewCancel');
$routes->get('designated-shops/browse', 'Admin\DesignatedShop::browse');
$routes->get('designated-shops', 'Admin\DesignatedShop::index'); $routes->get('designated-shops', 'Admin\DesignatedShop::index');
$routes->get('designated-shops/create', 'Admin\DesignatedShop::create'); $routes->get('designated-shops/create', 'Admin\DesignatedShop::create');
$routes->post('designated-shops/store', 'Admin\DesignatedShop::store'); $routes->post('designated-shops/store', 'Admin\DesignatedShop::store');

View File

@@ -9,8 +9,11 @@ use App\Models\BagPriceModel;
use App\Models\PackagingUnitModel; use App\Models\PackagingUnitModel;
use App\Models\CompanyModel; use App\Models\CompanyModel;
use App\Models\SalesAgencyModel; use App\Models\SalesAgencyModel;
use App\Models\BagReceivingModel;
use App\Models\CodeKindModel; use App\Models\CodeKindModel;
use App\Models\CodeDetailModel; use App\Models\CodeDetailModel;
use App\Models\LocalGovernmentModel;
use App\Libraries\Blockchain\SqlLedger;
class BagOrder extends BaseController class BagOrder extends BaseController
{ {
private BagOrderModel $orderModel; private BagOrderModel $orderModel;
@@ -30,36 +33,76 @@ class BagOrder extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$builder = $this->orderModel->where('bo_lg_idx', $lgIdx); $startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
$endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
// 기간 필터 if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
$startDate = $this->request->getGet('start_date'); $startMonth = date('Y-m');
$endDate = $this->request->getGet('end_date'); }
$status = $this->request->getGet('status'); if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
if ($startDate) $builder->where('bo_order_date >=', $startDate); $endMonth = $startMonth;
if ($endDate) $builder->where('bo_order_date <=', $endDate); }
if ($status) $builder->where('bo_status', $status); if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
[$startMonth, $endMonth] = [$endMonth, $startMonth];
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->paginate(20);
$pager = $this->orderModel->pager;
// 발주별 품목 합계
$itemSummary = [];
foreach ($list as $order) {
$items = $this->itemModel->where('boi_bo_idx', $order->bo_idx)->findAll();
$totalQty = 0; $totalAmt = 0;
foreach ($items as $it) { $totalQty += (int) $it->boi_qty_sheet; $totalAmt += (float) $it->boi_amount; }
$itemSummary[$order->bo_idx] = ['qty' => $totalQty, 'amount' => $totalAmt, 'count' => count($items)];
} }
// 제작업체/대행소 이름 매핑 $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$companyMap = []; $agencyMap = []; $bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $c) $companyMap[$c->cp_idx] = $c->cp_name; $receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $a) { if (! in_array($receiveType, ['all', 'received', 'pending'], true)) {
$agencyMap[$a->sa_idx] = '[' . ($a->sa_kind ?? '') . '] ' . ($a->sa_code ?? '') . ' — ' . ($a->sa_name ?? ''); $receiveType = 'all';
} }
return $this->renderWorkPage('발주 현황', 'admin/bag_order/index', compact('list', 'itemSummary', 'companyMap', 'agencyMap', 'startDate', 'endDate', 'status', 'pager')); $companies = model(CompanyModel::class)
->where('cp_lg_idx', $lgIdx)
->where('cp_type', '제작업체')
->where('cp_state', 1)
->orderBy('cp_name', 'ASC')
->findAll();
$companyMap = [];
foreach ($companies as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
$agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? '');
}
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$bagNameMap = [];
foreach ($bagCodes as $code) {
$bagNameMap[(string) $code->cd_code] = (string) $code->cd_name;
}
$reportData = $this->buildOrderStatusRows(
$lgIdx,
$startMonth,
$endMonth,
$companyIdx,
$bagCode,
$receiveType,
$companyMap,
$agencyMap,
$bagNameMap
);
return $this->renderWorkPage(
'발주 현황',
'admin/bag_order/index',
[
'startMonth' => $startMonth,
'endMonth' => $endMonth,
'companyIdx' => $companyIdx,
'bagCode' => $bagCode,
'receiveType' => $receiveType,
'companyOptions' => $companies,
'bagCodeOptions' => $bagCodes,
'rows' => $reportData['rows'],
'groupRows' => $reportData['groupRows'],
'grandTotals' => $reportData['grandTotals'],
]
);
} }
public function export() public function export()
@@ -70,44 +113,240 @@ class BagOrder extends BaseController
return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(mgmt_url('bag-orders'))->with('error', '지자체를 선택해 주세요.');
} }
$builder = $this->orderModel->where('bo_lg_idx', $lgIdx); $startMonth = (string) ($this->request->getGet('start_month') ?? date('Y-m'));
$startDate = $this->request->getGet('start_date'); $endMonth = (string) ($this->request->getGet('end_month') ?? date('Y-m'));
$endDate = $this->request->getGet('end_date'); if (! preg_match('/^\d{4}-\d{2}$/', $startMonth)) {
$status = $this->request->getGet('status'); $startMonth = date('Y-m');
if ($startDate) $builder->where('bo_order_date >=', $startDate); }
if ($endDate) $builder->where('bo_order_date <=', $endDate); if (! preg_match('/^\d{4}-\d{2}$/', $endMonth)) {
if ($status) $builder->where('bo_status', $status); $endMonth = $startMonth;
}
if (strtotime($startMonth . '-01') > strtotime($endMonth . '-01')) {
[$startMonth, $endMonth] = [$endMonth, $startMonth];
}
$list = $builder->orderBy('bo_order_date', 'DESC')->orderBy('bo_idx', 'DESC')->findAll(); $companyIdx = (int) ($this->request->getGet('company_idx') ?? 0);
$bagCode = trim((string) ($this->request->getGet('bag_code') ?? ''));
$receiveType = (string) ($this->request->getGet('receive_type') ?? 'all');
if (! in_array($receiveType, ['all', 'received', 'pending'], true)) {
$receiveType = 'all';
}
$companyMap = [];
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->findAll() as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach (model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll() as $agency) {
$agencyMap[(int) $agency->sa_idx] = (string) ($agency->sa_name ?? '');
}
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$bagNameMap = [];
foreach ($bagCodes as $code) {
$bagNameMap[(string) $code->cd_code] = (string) $code->cd_name;
}
$reportData = $this->buildOrderStatusRows(
$lgIdx,
$startMonth,
$endMonth,
$companyIdx,
$bagCode,
$receiveType,
$companyMap,
$agencyMap,
$bagNameMap
);
$rows = []; $rows = [];
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; foreach ($reportData['rows'] as $row) {
foreach ($list as $row) { if (! empty($row['is_subtotal'])) {
$items = $this->itemModel->where('boi_bo_idx', $row->bo_idx)->findAll(); $rows[] = [
$totalQty = 0; '',
$totalAmt = 0; '',
foreach ($items as $it) { (string) ($row['label'] ?? '소계'),
$totalQty += (int) $it->boi_qty_sheet; (int) ($row['order_qty'] ?? 0),
$totalAmt += (float) $it->boi_amount; (int) ($row['received_qty'] ?? 0),
(int) ($row['pending_qty'] ?? 0),
(float) ($row['amount'] ?? 0),
'',
];
continue;
} }
$rows[] = [ $rows[] = [
$row->bo_idx, (string) ($row['order_date'] ?? ''),
$row->bo_lot_no, (string) ($row['company_name'] ?? ''),
$row->bo_order_date, (string) ($row['bag_name'] ?? ''),
count($items), (int) ($row['order_qty'] ?? 0),
$totalQty, (int) ($row['received_qty'] ?? 0),
$totalAmt, (int) ($row['pending_qty'] ?? 0),
$statusMap[$row->bo_status] ?? $row->bo_status, (float) ($row['amount'] ?? 0),
(string) ($row['agency_name'] ?? ''),
'',
]; ];
} }
export_csv( $gt = $reportData['grandTotals'] ?? [];
'발주현황_' . date('Ymd') . '.csv', $rows[] = [
['번호', 'LOT번호', '발주일', '품목수', '총수량', '총금액', '상태'], '',
'',
'총계',
(int) ($gt['order_qty'] ?? 0),
(int) ($gt['received_qty'] ?? 0),
(int) ($gt['pending_qty'] ?? 0),
(float) ($gt['amount'] ?? 0),
'',
'',
];
export_xlsx(
'발주현황_' . date('Ymd'),
'발주현황',
['발주일자', '제작업체', '품명', '발주수량', '입고수량', '미입고수량', '발주금액', '입고처', '비고'],
$rows $rows
); );
} }
/**
* 발주 현황(품목 기준) 행 및 소계를 만든다.
*/
private function buildOrderStatusRows(
int $lgIdx,
string $startMonth,
string $endMonth,
int $companyIdx,
string $bagCode,
string $receiveType,
array $companyMap,
array $agencyMap,
array $bagNameMap
): array {
$startDate = $startMonth . '-01';
$endDate = date('Y-m-t', strtotime($endMonth . '-01 00:00:00'));
$builder = $this->orderModel
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->where('bo_order_date >=', $startDate)
->where('bo_order_date <=', $endDate)
->whereIn('bo_status', ['normal', 'cancelled'])
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC');
if ($companyIdx > 0) {
$builder->where('bo_company_idx', $companyIdx);
}
$orders = $builder->findAll();
if (empty($orders)) {
return ['rows' => [], 'groupRows' => [], 'grandTotals' => ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0]];
}
$orderIds = array_map(static fn($order) => (int) $order->bo_idx, $orders);
$itemsByOrder = [];
if (! empty($orderIds)) {
$allItems = $this->itemModel
->whereIn('boi_bo_idx', $orderIds)
->orderBy('boi_bo_idx', 'DESC')
->orderBy('boi_idx', 'ASC')
->findAll();
foreach ($allItems as $item) {
$boIdx = (int) ($item->boi_bo_idx ?? 0);
if (! isset($itemsByOrder[$boIdx])) {
$itemsByOrder[$boIdx] = [];
}
$itemsByOrder[$boIdx][] = $item;
}
}
$receivedMap = [];
$receivingRows = model(BagReceivingModel::class)
->select('br_bo_idx, br_bag_code, SUM(br_qty_sheet) as recv_qty')
->where('br_lg_idx', $lgIdx)
->whereIn('br_bo_idx', $orderIds)
->groupBy('br_bo_idx, br_bag_code')
->findAll();
foreach ($receivingRows as $received) {
$key = (int) ($received->br_bo_idx ?? 0) . '|' . (string) ($received->br_bag_code ?? '');
$receivedMap[$key] = (int) ($received->recv_qty ?? 0);
}
$rows = [];
$groupRows = [];
$grandTotals = ['order_qty' => 0, 'received_qty' => 0, 'pending_qty' => 0, 'amount' => 0.0];
foreach ($orders as $order) {
$boIdx = (int) ($order->bo_idx ?? 0);
$items = $itemsByOrder[$boIdx] ?? [];
$groupCount = 0;
$groupTotalOrder = 0;
$groupTotalReceived = 0;
$groupTotalPending = 0;
$groupTotalAmount = 0.0;
foreach ($items as $item) {
$itemBagCode = (string) ($item->boi_bag_code ?? '');
if ($bagCode !== '' && $itemBagCode !== $bagCode) {
continue;
}
$orderQty = (int) ($item->boi_qty_sheet ?? 0);
$recvQty = (int) ($receivedMap[$boIdx . '|' . $itemBagCode] ?? 0);
if ($recvQty > $orderQty) {
$recvQty = $orderQty;
}
$pendingQty = max(0, $orderQty - $recvQty);
if ($receiveType === 'received' && $recvQty <= 0) {
continue;
}
if ($receiveType === 'pending' && $pendingQty <= 0) {
continue;
}
$amount = (float) ($item->boi_amount ?? 0);
$rows[] = [
'bo_idx' => $boIdx,
'order_date' => (string) ($order->bo_order_date ?? ''),
'company_name' => (string) ($companyMap[(int) ($order->bo_company_idx ?? 0)] ?? ''),
'bag_name' => (string) ($item->boi_bag_name ?? ($bagNameMap[$itemBagCode] ?? $itemBagCode)),
'order_qty' => $orderQty,
'received_qty' => $recvQty,
'pending_qty' => $pendingQty,
'amount' => $amount,
'agency_name' => (string) ($agencyMap[(int) ($order->bo_agency_idx ?? 0)] ?? ''),
];
$groupCount++;
$groupTotalOrder += $orderQty;
$groupTotalReceived += $recvQty;
$groupTotalPending += $pendingQty;
$groupTotalAmount += $amount;
}
if ($groupCount > 0) {
$groupRows[$boIdx] = $groupCount;
$rows[] = [
'bo_idx' => $boIdx,
'is_subtotal' => true,
'label' => '소계',
'order_qty' => $groupTotalOrder,
'received_qty' => $groupTotalReceived,
'pending_qty' => $groupTotalPending,
'amount' => $groupTotalAmount,
];
$grandTotals['order_qty'] += $groupTotalOrder;
$grandTotals['received_qty'] += $groupTotalReceived;
$grandTotals['pending_qty'] += $groupTotalPending;
$grandTotals['amount'] += $groupTotalAmount;
}
}
return ['rows' => $rows, 'groupRows' => $groupRows, 'grandTotals' => $grandTotals];
}
public function create() public function create()
{ {
helper('admin'); helper('admin');
@@ -119,18 +358,105 @@ class BagOrder extends BaseController
// 봉투 종류 + 단가 + 포장단위 // 봉투 종류 + 단가 + 포장단위
$kind = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kind = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; $bagCodes = $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
$prices = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_state', 1)->findAll(); $priceMapRows = model(BagPriceModel::class)->latestActiveMapByBagCode($lgIdx);
$units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll(); $units = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_state', 1)->findAll();
$companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
$agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
return $this->renderWorkPage('발주 등록', 'admin/bag_order/create', compact('bagCodes', 'prices', 'units', 'companies', 'agencies')); $companies = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '제작업체')->where('cp_state', 1)->findAll();
$associations = model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->where('cp_type', '협회')->where('cp_state', 1)->findAll();
$agencies = model(SalesAgencyModel::class)->where('sa_lg_idx', $lgIdx)->orderForDisplay()->findAll();
$companyMap = [];
foreach (model(CompanyModel::class)->where('cp_lg_idx', $lgIdx)->findAll() as $company) {
$companyMap[(int) $company->cp_idx] = (string) $company->cp_name;
}
$agencyMap = [];
foreach ($agencies as $agency) {
$agencyMap[(int) $agency->sa_idx] = '[' . ($agency->sa_kind ?? '') . '] ' . ($agency->sa_code ?? '') . ' — ' . ($agency->sa_name ?? '');
}
$recentOrders = $this->orderModel
->where('bo_lg_idx', $lgIdx)
->whereLatestHead($lgIdx)
->orderBy('bo_order_date', 'DESC')
->orderBy('bo_idx', 'DESC')
->findAll(12);
$bagNameMap = [];
foreach ($bagCodes as $codeDetail) {
$bagNameMap[(string) $codeDetail->cd_code] = (string) $codeDetail->cd_name;
}
$priceMap = [];
foreach ($priceMapRows as $bagCode => $price) {
$priceMap[(string) $bagCode] = (float) ($price->bp_order_price ?? 0);
}
$unitMap = [];
foreach ($units as $unit) {
$unitMap[(string) $unit->pu_bag_code] = [
'boxPerPack' => (int) $unit->pu_box_per_pack,
'packPerSheet' => (int) $unit->pu_pack_per_sheet,
'totalPerBox' => (int) $unit->pu_total_per_box,
];
}
$bagReferenceRows = [];
foreach ($bagCodes as $codeDetail) {
$bagCode = (string) $codeDetail->cd_code;
$unit = $unitMap[$bagCode] ?? ['boxPerPack' => 0, 'packPerSheet' => 0, 'totalPerBox' => 0];
$bagReferenceRows[] = [
'code' => $bagCode,
'name' => (string) ($bagNameMap[$bagCode] ?? ''),
'orderPrice' => (float) ($priceMap[$bagCode] ?? 0),
'boxPerPack' => (int) $unit['boxPerPack'],
'packPerSheet' => (int) $unit['packPerSheet'],
'totalPerBox' => (int) $unit['totalPerBox'],
];
}
return $this->renderWorkPage(
'발주 등록',
'admin/bag_order/create',
compact(
'bagCodes',
'units',
'companies',
'associations',
'agencies',
'recentOrders',
'companyMap',
'agencyMap',
'bagReferenceRows'
)
);
}
public function revise(int $id)
{
helper('admin');
$lgIdx = admin_effective_lg_idx();
$order = $this->orderModel->find($id);
if (! $order || (int) $order->bo_lg_idx !== $lgIdx) {
return redirect()->to(mgmt_url('bag-orders'))->with('error', '발주를 찾을 수 없습니다.');
}
return redirect()->to(site_url('bag/order/revise/' . $id));
} }
public function store() public function store()
{ {
helper('admin'); helper('admin');
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null || $lgIdx <= 0) {
return redirect()->back()->withInput()->with('error', '지자체를 선택해 주세요.');
}
$sourceIdx = (int) ($this->request->getPost('bo_source_idx') ?? 0);
$sourceOrder = null;
if ($sourceIdx > 0) {
$sourceOrder = $this->orderModel->find($sourceIdx);
if (! $sourceOrder || (int) $sourceOrder->bo_lg_idx !== $lgIdx) {
return redirect()->back()->withInput()->with('error', '수정 대상 발주를 찾을 수 없습니다.');
}
}
$rules = [ $rules = [
'bo_order_date' => 'required|valid_date[Y-m-d]', 'bo_order_date' => 'required|valid_date[Y-m-d]',
@@ -141,65 +467,114 @@ class BagOrder extends BaseController
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
} }
$bagCodes = $this->request->getPost('item_bag_code') ?? [];
$qtySheets = $this->request->getPost('item_qty_sheet') ?? [];
$qtyBoxes = $this->request->getPost('item_qty_box') ?? []; // 구 화면 호환
$postedUnitPrices = $this->request->getPost('item_unit_price');
$changeKind = (string) ($this->request->getPost('bo_change_mode') ?? 'meta');
if (! in_array($changeKind, ['price', 'meta', 'delete'], true)) {
$changeKind = 'meta';
}
$itemCount = count($bagCodes);
$normalizedItems = [];
for ($i = 0; $i < $itemCount; $i++) {
$code = trim((string) ($bagCodes[$i] ?? ''));
$qtySheet = (int) ($qtySheets[$i] ?? 0);
$qtyBox = (int) ($qtyBoxes[$i] ?? 0);
if ($code === '' || ($qtySheet <= 0 && $qtyBox <= 0)) {
continue;
}
$normalizedItems[] = ['code' => $code, 'qtySheet' => $qtySheet, 'qtyBox' => $qtyBox];
}
if (empty($normalizedItems)) {
return redirect()->back()->withInput()->with('error', '최소 1개 이상의 봉투 수량을 입력해 주세요.');
}
$priceByCode = [];
if ($sourceOrder !== null && $changeKind === 'price' && is_array($postedUnitPrices)) {
for ($pi = 0; $pi < count($bagCodes); $pi++) {
$c = trim((string) ($bagCodes[$pi] ?? ''));
if ($c === '') {
continue;
}
$raw = $postedUnitPrices[$pi] ?? null;
if ($raw !== null && $raw !== '' && is_numeric($raw)) {
$priceByCode[$c] = round((float) $raw, 2);
}
}
}
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$db->transStart(); $db->transStart();
// UUID 생성 try {
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', if ($sourceOrder) {
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), $uuid = (string) $sourceOrder->bo_uuid;
mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, $maxVerRow = $this->orderModel->selectMax('bo_version')->where('bo_uuid', $uuid)->first();
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); $latestVersion = ($maxVerRow !== null && isset($maxVerRow->bo_version)) ? (int) $maxVerRow->bo_version : 0;
$version = $latestVersion + 1;
// LOT 번호 생성 $lotNo = (string) $sourceOrder->bo_lot_no;
$lotNo = 'LOT-' . date('Ymd') . '-' . strtoupper(substr(md5($uuid), 0, 6)); } else {
$uuid = $this->generateUuidV4();
$version = 1;
$lotNo = $this->generateLotNo6();
}
$orderData = [ $orderData = [
'bo_uuid' => $uuid, 'bo_uuid' => $uuid,
'bo_version' => 1, 'bo_version' => $version,
'bo_lg_idx' => $lgIdx, 'bo_lg_idx' => $lgIdx,
'bo_gugun_code' => $this->request->getPost('bo_gugun_code') ?? '', 'bo_gugun_code' => $this->resolveGugunCodeFromLg($lgIdx),
'bo_dong_code' => $this->request->getPost('bo_dong_code') ?? '', 'bo_dong_code' => '',
'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null, 'bo_company_idx' => $this->request->getPost('bo_company_idx') ?: null,
'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null, 'bo_agency_idx' => $this->request->getPost('bo_agency_idx') ?: null,
'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0), 'bo_fee_rate' => (float) ($this->request->getPost('bo_fee_rate') ?: 0),
'bo_order_date' => $this->request->getPost('bo_order_date'), 'bo_order_date' => $this->request->getPost('bo_order_date'),
'bo_bag_types' => '',
'bo_unit_prices' => '',
'bo_qty_boxes' => '',
'bo_lot_no' => $lotNo, 'bo_lot_no' => $lotNo,
'bo_status' => 'normal', 'bo_status' => 'normal',
'bo_orderer_idx' => session()->get('mb_idx'), 'bo_orderer_idx' => session()->get('mb_idx'),
'bo_regdate' => date('Y-m-d H:i:s'), 'bo_regdate' => date('Y-m-d H:i:s'),
]; ];
// SHA-256 해시 // 품목 입력 후 최종 payload 기준으로 해시를 계산하므로 우선 빈값으로 생성
$orderData['bo_hash'] = hash('sha256', json_encode($orderData)); $orderData['bo_hash'] = '';
$this->orderModel->insert($orderData); $this->orderModel->insert($orderData);
$boIdx = (int) $this->orderModel->getInsertID(); $boIdx = (int) $this->orderModel->getInsertID();
// CT-05: 감사 로그
helper('audit');
audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx]));
// 품목 저장 // 품목 저장
$bagCodes = $this->request->getPost('item_bag_code') ?? []; $hashItems = [];
$qtyBoxes = $this->request->getPost('item_qty_box') ?? []; $bagTypesForHeader = [];
foreach ($bagCodes as $i => $code) { $unitPricesForHeader = [];
if (empty($code) || empty($qtyBoxes[$i])) continue; $qtyBoxesForHeader = [];
$qtyBox = (int) $qtyBoxes[$i]; foreach ($normalizedItems as $item) {
$code = $item['code'];
$qtySheetInput = (int) ($item['qtySheet'] ?? 0);
$qtyBoxInput = (int) ($item['qtyBox'] ?? 0);
// 포장단위에서 낱장 환산 // 포장단위에서 낱장 환산
$unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first(); $unit = model(PackagingUnitModel::class)->where('pu_lg_idx', $lgIdx)->where('pu_bag_code', $code)->where('pu_state', 1)->first();
$totalPerBox = $unit ? (int) $unit->pu_total_per_box : 1; $totalPerBox = $unit ? max(1, (int) $unit->pu_total_per_box) : 1;
$qtySheet = $qtyBox * $totalPerBox; $qtySheet = $qtySheetInput > 0 ? $qtySheetInput : ($qtyBoxInput * $totalPerBox);
if ($qtySheet <= 0) {
continue;
}
$qtyBox = intdiv($qtySheet, $totalPerBox);
// 단가 // 단가 (발주 변경·단가 구분 시 POST 단가 우선)
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first(); $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, $code);
$unitPrice = $price ? (float) $price->bp_order_price : 0; $unitPrice = $price ? (float) $price->bp_order_price : 0;
if ($sourceOrder !== null && isset($priceByCode[$code])) {
$unitPrice = $priceByCode[$code];
}
// 봉투명 // 봉투명
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null; $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $code, $lgIdx) : null;
$this->itemModel->insert([ $itemData = [
'boi_bo_idx' => $boIdx, 'boi_bo_idx' => $boIdx,
'boi_bag_code' => $code, 'boi_bag_code' => $code,
'boi_bag_name' => $detail ? $detail->cd_name : '', 'boi_bag_name' => $detail ? $detail->cd_name : '',
@@ -207,14 +582,204 @@ class BagOrder extends BaseController
'boi_qty_box' => $qtyBox, 'boi_qty_box' => $qtyBox,
'boi_qty_sheet' => $qtySheet, 'boi_qty_sheet' => $qtySheet,
'boi_amount' => $unitPrice * $qtySheet, 'boi_amount' => $unitPrice * $qtySheet,
]); ];
$this->itemModel->insert($itemData);
$hashItems[] = $itemData;
$bagTypesForHeader[] = [
'code' => $itemData['boi_bag_code'],
'name' => $itemData['boi_bag_name'],
];
$unitPricesForHeader[] = [
'code' => $itemData['boi_bag_code'],
'unit_price' => $itemData['boi_unit_price'],
];
$qtyBoxesForHeader[] = [
'code' => $itemData['boi_bag_code'],
'qty_box' => $itemData['boi_qty_box'],
];
} }
$db->transComplete(); $orderData['bo_bag_types'] = json_encode($bagTypesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
$orderData['bo_unit_prices'] = json_encode($unitPricesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
$orderData['bo_qty_boxes'] = json_encode($qtyBoxesForHeader, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
// 최종 발주 데이터(헤더+품목) 해시
$hashPayload = $orderData;
$hashPayload['bo_idx'] = $boIdx;
$hashPayload['items'] = $hashItems;
$hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$orderHash = hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx);
$this->orderModel->update($boIdx, [
'bo_bag_types' => $orderData['bo_bag_types'],
'bo_unit_prices' => $orderData['bo_unit_prices'],
'bo_qty_boxes' => $orderData['bo_qty_boxes'],
'bo_hash' => $orderHash,
]);
$beforeHash = $sourceOrder ? (string) ($sourceOrder->bo_hash ?? '') : '';
$seedFilePath = $this->generateBarcodeSeedFile($uuid, $version, $lotNo, $orderData, $hashItems, $orderHash);
$blockPayload = [
'bo_idx' => $boIdx,
'bo_uuid' => $uuid,
'bo_version' => $version,
'bo_lot_no' => $lotNo,
'bo_hash' => $orderHash,
'seed_file' => $seedFilePath,
'hash_chain' => $beforeHash !== '' ? [$beforeHash, $orderHash] : [$orderHash],
'order' => $orderData,
'items' => $hashItems,
];
$ledger = new SqlLedger();
$ledger->appendBlock(
$sourceOrder ? 'ORDER_UPDATE' : 'ORDER_CREATE',
$blockPayload,
$uuid,
$version,
session()->get('mb_idx') ? (int) session()->get('mb_idx') : null,
$lgIdx
);
// CT-05: 감사 로그
helper('audit');
if ($sourceOrder) {
audit_log(
'update',
'bag_order',
$boIdx,
['bo_idx' => (int) $sourceOrder->bo_idx, 'bo_hash' => $beforeHash, 'bo_version' => (int) $sourceOrder->bo_version],
array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath])
);
} else {
audit_log('create', 'bag_order', $boIdx, null, array_merge($orderData, ['bo_idx' => $boIdx, 'bo_hash' => $orderHash, 'items' => $hashItems, 'seed_file' => $seedFilePath]));
}
if (! $db->transComplete()) {
throw new \RuntimeException('Transaction did not complete');
}
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'BagOrder::store: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
return redirect()->back()->withInput()->with('error', '발주 저장 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요.');
}
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 등록되었습니다. LOT: ' . $lotNo);
} }
/** 효과 지자체(`local_government`)의 행정 구·군 코드(lg_code) */
private function resolveGugunCodeFromLg(int $lgIdx): string
{
$lg = model(LocalGovernmentModel::class)->find($lgIdx);
return $lg ? trim((string) ($lg->lg_code ?? '')) : '';
}
private function generateUuidV4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
private function generateLotNo6(): string
{
// 문서의 "LOT 번호 6 Byte" 요구를 맞추기 위해 영숫자 6자리로 생성한다.
// 충돌 가능성을 낮추기 위해 최대 20회 재시도 후 timestamp 기반으로 fallback.
$chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for ($attempt = 0; $attempt < 20; $attempt++) {
$lot = '';
for ($i = 0; $i < 6; $i++) {
$lot .= $chars[random_int(0, strlen($chars) - 1)];
}
$exists = $this->orderModel->where('bo_lot_no', $lot)->countAllResults() > 0;
if (! $exists) {
return $lot;
}
}
return strtoupper(substr(base_convert((string) time(), 10, 36), -6));
}
/**
* @param array<string,mixed> $orderData
* @param array<int,array<string,mixed>> $items
*/
private function generateBarcodeSeedFile(string $uuid, int $version, string $lotNo, array $orderData, array $items, string $orderHash): string
{
$baseDir = WRITEPATH . 'barcode-seeds';
if (! is_dir($baseDir)) {
mkdir($baseDir, 0775, true);
}
$keyDir = WRITEPATH . 'keys';
if (! is_dir($keyDir)) {
mkdir($keyDir, 0775, true);
}
$privateKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_private.pem';
$publicKeyPath = $keyDir . DIRECTORY_SEPARATOR . 'barcode_seed_public.pem';
if (! is_file($privateKeyPath) || ! is_file($publicKeyPath)) {
$config = [
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$resource = openssl_pkey_new($config);
if ($resource !== false) {
$privatePem = '';
openssl_pkey_export($resource, $privatePem);
$details = openssl_pkey_get_details($resource);
$publicPem = $details['key'] ?? '';
if ($privatePem !== '' && $publicPem !== '') {
file_put_contents($privateKeyPath, $privatePem);
file_put_contents($publicKeyPath, $publicPem);
}
}
}
$payload = [
'uuid' => $uuid,
'version' => $version,
'lot_no' => $lotNo,
'order_hash' => $orderHash,
'order' => $orderData,
'items' => $items,
];
$payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
$aesKey = random_bytes(32);
$iv = random_bytes(16);
$cipherRaw = openssl_encrypt($payloadJson, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
if ($cipherRaw === false) {
$cipherRaw = $payloadJson;
}
$encryptedKey = '';
$publicPem = is_file($publicKeyPath) ? file_get_contents($publicKeyPath) : '';
if (is_string($publicPem) && $publicPem !== '') {
openssl_public_encrypt($aesKey, $encryptedKey, $publicPem, OPENSSL_PKCS1_OAEP_PADDING);
}
$seed = [
'algorithm' => ['symmetric' => 'AES-256-CBC', 'asymmetric' => 'RSA-2048'],
'lot_no' => $lotNo,
'uuid' => $uuid,
'version' => $version,
'iv_b64' => base64_encode($iv),
'key_b64' => $encryptedKey !== '' ? base64_encode($encryptedKey) : '',
'cipher_b64' => base64_encode((string) $cipherRaw),
'payload_hash' => hash('sha256', $payloadJson),
'created_at' => date('c'),
];
$fileName = $lotNo . '_v' . $version . '.seed.json';
$fullPath = $baseDir . DIRECTORY_SEPARATOR . $fileName;
file_put_contents($fullPath, json_encode($seed, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return $fullPath;
}
public function detail(int $id) public function detail(int $id)
{ {
helper('admin'); helper('admin');
@@ -250,9 +815,11 @@ class BagOrder extends BaseController
} }
$before = (array) $order; $before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'cancelled', 'bo_moddate' => date('Y-m-d H:i:s')]); $beforeHash = (string) ($order->bo_hash ?? '');
$this->appendLedgerForStatusChange($order, $id, 'ORDER_CANCEL', 'cancelled', $beforeHash);
$after = (array) $this->orderModel->find($id);
helper('audit'); helper('audit');
audit_log('update', 'bag_order', $id, $before, ['bo_status' => 'cancelled']); audit_log('update', 'bag_order', $id, $before, $after);
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.'); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 취소되었습니다.');
} }
@@ -266,10 +833,117 @@ class BagOrder extends BaseController
} }
$before = (array) $order; $before = (array) $order;
$this->orderModel->update($id, ['bo_status' => 'deleted', 'bo_moddate' => date('Y-m-d H:i:s')]); $beforeHash = (string) ($order->bo_hash ?? '');
$this->appendLedgerForStatusChange($order, $id, 'ORDER_DELETE', 'deleted', $beforeHash);
$after = (array) $this->orderModel->find($id);
helper('audit'); helper('audit');
audit_log('delete', 'bag_order', $id, $before, ['bo_status' => 'deleted']); audit_log('delete', 'bag_order', $id, $before, $after);
return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 삭제 처리되었습니다.'); return redirect()->to(mgmt_url('bag-orders'))->with('success', '발주가 삭제 처리되었습니다.');
} }
/**
* 상태 변경 시(취소/삭제) 무결성 검증을 위해 bo_hash 재계산 후
* SQL-Ledger(append-only)에 블록을 추가한다.
*
* @param object $order
* @param int $boIdx
* @param string $txType ORDER_CANCEL|ORDER_DELETE
* @param string $newStatus cancelled|deleted
* @param string $previousHash
*/
private function appendLedgerForStatusChange(object $order, int $boIdx, string $txType, string $newStatus, string $previousHash): void
{
// 품목은 상태 변경 시 그대로이므로, 동일 payload 형태로 items array를 만든다.
$items = $this->itemModel->where('boi_bo_idx', $boIdx)->findAll();
$hashItems = [];
foreach ($items as $it) {
$hashItems[] = [
'boi_bo_idx' => (int) $it->boi_bo_idx,
'boi_bag_code' => (string) $it->boi_bag_code,
'boi_bag_name' => (string) ($it->boi_bag_name ?? ''),
'boi_unit_price' => (float) $it->boi_unit_price,
'boi_qty_box' => (int) $it->boi_qty_box,
'boi_qty_sheet' => (int) $it->boi_qty_sheet,
'boi_amount' => (float) $it->boi_amount,
];
}
$newOrder = $order;
$newOrder->bo_status = $newStatus;
$newHash = $this->computeOrderHash($boIdx, $newOrder, $hashItems);
$actorIdx = session()->get('mb_idx') ? (int) session()->get('mb_idx') : null;
$lgIdx = (int) ($order->bo_lg_idx ?? 0);
$seedFilePath = '';
$ledgerPayload = [
'bo_idx' => $boIdx,
'bo_uuid' => (string) $order->bo_uuid,
'bo_version' => (int) $order->bo_version,
'bo_lot_no' => (string) $order->bo_lot_no,
'bo_hash' => $newHash,
'seed_file' => $seedFilePath,
'hash_chain' => [$previousHash, $newHash],
'order' => [
'bo_status' => $newStatus,
'bo_hash' => $newHash,
],
'items' => $hashItems,
];
$ledger = new SqlLedger();
$ledger->appendBlock(
$txType,
$ledgerPayload,
(string) $order->bo_uuid,
(int) $order->bo_version,
$actorIdx,
$lgIdx
);
// order row에 hash 반영
$this->orderModel->update($boIdx, [
'bo_status' => $newStatus,
'bo_moddate' => date('Y-m-d H:i:s'),
'bo_hash' => $newHash,
]);
}
/**
* store()에서 생성하는 bo_hash와 동일한 "헤더+items" 규격을 사용해 SHA-256을 계산한다.
*
* @param int $boIdx
* @param object $order
* @param array<int,array<string,mixed>> $hashItems
*/
private function computeOrderHash(int $boIdx, object $order, array $hashItems): string
{
$orderData = [
'bo_uuid' => (string) $order->bo_uuid,
'bo_version' => (int) $order->bo_version,
'bo_lg_idx' => (int) $order->bo_lg_idx,
'bo_gugun_code' => (string) ($order->bo_gugun_code ?? ''),
'bo_dong_code' => (string) ($order->bo_dong_code ?? ''),
'bo_company_idx' => $order->bo_company_idx !== null ? (int) $order->bo_company_idx : null,
'bo_agency_idx' => $order->bo_agency_idx !== null ? (int) $order->bo_agency_idx : null,
'bo_fee_rate' => (float) ($order->bo_fee_rate ?? 0),
'bo_order_date' => (string) $order->bo_order_date,
'bo_bag_types' => (string) ($order->bo_bag_types ?? ''),
'bo_unit_prices' => (string) ($order->bo_unit_prices ?? ''),
'bo_qty_boxes' => (string) ($order->bo_qty_boxes ?? ''),
'bo_lot_no' => (string) $order->bo_lot_no,
'bo_hash' => '',
'bo_status' => (string) $order->bo_status,
'bo_orderer_idx' => $order->bo_orderer_idx !== null ? (int) $order->bo_orderer_idx : null,
'bo_regdate' => (string) ($order->bo_regdate ?? ''),
];
$hashPayload = $orderData;
$hashPayload['bo_idx'] = $boIdx;
$hashPayload['items'] = $hashItems;
$hashJson = json_encode($hashPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return hash('sha256', $hashJson !== false ? $hashJson : (string) $boIdx);
}
} }

View File

@@ -27,14 +27,143 @@ class BagPrice extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$list = $this->priceModel->where('bp_lg_idx', $lgIdx) $get = $this->request->getGet();
$readSrc = static function (array $src, string $key): ?string {
if (! array_key_exists($key, $src)) {
return null;
}
$v = $src[$key];
if ($v === null || is_array($v)) {
return null;
}
$s = trim((string) $v);
return $s === '' ? null : $s;
};
$sy = $readSrc($get, 'start_y');
$sm = $readSrc($get, 'start_m');
$sd = $readSrc($get, 'start_d');
$ey = $readSrc($get, 'end_y');
$em = $readSrc($get, 'end_m');
$ed = $readSrc($get, 'end_d');
$startDate = null;
if ($sy !== null && $sy !== '' && $sm !== null && $sm !== '' && $sd !== null && $sd !== '') {
$startDate = parse_ymd_from_triple($sy, $sm, $sd);
}
if ($startDate === null) {
$legacyStart = $readSrc($get, 'start_date');
$startDate = ($legacyStart !== null && $legacyStart !== '') ? $legacyStart : null;
}
$endDate = null;
if ($ey !== null && $ey !== '' && $em !== null && $em !== '' && $ed !== null && $ed !== '') {
$endDate = parse_ymd_from_triple($ey, $em, $ed);
}
if ($endDate === null) {
$legacyEnd = $readSrc($get, 'end_date');
$endDate = ($legacyEnd !== null && $legacyEnd !== '') ? $legacyEnd : null;
}
$startParts = ['y' => '', 'm' => '', 'd' => ''];
$endParts = ['y' => '', 'm' => '', 'd' => ''];
if ($startDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $startDate, $m)) {
$startParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
}
if ($endDate !== null && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $endDate, $m)) {
$endParts = ['y' => $m[1], 'm' => (int) $m[2], 'd' => (int) $m[3]];
}
$bagKindE = $readSrc($get, 'bag_kind_e');
$bagCode = $readSrc($get, 'bag_code');
$builder = $this->priceModel->where('bp_lg_idx', $lgIdx);
if (($startDate !== null && $startDate !== '') || ($endDate !== null && $endDate !== '')) {
$qStart = ($startDate !== null && $startDate !== '') ? $startDate : $endDate;
$qEnd = ($endDate !== null && $endDate !== '') ? $endDate : $startDate;
if (strcmp((string) $qStart, (string) $qEnd) > 0) {
[$qStart, $qEnd] = [$qEnd, $qStart];
}
$builder->where('bp_start_date <=', $qEnd);
$builder->groupStart()
->where('bp_end_date IS NULL')
->orWhere('bp_end_date >=', $qStart)
->groupEnd();
}
if ($bagKindE !== null && $bagKindE !== '') {
$kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
if ($kindE) {
$detailE = model(CodeDetailModel::class)
->where('cd_ck_idx', (int) $kindE->ck_idx)
->where('cd_code', $bagKindE)
->where('cd_state', 1)
->first();
if ($detailE !== null) {
$builder->like('bp_bag_code', $bagKindE, 'after');
}
}
}
if ($bagCode !== null && $bagCode !== '') {
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
if ($kindO) {
$detailO = model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, $bagCode, $lgIdx);
if ($detailO !== null) {
$builder->where('bp_bag_code', $bagCode);
}
}
}
$list = $builder
->orderBy('bp_bag_code', 'ASC') ->orderBy('bp_bag_code', 'ASC')
->orderBy('bp_start_date', 'DESC') ->orderBy('bp_start_date', 'DESC')
->paginate(20); ->paginate(20);
$queryForPager = [];
if ($sy !== null && $sm !== null && $sd !== null && $sy !== '' && $sm !== '' && $sd !== '') {
$queryForPager['start_y'] = $sy;
$queryForPager['start_m'] = $sm;
$queryForPager['start_d'] = $sd;
}
if ($ey !== null && $em !== null && $ed !== null && $ey !== '' && $em !== '' && $ed !== '') {
$queryForPager['end_y'] = $ey;
$queryForPager['end_m'] = $em;
$queryForPager['end_d'] = $ed;
}
if ($bagKindE !== null && $bagKindE !== '') {
$queryForPager['bag_kind_e'] = $bagKindE;
}
if ($bagCode !== null && $bagCode !== '') {
$queryForPager['bag_code'] = $bagCode;
}
$pagerPath = mgmt_url('bag-prices');
if ($queryForPager !== []) {
$pagerPath .= '?' . http_build_query($queryForPager);
}
$this->priceModel->pager->setPath($pagerPath);
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$bagCodes = $kindO
? model(CodeDetailModel::class)->getByKind((int) $kindO->ck_idx, true, $lgIdx)
: [];
$kindE = model(CodeKindModel::class)->where('ck_code', 'E')->first();
$bagKindOptions = $kindE
? model(CodeDetailModel::class)->getByKind((int) $kindE->ck_idx, true, null)
: [];
return $this->renderWorkPage('봉투 단가 관리', 'admin/bag_price/index', [ return $this->renderWorkPage('봉투 단가 관리', 'admin/bag_price/index', [
'list' => $list, 'list' => $list,
'pager' => $this->priceModel->pager, 'pager' => $this->priceModel->pager,
'startParts' => $startParts,
'endParts' => $endParts,
'dateYearMin' => (int) date('Y') - 12,
'dateYearMax' => (int) date('Y') + 2,
'bag_kind_e' => $bagKindE,
'bag_code' => $bagCode,
'bag_codes' => $bagCodes,
'bag_kind_options' => $bagKindOptions,
]); ]);
} }

View File

@@ -133,7 +133,7 @@ class BagSale extends BaseController
$shop = model(DesignatedShopModel::class)->find($dsIdx); $shop = model(DesignatedShopModel::class)->find($dsIdx);
$kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first(); $kindO = model(CodeKindModel::class)->where('ck_code', 'O')->first();
$detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null; $detail = $kindO ? model(CodeDetailModel::class)->findResolvedByKindAndCode((int) $kindO->ck_idx, (string) $bagCode, $lgIdx) : null;
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $bagCode)->where('bp_state', 1)->first(); $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $bagCode);
$unitPrice = $price ? (float) $price->bp_consumer : 0; $unitPrice = $price ? (float) $price->bp_consumer : 0;
$actualQty = ($type === 'return') ? -$qty : $qty; $actualQty = ($type === 'return') ? -$qty : $qty;

View File

@@ -9,6 +9,11 @@ class Company extends BaseController
{ {
private CompanyModel $model; private CompanyModel $model;
private function companyTypeOptions(): array
{
return ['협회', '제작업체', '회수업체'];
}
public function __construct() public function __construct()
{ {
$this->model = model(CompanyModel::class); $this->model = model(CompanyModel::class);
@@ -22,10 +27,33 @@ class Company extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$list = $this->model->where('cp_lg_idx', $lgIdx)->orderBy('cp_idx', 'DESC')->paginate(20); $companyType = trim((string) ($this->request->getGet('cp_type') ?? ''));
$typeOptions = $this->companyTypeOptions();
$builder = $this->model->where('cp_lg_idx', $lgIdx);
if ($companyType !== '' && in_array($companyType, $typeOptions, true)) {
$builder->where('cp_type', $companyType);
}
$list = $builder->orderBy('cp_idx', 'DESC')->paginate(20);
$pager = $this->model->pager; $pager = $this->model->pager;
return $this->renderWorkPage('업체 관리', 'admin/company/index', ['list' => $list, 'pager' => $pager]); $queryForPager = [];
if ($companyType !== '' && in_array($companyType, $typeOptions, true)) {
$queryForPager['cp_type'] = $companyType;
}
$pagerPath = mgmt_url('companies');
if ($queryForPager !== []) {
$pagerPath .= '?' . http_build_query($queryForPager);
}
$pager->setPath($pagerPath);
return $this->renderWorkPage('업체 관리', 'admin/company/index', [
'list' => $list,
'pager' => $pager,
'cpType' => $companyType,
'typeOptions' => $typeOptions,
]);
} }
public function create() public function create()

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,15 @@ class Manager extends BaseController
return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : []; return $kind ? model(CodeDetailModel::class)->getByKind((int) $kind->ck_idx, true, $lgIdx) : [];
} }
private function managerCategoryOptions(): array
{
return [
'company' => '제작업체',
'district' => '구·군',
'agency' => '대행소',
];
}
public function index() public function index()
{ {
helper('admin'); helper('admin');
@@ -35,16 +44,29 @@ class Manager extends BaseController
return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.'); return redirect()->to(work_area_home_url())->with('error', '지자체를 선택해 주세요.');
} }
$list = $this->model->where('mg_lg_idx', $lgIdx)->orderBy('mg_idx', 'DESC')->paginate(20); $category = (string) ($this->request->getGet('category') ?? '');
$categories = $this->managerCategoryOptions();
$builder = $this->model->where('mg_lg_idx', $lgIdx);
if ($category !== '' && isset($categories[$category])) {
$builder->where('mg_dept_code', $category);
}
$list = $builder->orderBy('mg_idx', 'DESC')->paginate(20);
$pager = $this->model->pager; $pager = $this->model->pager;
return $this->renderWorkPage('담당자 관리', 'admin/manager/index', ['list' => $list, 'pager' => $pager]); return $this->renderWorkPage('담당자 관리', 'admin/manager/index', [
'list' => $list,
'pager' => $pager,
'categories' => $categories,
'category' => $category,
]);
} }
public function create() public function create()
{ {
return $this->renderWorkPage('담당자 등록', 'admin/manager/create', [ return $this->renderWorkPage('담당자 등록', 'admin/manager/create', [
'deptCodes' => $this->getCodeOptions('S'), 'categories' => $this->managerCategoryOptions(),
'positionCodes' => $this->getCodeOptions('T'), 'positionCodes' => $this->getCodeOptions('T'),
]); ]);
} }
@@ -54,6 +76,7 @@ class Manager extends BaseController
helper(['admin', 'url']); helper(['admin', 'url']);
$rules = [ $rules = [
'mg_name' => 'required|max_length[50]', 'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]',
'mg_tel' => 'permit_empty|max_length[20]', 'mg_tel' => 'permit_empty|max_length[20]',
'mg_phone' => 'permit_empty|max_length[20]', 'mg_phone' => 'permit_empty|max_length[20]',
'mg_email' => 'permit_empty|valid_email|max_length[100]', 'mg_email' => 'permit_empty|valid_email|max_length[100]',
@@ -65,7 +88,7 @@ class Manager extends BaseController
$this->model->insert([ $this->model->insert([
'mg_lg_idx' => admin_effective_lg_idx(), 'mg_lg_idx' => admin_effective_lg_idx(),
'mg_name' => $this->request->getPost('mg_name'), 'mg_name' => $this->request->getPost('mg_name'),
'mg_dept_code' => $this->request->getPost('mg_dept_code') ?? '', 'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '', 'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '', 'mg_tel' => $this->request->getPost('mg_tel') ?? '',
'mg_phone' => $this->request->getPost('mg_phone') ?? '', 'mg_phone' => $this->request->getPost('mg_phone') ?? '',
@@ -87,7 +110,7 @@ class Manager extends BaseController
return $this->renderWorkPage('담당자 수정', 'admin/manager/edit', [ return $this->renderWorkPage('담당자 수정', 'admin/manager/edit', [
'item' => $item, 'item' => $item,
'deptCodes' => $this->getCodeOptions('S'), 'categories' => $this->managerCategoryOptions(),
'positionCodes' => $this->getCodeOptions('T'), 'positionCodes' => $this->getCodeOptions('T'),
]); ]);
} }
@@ -102,6 +125,7 @@ class Manager extends BaseController
$rules = [ $rules = [
'mg_name' => 'required|max_length[50]', 'mg_name' => 'required|max_length[50]',
'mg_category' => 'required|in_list[company,district,agency]',
'mg_state' => 'required|in_list[0,1]', 'mg_state' => 'required|in_list[0,1]',
]; ];
if (! $this->validate($rules)) { if (! $this->validate($rules)) {
@@ -110,7 +134,7 @@ class Manager extends BaseController
$this->model->update($id, [ $this->model->update($id, [
'mg_name' => $this->request->getPost('mg_name'), 'mg_name' => $this->request->getPost('mg_name'),
'mg_dept_code' => $this->request->getPost('mg_dept_code') ?? '', 'mg_dept_code' => (string) ($this->request->getPost('mg_category') ?? ''),
'mg_position_code' => $this->request->getPost('mg_position_code') ?? '', 'mg_position_code' => $this->request->getPost('mg_position_code') ?? '',
'mg_tel' => $this->request->getPost('mg_tel') ?? '', 'mg_tel' => $this->request->getPost('mg_tel') ?? '',
'mg_phone' => $this->request->getPost('mg_phone') ?? '', 'mg_phone' => $this->request->getPost('mg_phone') ?? '',

View File

@@ -143,14 +143,31 @@ class PackagingUnit extends BaseController
$db = \Config\Database::connect(); $db = \Config\Database::connect();
$db->transStart(); $db->transStart();
$trackFields = ['pu_box_per_pack', 'pu_pack_per_sheet']; $trackFields = ['pu_box_per_pack', 'pu_pack_per_sheet', 'pu_start_date', 'pu_end_date', 'pu_state'];
$fieldLabels = [
'pu_box_per_pack' => '박스당 팩 수',
'pu_pack_per_sheet' => '팩당 낱장 수',
'pu_start_date' => '적용시작일',
'pu_end_date' => '적용종료일',
'pu_state' => '상태',
];
foreach ($trackFields as $field) { foreach ($trackFields as $field) {
$oldVal = (string) $item->$field; $oldRaw = $item->$field;
$newVal = (string) $this->request->getPost($field); $newRaw = $this->request->getPost($field);
if ($field === 'pu_end_date') {
$oldRaw = $oldRaw ?: '';
$newRaw = $newRaw ?: '';
}
if ($field === 'pu_state') {
$oldRaw = (int) $oldRaw === 1 ? '사용' : '미사용';
$newRaw = (int) $newRaw === 1 ? '사용' : '미사용';
}
$oldVal = (string) $oldRaw;
$newVal = (string) $newRaw;
if ($oldVal !== $newVal) { if ($oldVal !== $newVal) {
$this->historyModel->insert([ $this->historyModel->insert([
'puh_pu_idx' => $id, 'puh_pu_idx' => $id,
'puh_field' => $field, 'puh_field' => $fieldLabels[$field] ?? $field,
'puh_old_value' => $oldVal, 'puh_old_value' => $oldVal,
'puh_new_value' => $newVal, 'puh_new_value' => $newVal,
'puh_changed_at' => date('Y-m-d H:i:s'), 'puh_changed_at' => date('Y-m-d H:i:s'),

View File

@@ -105,7 +105,7 @@ class ShopOrder extends BaseController
} }
$qty = (int) $qtys[$i]; $qty = (int) $qtys[$i];
$price = model(BagPriceModel::class)->where('bp_lg_idx', $lgIdx)->where('bp_bag_code', $code)->where('bp_state', 1)->first(); $price = model(BagPriceModel::class)->latestActiveByBagCode($lgIdx, (string) $code);
$unitPrice = $price ? (float) $price->bp_consumer : 0; $unitPrice = $price ? (float) $price->bp_consumer : 0;
$amount = $unitPrice * $qty; $amount = $unitPrice * $qty;

File diff suppressed because it is too large Load Diff

View File

@@ -51,13 +51,16 @@ abstract class BaseController extends Controller
protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string protected function renderWorkPage(string $title, string $contentView, array $contentData = []): string
{ {
$content = view($contentView, $contentData); $content = view($contentView, $contentData);
$uri = service('request')->getUri(); helper('admin');
$seg1 = $uri->getSegment(1); $path = function_exists('current_nav_request_path') ? current_nav_request_path() : '';
$seg2 = $uri->getSegment(2); if ($path === '') {
$uri = service('request')->getUri();
// 지정판매소 관리는 관리자 전용 기능으로, /bag 경로여도 관리자 레이아웃을 유지한다. $path = trim((string) $uri->getPath(), '/');
$forceAdminLayoutOnBag = ($seg1 === 'bag' && $seg2 === 'designated-shops'); }
if ($seg1 === 'bag' && ! $forceAdminLayoutOnBag) { while (str_starts_with($path, 'index.php/')) {
$path = substr($path, strlen('index.php/'));
}
if ($path === 'bag' || str_starts_with($path, 'bag/')) {
return view('bag/layout/main', [ return view('bag/layout/main', [
'title' => $title, 'title' => $title,
'content' => $content, 'content' => $content,

View File

@@ -475,6 +475,68 @@ if (! function_exists('site_nav_link_matches_current')) {
} }
} }
if (! function_exists('menu_active_child_for_parent')) {
/**
* 같은 부모 아래 형제 소메뉴 중, 현재 요청에 해당하는 항목을 하나만 고른다.
*
* 짧은 mm_link(예: bag/designated-shops)가 긴 경로(bag/designated-shops/browse)와
* 동시에 prefix 규칙으로 매칭될 때, 가장 구체적인 경로(일치한 후보 문자열 길이 최대)만 활성으로 본다.
* 길이가 같으면 mm_num이 작은 항목을 선택(동일 URL이 여러 메뉴에 매핑된 경우 등).
*
* @param object{children?: array<int, object>} $parentNavItem
* @param list<string> $dashboardPathAliases
*
* @return object|null 활성으로 표시할 자식 노드(mm_idx 등 포함), 없으면 null
*/
function menu_active_child_for_parent(object $parentNavItem, string $currentPath, array $dashboardPathAliases = []): ?object
{
$children = $parentNavItem->children ?? [];
if ($children === []) {
return null;
}
$best = null;
$bestLen = -1;
$bestMmNum = PHP_INT_MAX;
foreach ($children as $child) {
$mmLink = $child->mm_link ?? null;
$maxLen = -1;
foreach (menu_link_candidate_paths($mmLink, $currentPath) as $cand) {
if (menu_single_path_matches_request($cand, $currentPath, $dashboardPathAliases)) {
$maxLen = max($maxLen, strlen($cand));
}
}
if ($maxLen < 0) {
continue;
}
$mmNum = (int) ($child->mm_num ?? 0);
if ($maxLen > $bestLen || ($maxLen === $bestLen && $mmNum < $bestMmNum)) {
$bestLen = $maxLen;
$bestMmNum = $mmNum;
$best = $child;
}
}
return $best;
}
}
if (! function_exists('site_nav_active_child_for_parent')) {
/**
* 사이트 상단 메뉴 전용 호환 래퍼.
*
* @param object{children?: array<int, object>} $parentNavItem
* @param list<string> $dashboardPathAliases
*
* @return object|null
*/
function site_nav_active_child_for_parent(object $parentNavItem, string $currentPath, array $dashboardPathAliases = []): ?object
{
return menu_active_child_for_parent($parentNavItem, $currentPath, $dashboardPathAliases);
}
}
if (! function_exists('session_user_nav_display')) { if (! function_exists('session_user_nav_display')) {
/** /**
* 상단 메뉴바용: 로그인 사용자 이름·역할 표시 * 상단 메뉴바용: 로그인 사용자 이름·역할 표시

View File

@@ -16,4 +16,45 @@ class BagPriceModel extends Model
'bp_start_date', 'bp_end_date', 'bp_state', 'bp_start_date', 'bp_end_date', 'bp_state',
'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx', 'bp_regdate', 'bp_moddate', 'bp_reg_mb_idx',
]; ];
/**
* 같은 봉투코드에 단가 기간이 겹쳐도 "나중 등록 단가"가 우선되도록
* 활성 단가를 등록일/PK 역순으로 정렬해 봉투코드별 1건만 남긴다.
*
* @return array<string, object>
*/
public function latestActiveMapByBagCode(int $lgIdx): array
{
$rows = $this->where('bp_lg_idx', $lgIdx)
->where('bp_state', 1)
->orderBy('bp_regdate', 'DESC')
->orderBy('bp_idx', 'DESC')
->findAll();
$map = [];
foreach ($rows as $row) {
$code = (string) ($row->bp_bag_code ?? '');
if ($code === '' || isset($map[$code])) {
continue;
}
$map[$code] = $row;
}
return $map;
}
public function latestActiveByBagCode(int $lgIdx, string $bagCode): ?object
{
$bagCode = trim($bagCode);
if ($bagCode === '') {
return null;
}
return $this->where('bp_lg_idx', $lgIdx)
->where('bp_bag_code', $bagCode)
->where('bp_state', 1)
->orderBy('bp_regdate', 'DESC')
->orderBy('bp_idx', 'DESC')
->first();
}
} }

View File

@@ -17,16 +17,25 @@ class DesignatedShopModel extends Model
'ds_name', 'ds_name',
'ds_biz_no', 'ds_biz_no',
'ds_rep_name', 'ds_rep_name',
'ds_biz_type',
'ds_biz_kind',
'ds_va_number', 'ds_va_number',
'ds_va_bank',
'ds_va_account',
'ds_zip', 'ds_zip',
'ds_addr', 'ds_addr',
'ds_addr_jibun', 'ds_addr_jibun',
'ds_addr_detail',
'ds_tel', 'ds_tel',
'ds_rep_phone', 'ds_rep_phone',
'ds_email', 'ds_email',
'ds_gugun_code', 'ds_gugun_code',
'ds_zone_code',
'ds_branch_no',
'ds_designated_at', 'ds_designated_at',
'ds_state', 'ds_state',
'ds_state_changed_at',
'ds_change_reason',
'ds_regdate', 'ds_regdate',
]; ];
} }

View File

@@ -1,4 +1,4 @@
<?= view('components/print_header', ['printTitle' => '봉투 단가 관리']) ?> <?= view('components/print_header', ['printTitle' => '봉투 단가 관리', 'printShowApproval' => false]) ?>
<style> <style>
@media print { @media print {
.no-print { display: none !important; } .no-print { display: none !important; }
@@ -9,12 +9,91 @@
<span class="text-sm font-bold text-gray-700">봉투 단가 관리</span> <span class="text-sm font-bold text-gray-700">봉투 단가 관리</span>
<div class="flex items-center gap-2 no-print"> <div class="flex items-center gap-2 no-print">
<button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <button onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= base_url('bag/prices') ?>" class="text-blue-600 hover:underline text-sm">단가 조회·검색</a>
<a href="<?= mgmt_url('bag-prices/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">단가 등록</a> <a href="<?= mgmt_url('bag-prices/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">단가 등록</a>
</div> </div>
</div> </div>
</section> </section>
<p class="text-xs text-gray-500 mt-2 no-print">목록·등록·수정·삭제는 이 화면에서, <strong>기간·봉투별 조회·인쇄</strong>는 <a href="<?= base_url('bag/prices') ?>" class="text-blue-600 hover:underline">봉투 단가(조회)</a>에서 이용하세요.</p> <section class="no-print border border-gray-200 rounded-lg bg-white p-3 mt-2">
<form method="get" action="<?= mgmt_url('bag-prices') ?>" class="flex flex-wrap items-end gap-3" autocomplete="off">
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">봉투구분</label>
<select name="bag_kind_e" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[9rem]">
<option value="">전체</option>
<?php foreach ($bag_kind_options ?? [] as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= (string) ($cd->cd_code ?? '') === (string) ($bag_kind_e ?? '') ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">봉투코드</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1.5 text-sm min-w-[11rem]">
<option value="">전체</option>
<?php foreach ($bag_codes ?? [] as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= (string) ($cd->cd_code ?? '') === (string) ($bag_code ?? '') ? 'selected' : '' ?>>
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-col gap-0.5">
<label class="text-xs text-gray-500">조회 기간 (적용기간 겹침)</label>
<?php
$sp = $startParts ?? ['y' => '', 'm' => '', 'd' => ''];
$ep = $endParts ?? ['y' => '', 'm' => '', 'd' => ''];
$ymin = (int) ($dateYearMin ?? ((int) date('Y') - 12));
$ymax = (int) ($dateYearMax ?? ((int) date('Y') + 2));
?>
<div class="flex flex-wrap items-center gap-1">
<span class="text-xs text-gray-500 mr-0.5">시작</span>
<select name="start_y" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[4.5rem]">
<option value="">연도</option>
<?php for ($yy = $ymin; $yy <= $ymax; $yy++): ?>
<option value="<?= $yy ?>" <?= (string) ($sp['y'] ?? '') === (string) $yy ? 'selected' : '' ?>><?= $yy ?></option>
<?php endfor; ?>
</select>
<select name="start_m" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">월</option>
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
<option value="<?= $mi ?>" <?= isset($sp['m']) && (int) $sp['m'] === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
<?php endfor; ?>
</select>
<select name="start_d" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">일</option>
<?php for ($di = 1; $di <= 31; $di++): ?>
<option value="<?= $di ?>" <?= isset($sp['d']) && (int) $sp['d'] === $di ? 'selected' : '' ?>><?= $di ?>일</option>
<?php endfor; ?>
</select>
<span class="text-sm text-gray-500 mx-0.5">~</span>
<span class="text-xs text-gray-500 mr-0.5">종료</span>
<select name="end_y" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[4.5rem]">
<option value="">연도</option>
<?php for ($yy = $ymin; $yy <= $ymax; $yy++): ?>
<option value="<?= $yy ?>" <?= (string) ($ep['y'] ?? '') === (string) $yy ? 'selected' : '' ?>><?= $yy ?></option>
<?php endfor; ?>
</select>
<select name="end_m" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">월</option>
<?php for ($mi = 1; $mi <= 12; $mi++): ?>
<option value="<?= $mi ?>" <?= isset($ep['m']) && (int) $ep['m'] === $mi ? 'selected' : '' ?>><?= $mi ?>월</option>
<?php endfor; ?>
</select>
<select name="end_d" class="border border-gray-300 rounded px-1.5 py-1.5 text-sm min-w-[3.75rem]">
<option value="">일</option>
<?php for ($di = 1; $di <= 31; $di++): ?>
<option value="<?= $di ?>" <?= isset($ep['d']) && (int) $ep['d'] === $di ? 'selected' : '' ?>><?= $di ?>일</option>
<?php endfor; ?>
</select>
</div>
</div>
<div class="flex items-center gap-2 pb-0.5">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('bag-prices') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
<button type="button" onclick="window.print()" class="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">인쇄</button>
</div>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
@@ -32,9 +111,17 @@
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->bp_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center font-mono"><?= esc($row->bp_bag_code) ?></td> <td class="text-center font-mono"><?= esc($row->bp_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->bp_bag_name) ?></td> <td class="text-left pl-2"><?= esc($row->bp_bag_name) ?></td>
<td><?= number_format((float) $row->bp_order_price) ?></td> <td><?= number_format((float) $row->bp_order_price) ?></td>

View File

@@ -8,6 +8,19 @@
</div> </div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('companies') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">업체유형</label>
<select name="cp_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-44">
<option value="">전 체</option>
<?php foreach (($typeOptions ?? []) as $type): ?>
<option value="<?= esc($type) ?>" <?= (string) ($cpType ?? '') === (string) $type ? 'selected' : '' ?>><?= esc($type) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('companies') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
@@ -24,9 +37,17 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->cp_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center"><?= esc($row->cp_type) ?></td> <td class="text-center"><?= esc($row->cp_type) ?></td>
<td class="text-left pl-2"><?= esc($row->cp_name) ?></td> <td class="text-left pl-2"><?= esc($row->cp_name) ?></td>
<td class="text-center"><?= esc($row->cp_biz_no) ?></td> <td class="text-center"><?= esc($row->cp_biz_no) ?></td>

View File

@@ -0,0 +1,137 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 바코드 출력']) ?>
<style>
.ds-bc-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.ds-bc-table th, .ds-bc-table td { border: 1px solid #ccc; padding: 4px 6px; }
.ds-bc-table th { background: #e9ecef; color: #2d3748; }
.ds-bc-table td { background: #fff; }
.ds-bc-table td.name-cell { max-width: 14rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-table td.addr-cell { max-width: 24rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ds-bc-check { width: 14px; height: 14px; }
</style>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 바코드 출력</span>
<div class="flex items-center gap-2">
<button type="button" id="ds-bc-print-btn" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">인쇄</button>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form id="ds-bc-filter-form" method="get" action="<?= mgmt_url('designated-shops/barcode') ?>" class="flex flex-wrap items-end gap-3">
<div class="min-w-[12rem]">
<label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
<?= esc($fixedGugunLabel ?? '현재 지자체') ?>
</div>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">읍·면·동</label>
<select name="ds_zone_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[10rem]">
<option value="">전체</option>
<?php foreach (($zones ?? []) as $z): ?>
<?php $zc = trim((string) ($z->zone_code ?? '')); ?>
<option value="<?= esc($zc) ?>" <?= ($zoneFilter ?? '') === $zc ? 'selected' : '' ?>><?= esc($zc) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회순서</label>
<select name="order_by" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[8rem]">
<option value="shop_no" <?= ($orderBy ?? 'shop_no') === 'shop_no' ? 'selected' : '' ?>>판매소 코드</option>
<option value="name" <?= ($orderBy ?? '') === 'name' ? 'selected' : '' ?>>판매소명</option>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<section class="mx-2 mt-2 mb-2">
<form id="ds-bc-print-form" method="post" action="<?= mgmt_url('designated-shops/barcode/print') ?>" target="ds-bc-print-frame">
<?= csrf_field() ?>
<input type="hidden" name="zone_label" value="<?= esc(($zoneFilter ?? '') !== '' ? (string) $zoneFilter : '전체') ?>">
<div class="mb-1 text-xs text-gray-600">
<label class="inline-flex items-center gap-1 cursor-pointer"><input type="checkbox" id="ds-bc-check-all" class="ds-bc-check"> 전체선택</label>
<span class="ml-3">선택 건수: <strong id="ds-bc-selected-count">0</strong></span>
</div>
<div class="overflow-auto border border-gray-300 bg-white">
<table class="ds-bc-table">
<thead>
<tr>
<th class="w-14">출력</th>
<th class="w-36">판매소 코드</th>
<th>판매소명</th>
<th class="w-24">대표자명</th>
<th class="w-32">사업자번호</th>
<th>사업장 주소</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($list ?? []) as $row): ?>
<?php
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '사용' : '정지';
?>
<tr>
<td class="text-center"><input class="ds-bc-row-check ds-bc-check" type="checkbox" name="ds_idx[]" value="<?= (int) $row->ds_idx ?>"></td>
<td class="text-center text-blue-700"><?= esc((string) ($row->ds_shop_no ?? '')) ?></td>
<td class="name-cell text-blue-700" title="<?= esc((string) ($row->ds_name ?? '')) ?>"><?= esc((string) ($row->ds_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_rep_name ?? '')) ?></td>
<td><?= esc((string) ($row->ds_biz_no ?? '')) ?></td>
<td class="addr-cell" title="<?= esc((string) ($row->ds_addr ?? '')) ?>"><?= esc((string) ($row->ds_addr ?? '')) ?></td>
<td class="<?= $st === 1 ? 'text-blue-700' : 'text-red-600' ?>"><?= esc($stLabel) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-8">조회된 지정판매소가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</form>
<iframe name="ds-bc-print-frame" class="hidden" style="display:none;width:0;height:0;border:0;" aria-hidden="true"></iframe>
</section>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 mx-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>
<script>
(function () {
var all = document.getElementById('ds-bc-check-all');
var countEl = document.getElementById('ds-bc-selected-count');
var printBtn = document.getElementById('ds-bc-print-btn');
var printForm = document.getElementById('ds-bc-print-form');
var rows = Array.prototype.slice.call(document.querySelectorAll('.ds-bc-row-check'));
if (!all || !countEl || !rows.length) return;
function refreshCount() {
var n = rows.filter(function (el) { return el.checked; }).length;
countEl.textContent = String(n);
all.checked = n > 0 && n === rows.length;
all.indeterminate = n > 0 && n < rows.length;
}
all.addEventListener('change', function () {
rows.forEach(function (el) { el.checked = all.checked; });
refreshCount();
});
rows.forEach(function (el) { el.addEventListener('change', refreshCount); });
if (printBtn && printForm) {
printBtn.addEventListener('click', function () {
var selected = rows.filter(function (el) { return el.checked; }).length;
if (selected < 1) {
window.alert('출력할 지정판매소를 1개 이상 선택해 주세요.');
return;
}
printForm.action = "<?= esc(mgmt_url('designated-shops/barcode/print')) ?>?autoprint=1";
printForm.submit();
});
}
refreshCount();
})();
</script>

View File

@@ -0,0 +1,94 @@
<?php
$rows = $rows ?? [];
$zoneLabel = trim((string) ($zoneLabel ?? '전체'));
$printedAt = trim((string) ($printedAt ?? date('Y.m.d')));
$chunks = array_chunk($rows, 12);
$totalPages = count($chunks);
?>
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>지정판매소 바코드</title>
<style>
body { margin: 0; font-family: Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; color: #222; background: #fff; }
.page { width: 210mm; min-height: 297mm; margin: 0 auto; padding: 14mm 12mm 12mm; box-sizing: border-box; }
.title { text-align: center; font-size: 42px; letter-spacing: 1px; font-weight: 500; margin: 0 0 14px; }
.meta { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #555; padding-bottom: 4px; font-size: 13px; margin-bottom: 8px; }
.meta .center { font-weight: 700; }
.cards { display: flex; flex-wrap: wrap; align-content: flex-start; }
.card { width: 33.3333%; padding: 0 8px 12px; box-sizing: border-box; }
.barcode-wrap { min-height: 40px; }
.barcode-svg { width: 100%; max-width: 270px; height: 22px; }
.code-text { text-align: center; margin-top: 1px; font-size: 16px; letter-spacing: 0.35px; }
.name-text { text-align: center; margin-top: 5px; font-size: 14px; line-height: 1.2; word-break: keep-all; }
@media print {
@page { size: A4; margin: 0; }
.page { page-break-after: always; }
.page:last-child { page-break-after: auto; }
}
</style>
</head>
<body>
<?php if ($rows === []): ?>
<div class="page">
<h1 class="title">지정판매소 바코드</h1>
<p style="text-align:center; margin-top:30px; color:#666;">출력할 지정판매소가 없습니다.</p>
</div>
<?php else: ?>
<?php foreach ($chunks as $pageIndex => $pageRows): ?>
<section class="page">
<h1 class="title">지정판매소 바코드</h1>
<div class="meta">
<span>출 력 일 자: <?= esc($printedAt) ?></span>
<span class="center"><?= esc($zoneLabel) ?></span>
<span>페&nbsp;&nbsp;이&nbsp;&nbsp;지: <?= (int) ($pageIndex + 1) ?> / <?= (int) $totalPages ?></span>
</div>
<div class="cards">
<?php foreach ($pageRows as $row): ?>
<?php
$code = trim((string) ($row->ds_shop_no ?? ''));
$nm = trim((string) ($row->ds_name ?? ''));
$rep = trim((string) ($row->ds_rep_name ?? ''));
$label = trim($nm . ($rep !== '' ? ('-' . $rep) : ''));
?>
<div class="card">
<div class="barcode-wrap">
<svg class="barcode-svg" data-barcode="<?= esc($code, 'attr') ?>"></svg>
</div>
<div class="code-text"><?= esc($code) ?></div>
<div class="name-text"><?= esc($label) ?></div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<?php endif; ?>
<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.6/dist/JsBarcode.all.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var svgs = document.querySelectorAll('svg[data-barcode]');
svgs.forEach(function (svg) {
var code = (svg.getAttribute('data-barcode') || '').trim();
if (!code) return;
try {
JsBarcode(svg, code, {
format: 'CODE128',
displayValue: false,
margin: 0,
height: 16,
width: 1.28
});
} catch (e) {
svg.outerHTML = '<div style="font-size:12px;color:#b91c1c;">바코드 생성 실패: ' + code + '</div>';
}
});
if (window.location.search.indexOf('autoprint=1') >= 0) {
setTimeout(function () { window.print(); }, 200);
}
});
</script>
</body>
</html>

View File

@@ -2,7 +2,7 @@
<span class="text-sm font-bold text-gray-700">지정판매소 등록</span> <span class="text-sm font-bold text-gray-700">지정판매소 등록</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl"> <div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('designated-shops/store') ?>" method="POST" class="space-y-4"> <form id="designated-shop-create-form" action="<?= mgmt_url('designated-shops/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<?php if (! empty($localGovs)): ?> <?php if (! empty($localGovs)): ?>
@@ -23,14 +23,18 @@
<div class="text-sm"> <div class="text-sm">
<span class="font-semibold"><?= esc($currentLg->lg_name) ?></span> <span class="font-semibold"><?= esc($currentLg->lg_name) ?></span>
<span class="text-gray-500 ml-2">(<?= esc($currentLg->lg_code) ?>)</span> <span class="text-gray-500 ml-2">(<?= esc($currentLg->lg_code) ?>)</span>
<span class="text-gray-500 ml-1"><?= esc(trim((string) ($currentLg->lg_sido ?? '') . ' ' . (string) ($currentLg->lg_gugun ?? ''))) ?></span>
</div> </div>
<input type="hidden" name="ds_lg_idx" value="<?= esc($currentLg->lg_idx) ?>"/> <input type="hidden" name="ds_lg_idx" value="<?= esc($currentLg->lg_idx) ?>"/>
</div> </div>
<?php endif; ?> <?php endif; ?>
<input type="hidden" name="addr_search_sido" id="addr_search_sido" value="<?= esc((string) old('addr_search_sido', '')) ?>"/>
<input type="hidden" name="addr_search_sigungu" id="addr_search_sigungu" value="<?= esc((string) old('addr_search_sigungu', '')) ?>"/>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">판매소번호</label> <label class="block text-sm font-bold text-gray-700 w-28">판매소번호</label>
<div class="text-sm text-gray-600">등록 시 자동 부여 (지자체코드 + 일련번호 3자리)</div> <div class="text-sm text-gray-600">등록 시 자동 부여 (주소 기준 기본코드 B·C·D 조합 + 일련번호 3자리)</div>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -49,23 +53,50 @@
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌</label> <label class="block text-sm font-bold text-gray-700 w-28">업태</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_number" type="text" value="<?= esc(old('ds_va_number')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_type" type="text" value="<?= esc(old('ds_biz_type')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">업종</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_kind" type="text" value="<?= esc(old('ds_biz_kind')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌(은행)</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_va_bank" type="text" value="<?= esc(old('ds_va_bank')) ?>" placeholder="예: 농협"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">계좌번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_account" type="text" value="<?= esc(old('ds_va_account')) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">주소</label>
<p class="text-xs text-gray-600 max-w-lg leading-relaxed">우편번호 옆 <strong>주소 검색</strong>으로만 지정합니다(직접 입력 불가). <strong>지도</strong>는 카카오맵에서 현재 입력된 주소를 검색해 엽니다. 관할이 아니면 검색 직후 안내 후 반영되지 않습니다. 동·호 등은 아래 <strong>상세주소</strong>에 입력하세요.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">우편번호</label> <label class="block text-sm font-bold text-gray-700 w-28">우편번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="ds_zip" type="text" value="<?= esc(old('ds_zip')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_zip" id="ds_zip" type="text" value="<?= esc(old('ds_zip')) ?>" maxlength="10" autocomplete="postal-code" readonly tabindex="-1"/>
<button type="button" id="btn-ds-kakao-postcode" class="no-print border border-btn-print-border text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition shrink-0">주소 검색</button>
<?= view('components/kakao_map_link_button', ['buttonId' => 'btn-ds-kakao-map-create']) ?>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">도로명주소</label> <label class="block text-sm font-bold text-gray-700 w-28">도로명주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr" type="text" value="<?= esc(old('ds_addr')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr" id="ds_addr" type="text" value="<?= esc(old('ds_addr')) ?>" readonly tabindex="-1"/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지번주소</label> <label class="block text-sm font-bold text-gray-700 w-28">지번주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr_jibun" type="text" value="<?= esc(old('ds_addr_jibun')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr_jibun" id="ds_addr_jibun" type="text" value="<?= esc(old('ds_addr_jibun')) ?>" readonly tabindex="-1"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상세주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full" name="ds_addr_detail" id="ds_addr_detail" type="text" value="<?= esc(old('ds_addr_detail')) ?>" maxlength="200" placeholder="동·호수·층 등"/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -88,11 +119,31 @@
<div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div> <div class="text-sm text-gray-600">해당 지자체(구·군) 코드로 등록 시 자동 설정</div>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구역</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="ds_zone_code" type="text" value="<?= esc(old('ds_zone_code')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종사업장번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_branch_no" type="text" value="<?= esc(old('ds_branch_no')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지정일자</label> <label class="block text-sm font-bold text-gray-700 w-28">지정일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_designated_at" type="date" value="<?= esc(old('ds_designated_at')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_designated_at" type="date" value="<?= esc(old('ds_designated_at')) ?>"/>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">변경일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_state_changed_at" type="date" value="<?= esc(old('ds_state_changed_at')) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">변경사유</label>
<textarea class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 min-h-[4rem]" name="ds_change_reason" rows="3"><?= esc(old('ds_change_reason')) ?></textarea>
</div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button> <button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a> <a href="<?= mgmt_url('designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a>
@@ -100,3 +151,17 @@
</form> </form>
</div> </div>
<?= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
<?= view('components/kakao_address_search', [
'buttonId' => 'btn-ds-kakao-postcode',
'zipName' => 'ds_zip',
'roadName' => 'ds_addr',
'jibunName' => 'ds_addr_jibun',
'sidoFieldName' => 'addr_search_sido',
'sigunguFieldName' => 'addr_search_sigungu',
'tenantScope' => $addrTenantScope ?? ['lg_sido' => '', 'lg_gugun' => ''],
'roadBaseOnly' => true,
'detailFieldName' => 'ds_addr_detail',
]) ?>

View File

@@ -0,0 +1,210 @@
<?php
$ry = (int) ($reportYear ?? (int) date('Y'));
$lg = $currentLg ?? null;
$lgSido = $lg !== null ? trim((string) ($lg->lg_sido ?? '')) : '';
$lgGugun = $lg !== null ? trim((string) ($lg->lg_gugun ?? '')) : '';
$lgName = $lg !== null ? trim((string) ($lg->lg_name ?? '')) : '';
$scopeLabel = $lgSido !== '' && $lgGugun !== ''
? $lgSido . ' ' . $lgGugun
: ($lgName !== '' ? $lgName : '—');
$exportUrl = mgmt_url('designated-shops/district-new-cancel/export') . '?' . http_build_query(['year' => $ry]);
?>
<?= view('components/print_header', ['printTitle' => '지정 판매소 신규/취소 현황 (' . $ry . '년)']) ?>
<style>
.gbms-dnc-wrap { max-width: 100%; }
.gbms-dnc-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.gbms-dnc-table th,
.gbms-dnc-table td {
border: 1px solid #7a8aa0;
padding: 6px 10px;
text-align: center;
}
.gbms-dnc-table thead th {
background: linear-gradient(180deg, #e8eef6 0%, #d4dee9 100%);
font-weight: 700;
color: #1a2a3a;
}
.gbms-dnc-table thead th.gbms-sub {
background: #dce6f0;
font-weight: 600;
}
.gbms-dnc-table tbody td.text-left { text-align: left; }
.gbms-dnc-table tbody tr.gbms-total td {
font-weight: 700;
border: 2px solid #c62828;
background: #fff8f8;
}
.gbms-dnc-caption {
font-size: 13px;
font-weight: 700;
margin: 8px 0 6px;
color: #1a2a3a;
}
.gbms-unit-pill {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: #0d47a1;
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 2px;
}
.gbms-tip {
position: relative;
display: inline-flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
}
.gbms-help {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #5c6f85;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
user-select: none;
cursor: help;
}
.gbms-help::after {
content: attr(data-tip);
position: absolute;
left: 50%;
top: calc(100% + 6px);
transform: translateX(-50%);
display: none;
min-width: 12rem;
max-width: 14rem;
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 30;
}
.gbms-help:hover::after,
.gbms-help:focus::after {
display: block;
}
@media print {
.gbms-dnc-table { font-size: 11px; }
.gbms-help { display: none !important; }
}
</style>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-800">[지정 판매소 신규/취소 현황]</span>
<div class="flex items-center gap-2">
<a href="<?= esc($exportUrl) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록</a>
</div>
</div>
</section>
<section class="p-3 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= mgmt_url('designated-shops/district-new-cancel') ?>" class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-xs text-gray-600 mb-0.5">조회년도</label>
<select name="year" class="border border-gray-400 rounded px-2 py-1.5 text-sm min-w-[7rem] bg-white">
<?php foreach (($yearChoices ?? []) as $y): ?>
<option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex-1 min-w-[12rem]">
<span class="block text-xs text-gray-600 mb-0.5">군·구 (소속 지자체)</span>
<div class="border border-gray-300 rounded px-3 py-1.5 text-sm bg-gray-50 text-gray-800 font-medium">
<?= esc($scopeLabel) ?>
</div>
</div>
<span class="gbms-unit-pill self-end mb-0.5">단위: 판매소</span>
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm font-medium shadow-sm hover:opacity-90">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 구·군 행은 효과 지자체의 기본코드(구 코드) 순서로 표시됩니다.
</p>
</section>
<div class="mx-2 mt-3 mb-4 gbms-dnc-wrap">
<div class="gbms-dnc-caption">지정 판매소 신규/취소 현황 조회 내역</div>
<div class="overflow-x-auto border border-gray-400 bg-white">
<table class="gbms-dnc-table">
<thead>
<tr>
<th rowspan="2" class="min-w-[6rem]">군·구</th>
<th rowspan="2">
<span class="gbms-tip">
종전
<span class="gbms-help" tabindex="0" aria-label="종전 설명" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(전년도말)</span>
</th>
<th colspan="2">사용</th>
<th rowspan="2">
<span class="gbms-tip">
현행
<span class="gbms-help" tabindex="0" aria-label="현행 설명" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span>
</span>
<br><span class="text-xs font-normal">(금년도말)</span>
</th>
</tr>
<tr>
<th class="gbms-sub">
<span class="gbms-tip">
지정
<span class="gbms-help" tabindex="0" aria-label="지정 설명" data-tip="조회년도 내 지정일이 속한 신규 지정 건수">?</span>
</span>
</th>
<th class="gbms-sub">
<span class="gbms-tip">
취소
<span class="gbms-help" tabindex="0" aria-label="취소 설명" data-tip="조회년도 내 폐업/해지 전환일이 속한 건수">?</span>
</span>
</th>
</tr>
</thead>
<tbody>
<?php foreach (($districtRows ?? []) as $row): ?>
<tr>
<td class="text-left font-medium"><?= esc($row->region_label) ?></td>
<td><?= number_format((int) $row->prev_end) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format((int) $row->curr_end) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr>
<td colspan="5" class="text-center text-gray-500 py-8">표시할 구·군 또는 지정판매소 데이터가 없습니다.</td>
</tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="gbms-total">
<td class="text-left"><?= esc($districtTotal->region_label) ?></td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -5,20 +5,35 @@ if ($shop === null) {
return; return;
} }
$v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($key) : ($shop->{$key} ?? $default); $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($key) : ($shop->{$key} ?? $default);
$vaAccountDefault = (isset($shop->ds_va_account) && (string) $shop->ds_va_account !== '')
? (string) $shop->ds_va_account
: (string) ($shop->ds_va_number ?? '');
$dateField = static function (string $key) use ($shop, $v): string {
$s = (string) $v($key);
if ($s === '' || str_starts_with($s, '0000')) {
return '';
}
return $s;
};
?> ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<span class="text-sm font-bold text-gray-700">지정판매소 수정</span> <span class="text-sm font-bold text-gray-700">지정판매소 수정</span>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl"> <div class="border border-gray-300 p-4 mt-2 bg-white max-w-3xl">
<form action="<?= mgmt_url('designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4"> <form id="designated-shop-edit-form" action="<?= mgmt_url('designated-shops/update/' . (int) $shop->ds_idx) ?>" method="POST" class="space-y-4">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="addr_search_sido" id="addr_search_sido" value="<?= esc((string) old('addr_search_sido', $currentLg !== null ? (string) ($currentLg->lg_sido ?? '') : '')) ?>"/>
<input type="hidden" name="addr_search_sigungu" id="addr_search_sigungu" value="<?= esc((string) old('addr_search_sigungu', $currentLg !== null ? (string) ($currentLg->lg_gugun ?? '') : '')) ?>"/>
<?php if ($currentLg !== null): ?> <?php if ($currentLg !== null): ?>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지자체</label> <label class="block text-sm font-bold text-gray-700 w-28">지자체</label>
<div class="text-sm"> <div class="text-sm">
<span class="font-semibold"><?= esc($currentLg->lg_name) ?></span> <span class="font-semibold"><?= esc($currentLg->lg_name) ?></span>
<span class="text-gray-500 ml-2">(<?= esc($currentLg->lg_code) ?>)</span> <span class="text-gray-500 ml-2">(<?= esc($currentLg->lg_code) ?>)</span>
<span class="text-gray-500 ml-1"><?= esc(trim((string) ($currentLg->lg_sido ?? '') . ' ' . (string) ($currentLg->lg_gugun ?? ''))) ?></span>
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -49,23 +64,50 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌</label> <label class="block text-sm font-bold text-gray-700 w-28">업태</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_number" type="text" value="<?= esc($v('ds_va_number')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_type" type="text" value="<?= esc($v('ds_biz_type')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">업종</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_biz_kind" type="text" value="<?= esc($v('ds_biz_kind')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">가상계좌(은행)</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_va_bank" type="text" value="<?= esc($v('ds_va_bank')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">계좌번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_va_account" type="text" value="<?= esc((old('ds_va_account') !== null && old('ds_va_account') !== '') ? old('ds_va_account') : $vaAccountDefault) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">주소</label>
<p class="text-xs text-gray-600 max-w-lg leading-relaxed">우편·도로명·지번은 <strong>주소 검색</strong>으로만 바꿀 수 있습니다(직접 입력 불가). <strong>지도</strong>는 카카오맵에서 현재 입력된 주소를 검색해 엽니다. 관할이 아니면 검색 직후 안내 후 반영되지 않습니다. 동·호 등은 <strong>상세주소</strong>에 입력하세요.</p>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">우편번호</label> <label class="block text-sm font-bold text-gray-700 w-28">우편번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32" name="ds_zip" type="text" value="<?= esc($v('ds_zip')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_zip" id="ds_zip" type="text" value="<?= esc($v('ds_zip')) ?>" maxlength="10" readonly tabindex="-1"/>
<button type="button" id="btn-ds-kakao-postcode-edit" class="no-print border border-btn-print-border text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition shrink-0">주소 검색</button>
<?= view('components/kakao_map_link_button', ['buttonId' => 'btn-ds-kakao-map-edit']) ?>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">도로명주소</label> <label class="block text-sm font-bold text-gray-700 w-28">도로명주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr" type="text" value="<?= esc($v('ds_addr')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr" id="ds_addr" type="text" value="<?= esc($v('ds_addr')) ?>" readonly tabindex="-1"/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지번주소</label> <label class="block text-sm font-bold text-gray-700 w-28">지번주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96" name="ds_addr_jibun" type="text" value="<?= esc($v('ds_addr_jibun')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full bg-gray-100 text-gray-800 cursor-not-allowed" name="ds_addr_jibun" id="ds_addr_jibun" type="text" value="<?= esc($v('ds_addr_jibun')) ?>" readonly tabindex="-1"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">상세주소</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 max-w-full" name="ds_addr_detail" id="ds_addr_detail" type="text" value="<?= esc($v('ds_addr_detail')) ?>" maxlength="200" placeholder="동·호수·층 등"/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
@@ -83,6 +125,16 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_email" type="email" value="<?= esc($v('ds_email')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="ds_email" type="email" value="<?= esc($v('ds_email')) ?>"/>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">구역</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-72" name="ds_zone_code" type="text" value="<?= esc($v('ds_zone_code')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">종사업장번호</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-48" name="ds_branch_no" type="text" value="<?= esc($v('ds_branch_no')) ?>"/>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">지정일자</label> <label class="block text-sm font-bold text-gray-700 w-28">지정일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_designated_at" type="date" value="<?= esc($v('ds_designated_at')) ?>"/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_designated_at" type="date" value="<?= esc($v('ds_designated_at')) ?>"/>
@@ -97,9 +149,33 @@ $v = fn ($key, $default = '') => old($key) !== null && old($key) !== '' ? old($k
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">변경일자</label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-40" name="ds_state_changed_at" type="date" value="<?= esc($dateField('ds_state_changed_at')) ?>"/>
</div>
<div class="flex flex-wrap items-start gap-2">
<label class="block text-sm font-bold text-gray-700 w-28 pt-1.5">변경사유</label>
<textarea class="border border-gray-300 rounded px-3 py-1.5 text-sm w-96 min-h-[4rem]" name="ds_change_reason" rows="3"><?= esc($v('ds_change_reason')) ?></textarea>
</div>
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button> <button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">수정</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a> <a href="<?= mgmt_url('designated-shops') ?>" class="bg-white text-black border border-btn-print-border px-4 py-1.5 rounded-sm text-sm shadow hover:bg-gray-50 transition">목록</a>
</div> </div>
</form> </form>
</div> </div>
<?= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
<?= view('components/kakao_address_search', [
'buttonId' => 'btn-ds-kakao-postcode-edit',
'zipName' => 'ds_zip',
'roadName' => 'ds_addr',
'jibunName' => 'ds_addr_jibun',
'sidoFieldName' => 'addr_search_sido',
'sigunguFieldName' => 'addr_search_sigungu',
'tenantScope' => $addrTenantScope ?? ['lg_sido' => '', 'lg_gugun' => ''],
'roadBaseOnly' => true,
'detailFieldName' => 'ds_addr_detail',
]) ?>

View File

@@ -1,24 +1,202 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 목록']) ?> <?php
helper('admin');
$currentPath = current_nav_request_path();
if ($currentPath === 'bag/designated-shops') {
$readOnly = false;
} elseif ($currentPath === 'bag/designated-shops/browse') {
$readOnly = true;
} else {
$readOnly = ! empty($readOnly);
}
?>
<?= view('components/print_header', ['printTitle' => $readOnly ? '지정판매소 조회 목록' : '지정판매소 목록']) ?>
<style>
/* 목록 위 → 지정판매소 정보 아래 (가로 2열 없음) */
.ds-split {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 0;
flex: 1 1 auto;
}
.ds-list-panel {
flex: 0 1 auto;
width: 100%;
max-height: 42vh;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid #ccc;
background: #fff;
}
.ds-detail-panel {
flex: 1 1 auto;
width: 100%;
min-width: 0;
min-height: 12rem;
border: 1px solid #ccc;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.ds-panel-title {
font-size: 12px;
font-weight: bold;
padding: 6px 10px;
background: linear-gradient(180deg, #fafafa 0%, #e9ecef 100%);
border-bottom: 1px solid #ccc;
color: #333;
}
.ds-summary-bar {
font-size: 12px;
padding: 6px 10px;
background: #fff3cd;
border: 1px solid #ffc107;
color: #333;
}
.ds-row-selected td { background-color: #cce5ff !important; }
.ds-detail-inner { padding: 10px; overflow: auto; flex: 1; }
/* 원본 지정판매소 정보: 라벨 고정폭 + 2열 값(우측 값이 더 넓음), 주소·개인전화는 전폭 */
.ds-detail-form {
font-size: 12px;
border: 1px solid #bbb;
background: #fff;
}
.ds-row {
display: grid;
gap: 0;
border-bottom: 1px solid #ccc;
}
.ds-detail-form > .ds-row:last-child { border-bottom: none; }
/* 그 외 2+2 동일 비율 (상호명 | 우편번호 등) */
.ds-row-4-even {
grid-template-columns: 5.5rem minmax(0, 1fr) 5.5rem minmax(0, 1fr);
}
/* 판매소번호 전폭 행 — 값을 우편·주소 필드처럼 넓게 */
.ds-value-shop-wide {
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
font-size: 13px;
padding-top: 8px;
padding-bottom: 8px;
}
/* 라벨 | 값(나머지 전체) — 도로명·지번·개인전화·이메일 */
.ds-row-wide {
grid-template-columns: 5.5rem minmax(0, 1fr);
}
.ds-row-wide-tall .ds-field-value {
min-height: 3.25rem;
align-content: start;
}
/* 도로명주소 + 카카오맵 버튼 */
.ds-field-value-with-map {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
}
.ds-field-value-with-map .ds-addr-text {
flex: 1 1 12rem;
min-width: 0;
word-break: break-word;
}
.ds-field-label {
background: #eef2f5;
border-right: 1px solid #ccc;
padding: 5px 8px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
}
.ds-field-value {
padding: 5px 8px;
background: #fff;
word-break: break-word;
border-right: 1px solid #ccc;
}
.ds-row-4-even > *:nth-child(4n) { border-right: none; }
.ds-row-wide > .ds-field-value { border-right: none; }
.ds-field-hint {
font-size: 11px;
color: #b91c1c;
margin-top: 4px;
line-height: 1.35;
}
@media (max-width: 720px) {
.ds-row-4-even { grid-template-columns: 5rem 1fr; }
}
.ds-detail-actions { padding: 10px; border-top: 1px solid #ccc; background: #eee; }
.ds-detail-info-wrap { overflow-x: auto; }
.ds-detail-info-wrap .data-table th { white-space: nowrap; }
.ds-detail-info-wrap th.ds-col-tight-head,
.ds-detail-info-wrap td.ds-col-tight-cell {
max-width: 6.5rem;
width: 6.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-list-panel .ds-col-tight {
max-width: 6rem;
width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-list-panel .ds-col-zip {
width: 4.5rem;
max-width: 5rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ds-list-panel .ds-col-addr-list {
max-width: 11rem;
min-width: 6rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-list-panel .ds-col-detail-list {
max-width: 8rem;
min-width: 4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ds-ro-road-btn { margin-left: 6px; vertical-align: middle; }
</style>
<?php
$listBasePath = $readOnly ? 'designated-shops/browse' : 'designated-shops';
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 목록</span> <span class="text-sm font-bold text-gray-700"><?= $readOnly ? '지정판매소 조회' : '지정판매소 관리' ?></span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<?php if ($readOnly): ?>
<a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a> <a href="<?= mgmt_url('designated-shops/export') ?>" class="no-print border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <button type="button" onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<?php endif; ?>
<?php if (! $readOnly): ?>
<a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a> <a href="<?= mgmt_url('designated-shops/create') ?>" class="bg-btn-search text-white px-4 py-1.5 rounded-sm flex items-center gap-1 text-sm shadow hover:opacity-90 transition border border-transparent">지정판매소 등록</a>
<?php endif; ?>
</div> </div>
</div> </div>
</section> </section>
<!-- P2-15: 다조건 검색 -->
<section class="p-2 bg-white border-b border-gray-200 no-print"> <section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('designated-shops') ?>" class="flex flex-wrap items-center gap-2"> <form method="GET" action="<?= mgmt_url($listBasePath) ?>" class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-gray-700 mr-1">지정판매소 검색</span>
<label class="text-sm text-gray-600">상호명</label> <label class="text-sm text-gray-600">상호명</label>
<input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명 검색" class="border border-gray-300 rounded px-2 py-1 text-sm w-40"/> <input type="text" name="ds_name" value="<?= esc($dsName ?? '') ?>" placeholder="상호명" class="border border-gray-300 rounded px-2 py-1 text-sm w-36"/>
<label class="text-sm text-gray-600">구코드</label> <label class="text-sm text-gray-600">구·군 코드</label>
<select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm"> <select name="ds_gugun_code" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[14rem]">
<option value="">전체</option> <option value="">전체</option>
<?php foreach (($gugunCodes ?? []) as $gc): ?> <?php foreach (($gugunCodes ?? []) as $gc): ?>
<option value="<?= esc($gc->ds_gugun_code) ?>" <?= ($dsGugunCode ?? '') === $gc->ds_gugun_code ? 'selected' : '' ?>><?= esc($gc->ds_gugun_code) ?></option> <?php $gCode = (string) ($gc->ds_gugun_code ?? ''); ?>
<option value="<?= esc($gCode) ?>" <?= ($dsGugunCode ?? '') === $gCode ? 'selected' : '' ?>><?= esc((string) (($gugunNameMap[$gCode] ?? '') !== '' ? $gugunNameMap[$gCode] : $gCode)) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<label class="text-sm text-gray-600">상태</label> <label class="text-sm text-gray-600">상태</label>
@@ -29,48 +207,357 @@
<option value="3" <?= ($dsState ?? '') === '3' ? 'selected' : '' ?>>직권해지</option> <option value="3" <?= ($dsState ?? '') === '3' ? 'selected' : '' ?>>직권해지</option>
</select> </select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a> <a href="<?= mgmt_url($listBasePath) ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">초기화</a>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <?php
$sc = $stateCounts ?? ['total' => 0, 1 => 0, 2 => 0, 3 => 0];
?>
<div class="ds-summary-bar no-print mx-2 mt-2 rounded-sm">
건수 : <?= (int) ($sc['total'] ?? 0) ?>
(정상 : <?= (int) ($sc[1] ?? 0) ?> / 폐업 : <?= (int) ($sc[2] ?? 0) ?> / 해지 : <?= (int) ($sc[3] ?? 0) ?>)
</div>
<div class="ds-split no-print mx-2 mb-2 mt-2 flex-1 min-h-0">
<div class="ds-list-panel">
<div class="ds-panel-title shrink-0">지정판매소 리스트</div>
<div class="overflow-auto flex-1 min-h-0">
<table class="w-full data-table">
<thead>
<tr>
<th class="w-14">번호</th>
<th class="w-24">구·군</th>
<th class="w-24">지정일</th>
<th class="w-24">구역</th>
<th class="ds-col-tight">대표자명</th>
<th class="ds-col-tight">상호명</th>
<th class="ds-col-zip">우편번호</th>
<th class="text-left">주소</th>
<th class="w-28">사업자번호</th>
<th class="w-28">전화</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody id="ds-list-body" class="text-right">
<?php foreach ($list as $i => $row): ?>
<?php
$sn = (string) ($row->ds_shop_no ?? '');
if (preg_match('/(\d{3})$/', $sn, $m)) {
$shortNo = $m[1];
} elseif ($sn !== '' && strlen($sn) >= 3) {
$shortNo = substr($sn, -3);
} else {
$shortNo = $sn;
}
$st = (int) ($row->ds_state ?? 1);
$stLabel = $st === 1 ? '' : ($st === 2 ? '폐업' : '해지');
$ggCode = (string) ($row->ds_gugun_code ?? '');
$ggLabel = (string) (($gugunNameMap[$ggCode] ?? '') !== '' ? $gugunNameMap[$ggCode] : $ggCode);
$da = $row->ds_designated_at ?? null;
$daDisp = ($da !== null && $da !== '' && (string) $da !== '0000-00-00') ? substr((string) $da, 0, 10) : '';
$zone = (string) ($row->ds_zone_code ?? '');
$zipList = trim((string) ($row->ds_zip ?? ''));
$roadL = trim((string) ($row->ds_addr ?? ''));
$jibunL = trim((string) ($row->ds_addr_jibun ?? ''));
$addrMainList = $roadL !== '' ? $roadL : $jibunL;
$addrDetailList = trim((string) ($row->ds_addr_detail ?? ''));
$addrCombinedList = trim($addrMainList . ' ' . $addrDetailList);
if ($addrCombinedList === '') {
$addrCombinedList = $addrMainList;
}
?>
<tr class="ds-list-row cursor-pointer" data-row-index="<?= (int) $i ?>" role="button" tabindex="0">
<td class="text-center"><?= esc($shortNo) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($ggLabel) ?></td>
<td class="text-center text-xs"><?= esc($daDisp) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($zone) ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_rep_name ?? '') ?>"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-left pl-1 text-xs ds-col-tight" title="<?= esc($row->ds_name ?? '') ?>"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-center text-xs ds-col-zip" title="<?= esc($zipList) ?>"><?= esc($zipList) ?></td>
<td class="text-left pl-1 text-xs ds-col-addr-list" title="<?= esc($addrCombinedList) ?>"><?= esc($addrCombinedList) ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_biz_no ?? '') ?></td>
<td class="text-left pl-1 text-xs"><?= esc($row->ds_tel ?? '') ?></td>
<td class="text-center <?= $st === 2 ? 'text-pink-600 font-medium' : ($st === 3 ? 'text-orange-700' : '') ?>"><?= esc($stLabel) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div class="ds-detail-panel">
<div class="ds-panel-title shrink-0">지정판매소 정보</div>
<div class="ds-detail-inner" id="ds-detail-box">
<p id="ds-detail-placeholder" class="text-sm text-gray-500 py-6 text-center">위 목록에서 행을 선택하세요.</p>
<div id="ds-detail-fields" class="hidden">
<div class="ds-detail-info-wrap">
<table class="w-full data-table text-sm" id="ds-detail-info-table" aria-label="지정판매소 상세">
<thead>
<tr>
<th>판매소번호</th>
<th class="ds-col-tight-head">상호명</th>
<th>우편번호</th>
<th>사업자번호</th>
<th>일반전화</th>
<th class="ds-col-tight-head">대표자명</th>
<th>이메일</th>
<th>업태</th>
<th>업종</th>
<th>지정일자</th>
<th>지자체</th>
<th>도로명주소</th>
<th>지번주소</th>
<th>상세주소</th>
<th>개인전화</th>
<th>구·군</th>
<th>구역</th>
<th>가상계좌(은행)</th>
<th>계좌번호</th>
<th>종사업장번호</th>
<th>변경일자</th>
<th>영업상태</th>
<th>등록일시</th>
<th>변경사유</th>
<th class="no-print w-14">지도</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-left" data-ro="ds_shop_no">—</td>
<td class="text-left ds-col-tight-cell" data-ro="ds_name">—</td>
<td class="text-left" data-ro="ds_zip">—</td>
<td class="text-left" data-ro="ds_biz_no">—</td>
<td class="text-left" data-ro="ds_tel">—</td>
<td class="text-left ds-col-tight-cell" data-ro="ds_rep_name">—</td>
<td class="text-left" data-ro="ds_email">—</td>
<td class="text-left" data-ro="ds_biz_type">—</td>
<td class="text-left" data-ro="ds_biz_kind">—</td>
<td class="text-left" data-ro="ds_designated_at">—</td>
<td class="text-left" data-ro="lg_name">—</td>
<td class="text-left min-w-[10rem]"><span data-ro="ds_addr">—</span></td>
<td class="text-left" data-ro="ds_addr_jibun">—</td>
<td class="text-left" data-ro="ds_addr_detail">—</td>
<td class="text-left" data-ro="ds_rep_phone">—</td>
<td class="text-left" data-ro="gugun_name">—</td>
<td class="text-left" data-ro="ds_zone_code">—</td>
<td class="text-left" data-ro="ds_va_bank">—</td>
<td class="text-left" data-ro="ds_va_account">—</td>
<td class="text-left" data-ro="ds_branch_no">—</td>
<td class="text-left" data-ro="ds_state_changed_at">—</td>
<td class="text-left" data-ro="state_label">—</td>
<td class="text-left" data-ro="ds_regdate">—</td>
<td class="text-left min-w-[8rem]" data-ro="ds_change_reason">—</td>
<td class="text-center no-print">
<button type="button" class="border border-btn-print-border text-gray-700 px-2 py-0.5 rounded-sm text-xs hover:bg-gray-50" id="ds-ro-map-btn">지도</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<?php if (! $readOnly): ?>
<div class="ds-detail-actions no-print flex flex-wrap items-center gap-3 shrink-0">
<a id="ds-edit-link" href="#" class="text-blue-700 hover:underline text-sm font-medium pointer-events-none opacity-40">수정</a>
<form id="ds-delete-form" method="POST" action="" class="inline" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" id="ds-delete-btn" class="text-red-600 hover:underline text-sm pointer-events-none opacity-40" disabled>삭제</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<?php if (isset($pager)): ?>
<div class="mt-2 mb-2 mx-2 no-print"><?= $pager->links() ?></div>
<?php endif; ?>
<?= view('components/kakao_map_modal', ['kakaoJavascriptKey' => $kakaoJavascriptKey ?? '']) ?>
<script type="application/json" id="ds-detail-json"><?= $detailRowsJson ?? '[]' ?></script>
<script>
(function () {
var raw = document.getElementById('ds-detail-json');
var rows = [];
try {
rows = JSON.parse(raw.textContent || '[]');
} catch (e) {
rows = [];
}
var readOnly = <?= json_encode($readOnly) ?>;
var body = document.getElementById('ds-list-body');
var placeholder = document.getElementById('ds-detail-placeholder');
var fieldsWrap = document.getElementById('ds-detail-fields');
var infoTable = document.getElementById('ds-detail-info-table');
var editLink = readOnly ? null : document.getElementById('ds-edit-link');
var delForm = readOnly ? null : document.getElementById('ds-delete-form');
var delBtn = readOnly ? null : document.getElementById('ds-delete-btn');
// mgmt_url() 이 path 를 trim 하므로 'edit/33' 이 아니라 'edit33' 로 붙지 않게 슬래시를 넣음
var editBase = <?= json_encode(mgmt_url('designated-shops/edit')) ?>;
var delBase = <?= json_encode(mgmt_url('designated-shops/delete')) ?>;
function textVal(v) {
return (v === '' || v == null) ? '—' : String(v);
}
function buildKakaoMapSearchQuery(d) {
var road = String(d.ds_addr || '').trim();
var jibun = String(d.ds_addr_jibun || '').trim();
var detail = String(d.ds_addr_detail || '').trim();
var q = road || jibun;
if (detail) {
q = q ? (q + ' ' + detail) : detail;
}
return q;
}
function fillDetailInfoTable(d) {
if (!infoTable) return;
infoTable.querySelectorAll('[data-ro]').forEach(function (el) {
var k = el.getAttribute('data-ro');
var v = d[k];
if (k === 'ds_va_account') {
v = d.ds_va_account || d.ds_va_number || '';
}
el.textContent = textVal(v);
});
window.__dsDetailForMap = d;
}
function selectIndex(idx) {
if (!rows.length || idx < 0 || idx >= rows.length) return;
var d = rows[idx];
Array.prototype.forEach.call(body.querySelectorAll('tr.ds-list-row'), function (tr) {
tr.classList.remove('ds-row-selected');
});
var tr = body.querySelector('tr[data-row-index="' + idx + '"]');
if (tr) tr.classList.add('ds-row-selected');
placeholder.classList.add('hidden');
fieldsWrap.classList.remove('hidden');
fillDetailInfoTable(d);
if (!readOnly && editLink && delForm && delBtn) {
var id = d.ds_idx;
editLink.href = editBase + '/' + id;
editLink.classList.remove('pointer-events-none', 'opacity-40');
delForm.action = delBase + '/' + id;
delBtn.disabled = false;
delBtn.classList.remove('pointer-events-none', 'opacity-40');
}
}
if (body) {
body.addEventListener('click', function (e) {
var tr = e.target.closest('tr.ds-list-row');
if (!tr) return;
var idx = parseInt(tr.getAttribute('data-row-index'), 10);
if (!isNaN(idx)) selectIndex(idx);
});
body.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
var tr = e.target.closest('tr.ds-list-row');
if (!tr) return;
e.preventDefault();
var idx = parseInt(tr.getAttribute('data-row-index'), 10);
if (!isNaN(idx)) selectIndex(idx);
});
}
var mapBtnRo = document.getElementById('ds-ro-map-btn');
if (mapBtnRo) {
mapBtnRo.addEventListener('click', function (ev) {
ev.preventDefault();
var d = window.__dsDetailForMap;
if (!d) return;
var q = buildKakaoMapSearchQuery(d);
if (!q) {
window.alert('표시할 주소 정보가 없습니다.');
return;
}
if (typeof window.openDesignatedShopKakaoMap === 'function') {
window.openDesignatedShopKakaoMap(q);
} else {
window.open('https://map.kakao.com/link/search/' + encodeURIComponent(q), '_blank', 'noopener,noreferrer');
}
});
}
if (rows.length > 0) {
selectIndex(0);
} else if (!readOnly && editLink && delBtn) {
editLink.classList.add('pointer-events-none', 'opacity-40');
delBtn.disabled = true;
delBtn.classList.add('pointer-events-none', 'opacity-40');
}
})();
</script>
<!-- 인쇄용: 전체 테이블 -->
<div class="hidden print:block print:p-4">
<table class="w-full data-table text-xs">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th>번호</th>
<th>지자체</th> <th>지자체</th>
<th>판매소번호</th> <th>구·군</th>
<th>지정일</th>
<th>구역</th>
<th>대표자명</th>
<th>상호명</th> <th>상호명</th>
<th>대표자</th> <th>우편번호</th>
<th>주소</th>
<th>사업자번호</th> <th>사업자번호</th>
<th>전화</th>
<th>판매소번호</th>
<th>가상계좌</th> <th>가상계좌</th>
<th>상태</th> <th>상태</th>
<th>등록일</th> <th>등록일</th>
<th class="w-28">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody>
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<?php
$snP = (string) ($row->ds_shop_no ?? '');
if (preg_match('/(\d{3})$/', $snP, $mP)) {
$shortNoP = $mP[1];
} elseif ($snP !== '' && strlen($snP) >= 3) {
$shortNoP = substr($snP, -3);
} else {
$shortNoP = $snP;
}
$daP = $row->ds_designated_at ?? null;
$daDispP = ($daP !== null && $daP !== '' && (string) $daP !== '0000-00-00') ? substr((string) $daP, 0, 10) : '';
$stP = (int) ($row->ds_state ?? 1);
$stLabP = $stP === 1 ? '정상' : ($stP === 2 ? '폐업' : '직권해지');
$zipP = trim((string) ($row->ds_zip ?? ''));
$roadP = trim((string) ($row->ds_addr ?? ''));
$jibP = trim((string) ($row->ds_addr_jibun ?? ''));
$addrP = $roadP !== '' ? $roadP : $jibP;
$detP = trim((string) ($row->ds_addr_detail ?? ''));
$addrCombinedP = trim($addrP . ' ' . $detP);
if ($addrCombinedP === '') {
$addrCombinedP = $addrP;
}
?>
<tr> <tr>
<td class="text-center"><?= esc($row->ds_idx) ?></td> <td class="text-center"><?= esc($shortNoP) ?></td>
<td class="text-left pl-2"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td> <td class="text-left"><?= esc($lgMap[$row->ds_lg_idx] ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_shop_no) ?></td> <?php $gCodeP = (string) ($row->ds_gugun_code ?? ''); ?>
<td class="text-left pl-2"><?= esc($row->ds_name) ?></td> <td class="text-left"><?= esc((string) (($gugunNameMap[$gCodeP] ?? '') !== '' ? $gugunNameMap[$gCodeP] : $gCodeP)) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_rep_name) ?></td> <td class="text-center"><?= esc($daDispP) ?></td>
<td class="text-left pl-2"><?= esc($row->ds_biz_no) ?></td> <td class="text-left"><?= esc($row->ds_zone_code ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_va_number) ?></td> <td class="text-left"><?= esc($row->ds_rep_name ?? '') ?></td>
<td class="text-center"><?= (int) $row->ds_state === 1 ? '정상' : ((int) $row->ds_state === 2 ? '폐업' : '직권해지') ?></td> <td class="text-left"><?= esc($row->ds_name ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->ds_regdate ?? '') ?></td> <td class="text-left"><?= esc($zipP) ?></td>
<td class="text-center"> <td class="text-left"><?= esc($addrCombinedP) ?></td>
<a href="<?= mgmt_url('designated-shops/edit/' . (int) $row->ds_idx) ?>" class="text-blue-600 hover:underline text-sm">수정</a> <td class="text-left"><?= esc($row->ds_biz_no ?? '') ?></td>
<form action="<?= mgmt_url('designated-shops/delete/' . (int) $row->ds_idx) ?>" method="POST" class="inline ml-1" onsubmit="return confirm('이 지정판매소를 삭제하시겠습니까?');"> <td class="text-left"><?= esc($row->ds_tel ?? '') ?></td>
<?= csrf_field() ?> <td class="text-left"><?= esc($row->ds_shop_no) ?></td>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button> <td class="text-left"><?= esc($row->ds_va_number) ?></td>
</form> <td class="text-center"><?= esc($stLabP) ?></td>
</td> <td class="text-left"><?= esc($row->ds_regdate ?? '') ?></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>

View File

@@ -8,12 +8,12 @@
<div id="kakao-map" class="w-full border border-gray-300 mt-2" style="height:600px;"></div> <div id="kakao-map" class="w-full border border-gray-300 mt-2" style="height:600px;"></div>
<div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div> <div class="mt-2 text-sm text-gray-500">총 <?= count($shops) ?>개 판매소 표시</div>
<script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=KAKAO_APP_KEY&libraries=services"></script> <script src="//dapi.kakao.com/v2/maps/sdk.js?appkey=<?= esc($kakaoJavascriptKey ?? '', 'attr') ?>&libraries=services"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var mapContainer = document.getElementById('kakao-map'); var mapContainer = document.getElementById('kakao-map');
if (typeof kakao === 'undefined' || typeof kakao.maps === 'undefined') { if (typeof kakao === 'undefined' || typeof kakao.maps === 'undefined') {
mapContainer.innerHTML = '<div class="flex items-center justify-center h-full text-gray-400">카카오맵 API 키를 설정해 주세요.</div>'; mapContainer.innerHTML = '<div class="flex items-center justify-center h-full text-gray-500 text-sm px-4 text-center">카카오맵을 불러올 수 없습니다. Kakao Developers → 제품 설정에서 「Kakao Map」을 켜고, 플랫폼(Web)에 이 사이트 URL을 등록했는지 확인하세요.</div>';
return; return;
} }

View File

@@ -1,80 +1,387 @@
<?= view('components/print_header', ['printTitle' => '지정판매소 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> $ry = (int) ($reportYear ?? (int) date('Y'));
$exportUrl = mgmt_url('designated-shops/status/export') . '?' . http_build_query([
'year' => $ry,
]);
$fixedGugunLabel = trim((string) ($fixedGugunLabel ?? ''));
$regionColLabel = '군·구';
$sumCurrForPct = (int) ($districtTotal->curr_end ?? 0);
?>
<style>
.ds-status-x-scroll {
overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
max-width: 100%;
}
@media print {
.ds-status-x-scroll { overflow: visible !important; border: none; }
}
.ds-status-x-scroll .ds-status-table {
width: max-content;
min-width: 100%;
border-collapse: collapse;
}
.ds-status-x-scroll .ds-status-table th,
.ds-status-x-scroll .ds-status-table td {
white-space: nowrap;
padding: 6px 10px;
font-size: 12px;
}
.ds-status-x-scroll .ds-status-table thead th {
background: #e9ecef;
border: 1px solid #ccc;
}
.ds-status-x-scroll .ds-status-table tbody td {
border: 1px solid #ccc;
}
.ds-status-x-scroll th.sticky-num,
.ds-status-x-scroll td.sticky-num {
position: sticky;
left: 0;
z-index: 3;
min-width: 3rem;
max-width: 3rem;
width: 3rem;
box-sizing: border-box;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
}
.ds-status-x-scroll td.sticky-num {
background: #fff;
text-align: center;
}
.ds-status-x-scroll tr.sum-row td.sticky-num {
background: #f3f4f6;
}
.ds-status-x-scroll th.sticky-region,
.ds-status-x-scroll td.sticky-region {
position: sticky;
left: 3rem;
z-index: 2;
background: #e9ecef;
border-right: 1px solid #bbb;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.06);
max-width: 16rem;
text-align: left;
}
.ds-status-x-scroll td.sticky-region {
background: #fff;
overflow: hidden;
text-overflow: ellipsis;
}
.ds-status-x-scroll tr.sum-row td.sticky-region {
background: #f3f4f6;
}
.ds-help {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.ds-help-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1rem;
height: 1rem;
padding: 0 2px;
border: 1px solid #64748b;
border-radius: 999px;
background: #fef9c3;
color: #111827;
font-size: 11px;
font-weight: 700;
line-height: 1;
font-family: Arial, sans-serif;
cursor: help;
user-select: none;
}
.ds-floating-tip {
position: fixed;
left: 0;
top: 0;
display: none;
max-width: min(22rem, calc(100vw - 16px));
padding: 6px 8px;
border: 1px solid #8fa0b3;
border-radius: 4px;
background: #1f2937;
color: #fff;
font-size: 11px;
font-weight: 500;
line-height: 1.35;
text-align: left;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
z-index: 9999;
pointer-events: none;
}
</style>
<?= view('components/print_header', ['printTitle' => '지정판매소 신규·취소 현황 (' . $ry . '년)']) ?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print">
<div class="flex flex-wrap items-center justify-between gap-y-2"> <div class="flex flex-wrap items-center justify-between gap-y-2">
<span class="text-sm font-bold text-gray-700">지정판매소 현황 (신규/취소)</span> <span class="text-sm font-bold text-gray-700">지정판매소 현황 (신규/취소)</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="window.print()" class="no-print border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button> <a href="<?= esc($exportUrl) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50 transition">엑셀저장</a>
<button type="button" onclick="window.print()" class="border border-btn-print-border text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50 transition">인쇄</button>
<a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a> <a href="<?= mgmt_url('designated-shops') ?>" class="border border-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">목록으로</a>
</div> </div>
</div> </div>
</section> </section>
<!-- 전체 현황 요약 --> <section class="p-2 bg-white border-b border-gray-200 no-print">
<div class="flex gap-4 mt-2 mb-2"> <form method="get" action="<?= mgmt_url('designated-shops/status') ?>" class="flex flex-wrap items-end gap-3">
<div class="border border-gray-300 p-3 flex-1 text-center"> <div>
<div class="text-sm text-gray-500">활성 판매소</div> <label class="block text-xs text-gray-600 mb-0.5">연도</label>
<div class="text-2xl font-bold text-green-600"><?= number_format($totalActive) ?></div> <select name="year" class="border border-gray-300 rounded px-2 py-1 text-sm min-w-[6rem]">
</div> <?php foreach (($yearChoices ?? []) as $y): ?>
<div class="border border-gray-300 p-3 flex-1 text-center"> <option value="<?= (int) $y ?>" <?= $ry === (int) $y ? 'selected' : '' ?>><?= (int) $y ?>년</option>
<div class="text-sm text-gray-500">비활성/취소 판매소</div> <?php endforeach; ?>
<div class="text-2xl font-bold text-red-600"><?= number_format($totalInactive) ?></div> </select>
</div> </div>
<div class="border border-gray-300 p-3 flex-1 text-center"> <div class="min-w-[12rem]">
<div class="text-sm text-gray-500">전체</div> <label class="block text-xs text-gray-600 mb-0.5">군·구</label>
<div class="text-2xl font-bold text-gray-700"><?= number_format($totalActive + $totalInactive) ?></div> <div class="border border-gray-300 rounded px-3 py-1 text-sm bg-gray-50 text-gray-800 font-medium pointer-events-none select-none">
</div> <?= esc($fixedGugunLabel !== '' ? $fixedGugunLabel : '현재 지자체 기준') ?>
</div>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
<p class="text-xs text-gray-500 mt-2">
종전·현행은 각각 <?= $ry - 1 ?>-12-31·<?= $ry ?>-12-31 기준 정상(지정일 이전·비정상 전환일 이후) 건수이며, 지정·취소는 <?= $ry ?>년 달력연도 기준입니다. 군·구는 현재 로그인 사용자의 지자체 기준으로 고정 표시됩니다.
</p>
</section>
<!-- 인쇄 시에도 보이는 본표 -->
<div class="mx-2 mt-2 mb-2 ds-status-x-scroll">
<table class="ds-status-table data-table">
<thead>
<tr>
<th class="sticky-num text-center w-12">순번</th>
<th class="sticky-region"><?= esc($regionColLabel) ?></th>
<th class="text-left">
<span class="ds-help">구코드 <span class="ds-help-badge" tabindex="0" data-tip="지정판매소에 저장된 구·군 코드 값">?</span></span>
</th>
<th class="text-right">
<span class="ds-help">종전 <span class="ds-help-badge" tabindex="0" data-tip="전년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(전년도말)
</th>
<th class="text-right">
<span class="ds-help">지정 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 지정일이 속한 신규 지정 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 내 폐업/해지 전환일이 속한 건수">?</span></span>(<?= $ry ?>년)
</th>
<th class="text-right">
<span class="ds-help">현행 <span class="ds-help-badge" tabindex="0" data-tip="조회년도 12월 31일 기준 정상 상태 판매소 수">?</span></span>(금년도말)
</th>
<th class="text-right">
<span class="ds-help">증감 <span class="ds-help-badge" tabindex="0" data-tip="현행에서 종전을 뺀 값 (현행−종전)">?</span></span>
<br/><span class="font-normal text-xs">(현행−종전)</span>
</th>
<th class="text-right">
<span class="ds-help">지정−취소 <span class="ds-help-badge" tabindex="0" data-tip="<?= esc($ry) ?>년 지정 건수에서 취소 건수를 뺀 값">?</span></span>
<br/><span class="font-normal text-xs">(<?= $ry ?>년)</span>
</th>
<th class="text-right">
<span class="ds-help">현행비중 <span class="ds-help-badge" tabindex="0" data-tip="전체 현행 합계 대비 해당 행 현행 건수의 비율(%)">?</span></span>
<br/><span class="font-normal text-xs">(%)</span>
</th>
<th class="text-right">
<span class="ds-help">전년대비 <span class="ds-help-badge ds-help-right" tabindex="0" data-tip="((현행−종전) / 종전) × 100, 종전이 0이면 표시 안함">?</span></span>
<br/><span class="font-normal text-xs">증감률(%)</span>
</th>
</tr>
</thead>
<tbody class="text-right">
<?php $rowNo = 0; ?>
<?php foreach (($districtRows ?? []) as $row): ?>
<?php
$rowNo++;
$curr = (int) $row->curr_end;
$prev = (int) $row->prev_end;
$pctShare = $sumCurrForPct > 0 ? round(($curr / $sumCurrForPct) * 100, 1) : 0.0;
$yoyPct = $prev > 0 ? round((($curr - $prev) / $prev) * 100, 1) : null;
?>
<tr>
<td class="sticky-num"><?= $rowNo ?></td>
<td class="sticky-region" title="<?= esc($row->region_label) ?>"><?= esc($row->region_label) ?></td>
<td class="text-left text-xs"><?= esc((string) ($row->gugun_code ?? '')) ?></td>
<td><?= number_format($prev) ?></td>
<td><?= number_format((int) $row->designated_y) ?></td>
<td><?= number_format((int) $row->cancelled_y) ?></td>
<td><?= number_format($curr) ?></td>
<td><?= number_format((int) ($row->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($row->delta_des_cancel ?? 0)) ?></td>
<td><?= $pctShare ?></td>
<td><?= $yoyPct !== null ? $yoyPct : '—' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($districtRows)): ?>
<tr><td colspan="11" class="text-center text-gray-400 py-6">조건에 맞는 데이터가 없습니다.</td></tr>
<?php endif; ?>
<?php if (! empty($districtRows) && isset($districtTotal)): ?>
<tr class="font-bold bg-gray-50 sum-row">
<td class="sticky-num">—</td>
<td class="sticky-region"><?= esc($districtTotal->region_label) ?></td>
<td class="text-left">—</td>
<td><?= number_format((int) $districtTotal->prev_end) ?></td>
<td><?= number_format((int) $districtTotal->designated_y) ?></td>
<td><?= number_format((int) $districtTotal->cancelled_y) ?></td>
<td><?= number_format((int) $districtTotal->curr_end) ?></td>
<td><?= number_format((int) ($districtTotal->delta_curr_prev ?? 0)) ?></td>
<td><?= number_format((int) ($districtTotal->delta_des_cancel ?? 0)) ?></td>
<td>100</td>
<td>
<?php
$tPrev = (int) $districtTotal->prev_end;
$tCurr = (int) $districtTotal->curr_end;
echo $tPrev > 0 ? round((($tCurr - $tPrev) / $tPrev) * 100, 1) : '—';
?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2"> <?php $zoneRows = $zoneSummaryRows ?? []; ?>
<!-- 연도별 신규등록 --> <section class="mx-2 mb-3 no-print">
<div> <div class="text-xs font-semibold text-gray-700 mb-1">동별 현행 요약 (<?= esc($fixedGugunLabel ?? '군·구') ?>)</div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 신규등록 건수</h3> <?php if (! empty($zoneRows)): ?>
<div class="border border-gray-300 overflow-auto"> <div class="flex flex-wrap gap-1 mb-2">
<table class="w-full data-table"> <?php foreach ($zoneRows as $z): ?>
<thead> <span class="inline-flex items-center px-2 py-0.5 text-xs rounded border border-gray-300 bg-gray-50 text-gray-700">
<tr> <?= esc((string) $z->zone_label) ?> <?= number_format((int) $z->curr_end) ?>
<th>연도</th> </span>
<th>신규등록 건수</th> <?php endforeach; ?>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($newByYear as $row): ?>
<tr>
<td class="text-center"><?= esc($row->yr) ?>년</td>
<td><?= number_format((int) $row->cnt) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div> </div>
<div class="border border-gray-300 bg-white overflow-auto max-h-56">
<table class="w-full data-table text-xs">
<thead>
<tr>
<th class="text-left">동</th>
<th class="text-right">종전</th>
<th class="text-right">지정</th>
<th class="text-right">취소</th>
<th class="text-right">현행</th>
<th class="text-right">증감</th>
</tr>
</thead>
<tbody class="text-right">
<?php foreach ($zoneRows as $z): ?>
<tr>
<td class="text-left"><?= esc((string) $z->zone_label) ?></td>
<td><?= number_format((int) $z->prev_end) ?></td>
<td><?= number_format((int) $z->designated_y) ?></td>
<td><?= number_format((int) $z->cancelled_y) ?></td>
<td><?= number_format((int) $z->curr_end) ?></td>
<td><?= number_format((int) $z->delta_curr_prev) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p class="text-xs text-gray-500">동별 집계 데이터가 없습니다.</p>
<?php endif; ?>
</section>
<!-- 연도별 취소/비활성 --> <div id="ds-floating-tip" class="ds-floating-tip no-print" aria-hidden="true"></div>
<div>
<h3 class="text-sm font-bold text-gray-700 mb-1">연도별 취소/비활성 건수</h3> <script>
<div class="border border-gray-300 overflow-auto"> (function () {
<table class="w-full data-table"> var tipEl = document.getElementById('ds-floating-tip');
<thead> if (!tipEl) return;
<tr> var badges = Array.prototype.slice.call(document.querySelectorAll('.ds-help-badge'));
<th>연도</th> if (!badges.length) return;
<th>취소/비활성 건수</th>
</tr> function placeTip(target) {
</thead> var text = String(target.getAttribute('data-tip') || '').trim();
<tbody class="text-right"> if (!text) return;
<?php foreach ($cancelByYear as $row): ?> tipEl.textContent = text;
<tr> tipEl.style.display = 'block';
<td class="text-center"><?= esc($row->yr) ?>년</td> tipEl.setAttribute('aria-hidden', 'false');
<td><?= number_format((int) $row->cnt) ?></td>
</tr> var rect = target.getBoundingClientRect();
<?php endforeach; ?> var tipRect = tipEl.getBoundingClientRect();
<?php if (empty($cancelByYear)): ?> var vw = window.innerWidth || document.documentElement.clientWidth;
<tr><td colspan="2" class="text-center text-gray-400 py-4">데이터가 없습니다.</td></tr> var vh = window.innerHeight || document.documentElement.clientHeight;
<?php endif; ?> var gap = 8;
</tbody>
</table> var left = rect.left + (rect.width / 2) - (tipRect.width / 2);
var top = rect.bottom + gap;
if (left < gap) left = gap;
if (left + tipRect.width > vw - gap) left = vw - gap - tipRect.width;
if (top + tipRect.height > vh - gap) top = rect.top - gap - tipRect.height;
if (top < gap) top = gap;
tipEl.style.left = Math.round(left) + 'px';
tipEl.style.top = Math.round(top) + 'px';
}
function hideTip() {
tipEl.style.display = 'none';
tipEl.setAttribute('aria-hidden', 'true');
tipEl.textContent = '';
}
badges.forEach(function (badge) {
badge.addEventListener('mouseenter', function () { placeTip(badge); });
badge.addEventListener('focus', function () { placeTip(badge); });
badge.addEventListener('mouseleave', hideTip);
badge.addEventListener('blur', hideTip);
});
window.addEventListener('scroll', hideTip, true);
window.addEventListener('resize', hideTip);
})();
</script>
<details class="mx-2 mb-4 no-print text-sm">
<summary class="cursor-pointer text-gray-600 hover:text-gray-800">연도별 요약 (참고)</summary>
<div class="flex gap-4 mt-2">
<div class="border border-gray-300 p-2 flex-1">
<div class="text-xs font-bold text-gray-700 mb-1">활성 / 비활성 / 전체</div>
<div class="text-sm">활성 <?= number_format((int) ($totalActive ?? 0)) ?> · 비활성 <?= number_format((int) ($totalInactive ?? 0)) ?> · 합 <?= number_format((int) ($totalActive ?? 0) + (int) ($totalInactive ?? 0)) ?></div>
</div> </div>
</div> </div>
</div> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
<div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 신규등록 (지정일)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach (($newByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($newByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div>
<h3 class="text-xs font-bold text-gray-700 mb-1">연도별 취소/비활성 (등록일 기준)</h3>
<div class="border border-gray-300 overflow-auto max-h-48">
<table class="w-full data-table text-xs">
<thead><tr><th>연도</th><th>건수</th></tr></thead>
<tbody class="text-right">
<?php foreach (($cancelByYear ?? []) as $row): ?>
<tr><td class="text-center"><?= esc($row->yr) ?>년</td><td><?= number_format((int) $row->cnt) ?></td></tr>
<?php endforeach; ?>
<?php if (empty($cancelByYear)): ?>
<tr><td colspan="2" class="text-center text-gray-400 py-2">없음</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</details>

View File

@@ -13,11 +13,9 @@
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16">번호</th>
<th>구분</th>
<th>대상자명</th> <th>대상자명</th>
<th>연락처</th> <th>연락처</th>
<th>주소</th> <th>주소</th>
<th>동코드</th>
<th>비고</th> <th>비고</th>
<th>종료일</th> <th>종료일</th>
<th class="w-20">상태</th> <th class="w-20">상태</th>
@@ -28,11 +26,9 @@
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->fr_idx) ?></td> <td class="text-center"><?= esc($row->fr_idx) ?></td>
<td class="text-center"><?= esc($row->fr_type_code) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_name) ?></td> <td class="text-left pl-2"><?= esc($row->fr_name) ?></td>
<td class="text-center"><?= esc($row->fr_phone) ?></td> <td class="text-center"><?= esc($row->fr_phone) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_addr) ?></td> <td class="text-left pl-2"><?= esc($row->fr_addr) ?></td>
<td class="text-center"><?= esc($row->fr_dong_code) ?></td>
<td class="text-left pl-2"><?= esc($row->fr_note) ?></td> <td class="text-left pl-2"><?= esc($row->fr_note) ?></td>
<td class="text-center"><?= esc($row->fr_end_date) ?></td> <td class="text-center"><?= esc($row->fr_end_date) ?></td>
<td class="text-center"><?= (int) $row->fr_state === 1 ? '사용' : '미사용' ?></td> <td class="text-center"><?= (int) $row->fr_state === 1 ? '사용' : '미사용' ?></td>
@@ -47,7 +43,7 @@
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr> <tr>
<td colspan="10" class="text-center text-gray-500 py-4 text-sm space-y-1"> <td colspan="8" class="text-center text-gray-500 py-4 text-sm space-y-1">
<p>등록된 데이터가 없습니다.</p> <p>등록된 데이터가 없습니다.</p>
<p class="text-gray-400">다른 지자체를 선택 중이면 해당 지자체 기준으로만 조회됩니다. Super Admin 은 상단에서 작업 지자체를 바꿔 보세요.</p> <p class="text-gray-400">다른 지자체를 선택 중이면 해당 지자체 기준으로만 조회됩니다. Super Admin 은 상단에서 작업 지자체를 바꿔 보세요.</p>
</td> </td>

View File

@@ -93,14 +93,10 @@ body { overflow: hidden; }
<?php <?php
$hasChildren = ! empty($navItem->children); $hasChildren = ! empty($navItem->children);
$parentLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath); $parentLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath);
$activeChild = $hasChildren ? menu_active_child_for_parent($navItem, $currentPath, []) : null;
$parentIsCurrent = $adminNavItemIsCurrent($navItem->mm_link ?? null); $parentIsCurrent = $adminNavItemIsCurrent($navItem->mm_link ?? null);
if (! $parentIsCurrent && $hasChildren) { if (! $parentIsCurrent && $activeChild !== null) {
foreach ($navItem->children as $ch) { $parentIsCurrent = true;
if ($adminNavItemIsCurrent($ch->mm_link ?? null)) {
$parentIsCurrent = true;
break;
}
}
} }
?> ?>
<div class="relative group"> <div class="relative group">
@@ -115,7 +111,8 @@ body { overflow: hidden; }
<?php foreach ($navItem->children as $child): ?> <?php foreach ($navItem->children as $child): ?>
<?php <?php
$childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath); $childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
$childIsCurrent = $adminNavItemIsCurrent($child->mm_link ?? null); $childIsCurrent = $activeChild !== null
&& (int) ($child->mm_idx ?? 0) === (int) ($activeChild->mm_idx ?? -1);
?> ?>
<?php if ($childLink !== ''): ?> <?php if ($childLink !== ''): ?>
<a href="<?= base_url($childLink) ?>" <a href="<?= base_url($childLink) ?>"

View File

@@ -11,28 +11,18 @@
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">소속</label> <label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_dept_code"> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($deptCodes as $cd): ?> <?php foreach (($categories ?? []) as $key => $label): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_dept_code') === $cd->cd_code ? 'selected' : '' ?>> <option value="<?= esc($key) ?>" <?= old('mg_category') === $key ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?> <?= esc($label) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <input type="hidden" name="mg_position_code" value="<?= esc(old('mg_position_code', '')) ?>"/>
<label class="block text-sm font-bold text-gray-700 w-28">직위</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_position_code">
<option value="">선택</option>
<?php foreach ($positionCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_position_code') === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">전화</label> <label class="block text-sm font-bold text-gray-700 w-28">전화</label>

View File

@@ -11,28 +11,18 @@
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">소속</label> <label class="block text-sm font-bold text-gray-700 w-28">담당자 구분 <span class="text-red-500">*</span></label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_dept_code"> <select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_category" required>
<option value="">선택</option> <option value="">선택</option>
<?php foreach ($deptCodes as $cd): ?> <?php foreach (($categories ?? []) as $key => $label): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_dept_code', $item->mg_dept_code) === $cd->cd_code ? 'selected' : '' ?>> <option value="<?= esc($key) ?>" <?= old('mg_category', (string) ($item->mg_dept_code ?? '')) === $key ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?> <?= esc($label) ?>
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <input type="hidden" name="mg_position_code" value="<?= esc(old('mg_position_code', $item->mg_position_code)) ?>"/>
<label class="block text-sm font-bold text-gray-700 w-28">직위</label>
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="mg_position_code">
<option value="">선택</option>
<?php foreach ($positionCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>" <?= old('mg_position_code', $item->mg_position_code) === $cd->cd_code ? 'selected' : '' ?>>
<?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">전화</label> <label class="block text-sm font-bold text-gray-700 w-28">전화</label>

View File

@@ -8,14 +8,26 @@
</div> </div>
</div> </div>
</section> </section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="GET" action="<?= mgmt_url('managers') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">카테고리</label>
<select name="category" class="border border-gray-300 rounded px-2 py-1 text-sm w-44">
<option value="">전 체</option>
<?php foreach (($categories ?? []) as $key => $label): ?>
<option value="<?= esc($key) ?>" <?= ($category ?? '') === $key ? 'selected' : '' ?>><?= esc($label) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('managers') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16">번호</th>
<th>담당자명</th> <th>담당자명</th>
<th>소속</th> <th>카테고리</th>
<th>직위</th>
<th>전화</th> <th>전화</th>
<th>휴대전화</th> <th>휴대전화</th>
<th>이메일</th> <th>이메일</th>
@@ -28,8 +40,13 @@
<tr> <tr>
<td class="text-center"><?= esc($row->mg_idx) ?></td> <td class="text-center"><?= esc($row->mg_idx) ?></td>
<td class="text-center"><?= esc($row->mg_name) ?></td> <td class="text-center"><?= esc($row->mg_name) ?></td>
<td class="text-center"><?= esc($row->mg_dept_code) ?></td> <td class="text-center">
<td class="text-center"><?= esc($row->mg_position_code) ?></td> <?php
$cat = (string) ($row->mg_dept_code ?? '');
$catLabel = $categories[$cat] ?? $cat;
echo esc($catLabel);
?>
</td>
<td class="text-center"><?= esc($row->mg_tel) ?></td> <td class="text-center"><?= esc($row->mg_tel) ?></td>
<td class="text-center"><?= esc($row->mg_phone) ?></td> <td class="text-center"><?= esc($row->mg_phone) ?></td>
<td class="text-center"><?= esc($row->mg_email) ?></td> <td class="text-center"><?= esc($row->mg_email) ?></td>
@@ -44,7 +61,7 @@
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?> <?php if (empty($list)): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr> <tr><td colspan="8" class="text-center text-gray-400 py-4">등록된 데이터가 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
</table> </table>

View File

@@ -25,7 +25,7 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="block text-sm font-bold text-gray-700 w-32">적용시작일 <span class="text-red-500">*</span></label> <label class="block text-sm font-bold text-gray-700 w-32">적용시작일 <span class="text-red-500">*</span></label>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="pu_start_date" type="date" value="<?= esc(old('pu_start_date', $item->pu_start_date)) ?>" required/> <input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="pu_start_date" type="date" value="<?= esc(old('pu_start_date', date('Y-m-d'))) ?>" required/>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">

View File

@@ -6,11 +6,20 @@
</div> </div>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2"> <div class="border border-gray-300 overflow-auto mt-2">
<?php
$fieldLabelMap = [
'pu_box_per_pack' => '박스당 팩 수',
'pu_pack_per_sheet' => '팩당 낱장 수',
'pu_start_date' => '적용시작일',
'pu_end_date' => '적용종료일',
'pu_state' => '상태',
];
?>
<table class="w-full data-table"> <table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-16">번호</th>
<th>변경 필드</th> <th>변경 내용</th>
<th>이전 값</th> <th>이전 값</th>
<th>변경 값</th> <th>변경 값</th>
<th>변경일시</th> <th>변경일시</th>
@@ -20,7 +29,7 @@
<?php foreach ($list as $row): ?> <?php foreach ($list as $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->puh_idx) ?></td> <td class="text-center"><?= esc($row->puh_idx) ?></td>
<td class="text-left pl-2"><?= esc($row->puh_field) ?></td> <td class="text-left pl-2"><?= esc($fieldLabelMap[(string) $row->puh_field] ?? $row->puh_field) ?></td>
<td><?= esc($row->puh_old_value) ?></td> <td><?= esc($row->puh_old_value) ?></td>
<td><?= esc($row->puh_new_value) ?></td> <td><?= esc($row->puh_new_value) ?></td>
<td class="text-center"><?= esc($row->puh_changed_at) ?></td> <td class="text-center"><?= esc($row->puh_changed_at) ?></td>

View File

@@ -35,9 +35,17 @@
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->pu_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-center font-mono"><?= esc($row->pu_bag_code) ?></td> <td class="text-center font-mono"><?= esc($row->pu_bag_code) ?></td>
<td class="text-left pl-2"><?= esc($row->pu_bag_name) ?></td> <td class="text-left pl-2"><?= esc($row->pu_bag_name) ?></td>
<td><?= number_format((int) $row->pu_box_per_pack) ?></td> <td><?= number_format((int) $row->pu_box_per_pack) ?></td>

View File

@@ -44,9 +44,17 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($list as $row): ?> <?php
$startNo = 1;
if (isset($pager) && method_exists($pager, 'getCurrentPage') && method_exists($pager, 'getPerPage')) {
$currentPage = (int) $pager->getCurrentPage();
$perPage = (int) $pager->getPerPage();
$startNo = (($currentPage > 0 ? $currentPage : 1) - 1) * ($perPage > 0 ? $perPage : 20) + 1;
}
?>
<?php foreach (($list ?? []) as $idx => $row): ?>
<tr> <tr>
<td class="text-center"><?= esc($row->sa_idx) ?></td> <td class="text-center"><?= (int) $startNo + (int) $idx ?></td>
<td class="text-left pl-2"><?= esc($row->sa_kind ?? '') ?></td> <td class="text-left pl-2"><?= esc($row->sa_kind ?? '') ?></td>
<td class="text-center"><?= esc($row->sa_code ?? '') ?></td> <td class="text-center"><?= esc($row->sa_code ?? '') ?></td>
<td class="text-left pl-2"><?= esc($row->sa_name) ?></td> <td class="text-left pl-2"><?= esc($row->sa_name) ?></td>

View File

@@ -2,62 +2,144 @@
/** @var list<object> $codeKinds */ /** @var list<object> $codeKinds */
/** @var array<int,int> $countMap */ /** @var array<int,int> $countMap */
/** @var bool $canManageKinds */ /** @var bool $canManageKinds */
$canManageKinds = ! empty($canManageKinds); /** @var bool $canManageDetails */
$showKindActions = $canManageKinds; /** @var object|null $selectedKind */
$colCount = 6 + ($showKindActions ? 1 : 0); /** @var list<object> $detailList */
/** @var array<int,bool> $rowCanEdit */
$canManageKinds = ! empty($canManageKinds);
$canManageDetails = ! empty($canManageDetails);
$showKindActions = $canManageKinds;
$selectedKindId = (int) ($selectedKind->ck_idx ?? 0);
$colCount = 6 + ($showKindActions ? 1 : 0);
$detailColCount = 7 + ($canManageDetails ? 1 : 0);
?> ?>
<div class="space-y-4"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<section> <section>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 border-b pb-1"> <div class="flex flex-wrap items-center justify-between gap-2 mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">기본코드 종류</h3> <h3 class="text-base font-bold text-gray-700">기본코드 종류</h3>
<div class="flex flex-wrap items-center gap-2 text-xs sm:text-sm"> <div class="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<?php if ($canManageKinds): ?> <?php if ($canManageKinds): ?>
<a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90">코드 종류 등록</a> <a href="<?= base_url('admin/code-kinds/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90">코드 종류 등록</a>
<?php elseif (! $canManageKinds): ?> <?php else: ?>
<span class="text-gray-500">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다. 세부코드는 행의 링크에서 조회할 수 있습니다.</span> <span class="text-gray-500">코드 종류 등록·수정은 super admin·본부 관리자만 가능합니다.</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
<table class="data-table"> <div class="border border-gray-300 overflow-auto">
<thead><tr> <table class="data-table w-full">
<th class="w-14"><?= $showKindActions ? 'PK' : '번호' ?></th> <thead><tr>
<th class="w-24">코드</th> <th class="w-14"><?= $showKindActions ? 'PK' : '번호' ?></th>
<th>코드</th> <th class="w-24">코드</th>
<th class="w-28">세부코드</th> <th>코드</th>
<th class="w-20">상태</th> <th class="w-24">세부건수</th>
<th class="w-40">등록일</th> <th class="w-20">상태</th>
<?php if ($showKindActions): ?> <th class="w-40">등록일</th>
<th class="w-44">작업</th>
<?php endif; ?>
</tr></thead>
<tbody>
<?php if (! empty($codeKinds)): ?>
<?php $i = 0; foreach ($codeKinds as $row): $i++; ?>
<tr>
<td class="text-center"><?= $showKindActions ? esc((string) $row->ck_idx) : (string) $i ?></td>
<td class="text-center font-mono"><?= esc($row->ck_code) ?></td>
<td><?= esc($row->ck_name) ?></td>
<td class="text-center">
<a href="<?= base_url('bag/code-details/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개 보기</a>
</td>
<td class="text-center"><?= (int) ($row->ck_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td class="text-left"><?= esc($row->ck_regdate ?? '') ?></td>
<?php if ($showKindActions): ?> <?php if ($showKindActions): ?>
<td class="text-center text-sm"> <th class="w-36">작업</th>
<a href="<?= base_url('bag/code-details/' . (int) $row->ck_idx) ?>" class="text-green-600 hover:underline mr-1">세부코드</a>
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline">삭제</button>
</form>
</td>
<?php endif; ?> <?php endif; ?>
</tr> </tr></thead>
<?php endforeach; ?> <tbody>
<?php else: ?> <?php if (! empty($codeKinds)): ?>
<tr><td colspan="<?= (string) $colCount ?>" class="text-center text-gray-400 py-4">등록된 코드 종류가 없습니다.</td></tr> <?php $i = 0; foreach ($codeKinds as $row): $i++; ?>
<?php
$isSelected = (int) $row->ck_idx === $selectedKindId;
$detailUrl = base_url('bag/code-kinds?ck_idx=' . (int) $row->ck_idx);
?>
<tr class="<?= $isSelected ? 'bg-blue-50' : '' ?> cursor-pointer hover:bg-blue-50"
onclick="window.location.href='<?= esc($detailUrl, 'attr') ?>'">
<td class="text-center"><?= $showKindActions ? esc((string) $row->ck_idx) : (string) $i ?></td>
<td class="text-center font-mono"><?= esc($row->ck_code) ?></td>
<td><?= esc($row->ck_name) ?></td>
<td class="text-center"><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
<td class="text-center"><?= (int) ($row->ck_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td class="text-left"><?= esc($row->ck_regdate ?? '') ?></td>
<?php if ($showKindActions): ?>
<td class="text-center text-sm" onclick="event.stopPropagation()">
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>" class="text-blue-600 hover:underline mr-1">수정</a>
<form action="<?= base_url('admin/code-kinds/delete/' . (int) $row->ck_idx) ?>" method="POST" class="inline" onsubmit="return confirm('이 코드 종류를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline">삭제</button>
</form>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="<?= (string) $colCount ?>" class="text-center text-gray-400 py-4">등록된 코드 종류가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section>
<div class="flex flex-wrap items-center justify-between gap-2 mb-2 border-b pb-1">
<h3 class="text-base font-bold text-gray-700">
세부코드
<?php if ($selectedKind !== null): ?>
— <?= esc($selectedKind->ck_name) ?> (<?= esc($selectedKind->ck_code) ?>)
<?php endif; ?>
</h3>
<?php if ($canManageDetails && $selectedKind !== null): ?>
<a href="<?= base_url('admin/code-details/' . (int) $selectedKind->ck_idx . '/create') ?>" class="inline-flex items-center rounded bg-[#1c4e80] px-3 py-1.5 text-white shadow hover:opacity-90 text-sm">세부코드 등록</a>
<?php endif; ?> <?php endif; ?>
</tbody> </div>
</table>
<?php if ($selectedKind === null): ?>
<div class="border border-gray-300 rounded p-6 text-center text-gray-500">왼쪽에서 코드 종류를 선택해 주세요.</div>
<?php else: ?>
<div class="border border-gray-300 overflow-auto">
<table class="data-table w-full">
<thead>
<tr>
<th class="w-16">번호</th>
<th class="w-24">코드</th>
<th>코드명</th>
<th class="w-24">범위</th>
<th class="w-20">정렬</th>
<th class="w-20">상태</th>
<th class="w-40">등록일</th>
<?php if ($canManageDetails): ?>
<th class="w-28">작업</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php if (! empty($detailList)): ?>
<?php foreach ($detailList as $row): ?>
<?php
$isPlatform = (($row->cd_source ?? 'platform') === 'platform' && (int) ($row->cd_lg_idx ?? 0) === 0);
$scopeLabel = $isPlatform ? '공통' : '지자체';
?>
<tr>
<td class="text-center"><?= esc((string) $row->cd_idx) ?></td>
<td class="text-center font-mono"><?= esc($row->cd_code) ?></td>
<td><?= esc($row->cd_name) ?></td>
<td class="text-center text-xs"><?= esc($scopeLabel) ?></td>
<td class="text-center"><?= (int) ($row->cd_sort ?? 0) ?></td>
<td class="text-center"><?= (int) ($row->cd_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td class="text-left"><?= esc($row->cd_regdate ?? '') ?></td>
<?php if ($canManageDetails): ?>
<td class="text-center text-sm">
<?php if (! empty($rowCanEdit[$row->cd_idx])): ?>
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>" class="text-blue-600 hover:underline">수정</a>
<form action="<?= base_url('admin/code-details/delete/' . (int) $row->cd_idx) ?>" method="POST" class="ml-1 inline" onsubmit="return confirm('이 세부코드를 삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline">삭제</button>
</form>
<?php else: ?>
<span class="text-gray-400">—</span>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="<?= (string) $detailColCount ?>" class="text-center text-gray-400 py-4">등록된 세부코드가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section> </section>
</div> </div>

View File

@@ -133,10 +133,13 @@ $userNav = session_user_nav_display();
<?php if (! empty($navItem->children)): ?> <?php if (! empty($navItem->children)): ?>
<div class="absolute left-0 top-full z-[200] -mt-1 pt-2 hidden group-hover:block group-focus-within:block min-w-[12rem]"> <div class="absolute left-0 top-full z-[200] -mt-1 pt-2 hidden group-hover:block group-focus-within:block min-w-[12rem]">
<div class="bg-white border border-gray-200 rounded shadow-lg py-1"> <div class="bg-white border border-gray-200 rounded shadow-lg py-1">
<?php
$activeChild = site_nav_active_child_for_parent($navItem, $currentPath, $dashboardPathAliases);
?>
<?php foreach ($navItem->children as $child): ?> <?php foreach ($navItem->children as $child): ?>
<?php <?php
$childLink = site_nav_resolved_link_path($child->mm_link ?? null, $child->mm_name ?? null); $childLink = site_nav_resolved_link_path($child->mm_link ?? null, $child->mm_name ?? null);
$childCurrent = menu_link_matches_request($child->mm_link ?? null, $currentPath, $dashboardPathAliases); $childCurrent = $activeChild !== null && $child === $activeChild;
?> ?>
<?php if ($childLink !== ''): ?> <?php if ($childLink !== ''): ?>
<a href="<?= base_url($childLink) ?>" <a href="<?= base_url($childLink) ?>"

View File

@@ -71,14 +71,12 @@ body { overflow: hidden; }
<?php foreach ($siteNavTree as $navItem): ?> <?php foreach ($siteNavTree as $navItem): ?>
<?php <?php
$navLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath); $navLink = menu_link_preferred_href_path($navItem->mm_link ?? null, $currentPath);
$activeChild = ! empty($navItem->children)
? menu_active_child_for_parent($navItem, $currentPath, $dashboardPathAliases)
: null;
$isActive = site_nav_link_matches_current($navItem->mm_link ?? null, $currentPath, $dashboardPathAliases); $isActive = site_nav_link_matches_current($navItem->mm_link ?? null, $currentPath, $dashboardPathAliases);
if (! $isActive && ! empty($navItem->children)) { if (! $isActive && $activeChild !== null) {
foreach ($navItem->children as $ch) { $isActive = true;
if (site_nav_link_matches_current($ch->mm_link ?? null, $currentPath, $dashboardPathAliases)) {
$isActive = true;
break;
}
}
} }
?> ?>
<div class="relative group"> <div class="relative group">
@@ -93,7 +91,8 @@ body { overflow: hidden; }
<?php foreach ($navItem->children as $child): ?> <?php foreach ($navItem->children as $child): ?>
<?php <?php
$childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath); $childLink = menu_link_preferred_href_path($child->mm_link ?? null, $currentPath);
$childCurrent = menu_link_matches_request($child->mm_link ?? null, $currentPath, $dashboardPathAliases); $childCurrent = $activeChild !== null
&& (int) ($child->mm_idx ?? 0) === (int) ($activeChild->mm_idx ?? -1);
?> ?>
<?php if ($childLink !== ''): ?> <?php if ($childLink !== ''): ?>
<a href="<?= base_url($childLink) ?>" <a href="<?= base_url($childLink) ?>"

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
/** @var string $buttonId 주소 검색 버튼 id */
$buttonId = $buttonId ?? 'btn-kakao-postcode';
/** @var string $zipName 우편번호 input name */
$zipName = $zipName ?? 'ds_zip';
/** @var string $roadName 도로명 input name */
$roadName = $roadName ?? 'ds_addr';
/** @var string $jibunName 지번 input name */
$jibunName = $jibunName ?? 'ds_addr_jibun';
/** @var string $sidoFieldName 카카오 시·도 → hidden name (비우면 미설정) */
$sidoFieldName = $sidoFieldName ?? '';
/** @var string $sigunguFieldName 카카오 시·군·구 → hidden name */
$sigunguFieldName = $sigunguFieldName ?? '';
/** @var string $detailFieldName 상세주소 input name (건물명 등, 비우면 미사용) */
$detailFieldName = $detailFieldName ?? '';
/**
* @var array{lg_sido?: string, lg_gugun?: string}|null $tenantScope 지자체 관할 검사(비우면 미검사)
*/
$tenantScope = $tenantScope ?? null;
/** @var bool $roadBaseOnly true면 도로명에 건물명 괄호 미부착(상세로 이전) */
$roadBaseOnly = ! empty($roadBaseOnly);
?>
<script src="https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
<script>
(function () {
var btnId = <?= json_encode($buttonId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var zipName = <?= json_encode($zipName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var roadName = <?= json_encode($roadName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var jibunName = <?= json_encode($jibunName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var sidoFieldName = <?= json_encode($sidoFieldName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var sigunguFieldName = <?= json_encode($sigunguFieldName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var detailFieldName = <?= json_encode($detailFieldName, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var roadBaseOnly = <?= $roadBaseOnly ? 'true' : 'false' ?>;
var tenantScope = <?= json_encode($tenantScope ?? (object) [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function compactStr(s) {
return String(s || '').replace(/\s+/g, '');
}
function tokenMatches(needle, primary, blob) {
var n = compactStr(needle);
if (!n) return true;
var b = compactStr(blob);
if (b.indexOf(n) !== -1) return true;
var p = compactStr(primary);
if (p.indexOf(n) !== -1) return true;
if (n.indexOf(p) !== -1 && p) return true;
return false;
}
function addressAllowedByTenant(data) {
var lgSido = tenantScope && tenantScope.lg_sido ? String(tenantScope.lg_sido) : '';
var lgGugun = tenantScope && tenantScope.lg_gugun ? String(tenantScope.lg_gugun) : '';
if (!lgSido && !lgGugun) return true;
var sido = data.sido || '';
var sigungu = data.sigungu || '';
var road = data.roadAddress || '';
var jibun = data.jibunAddress || '';
var zip = data.zonecode || '';
var blob = sido + ' ' + sigungu + ' ' + road + ' ' + jibun + ' ' + zip;
if (lgSido && !tokenMatches(lgSido, sido, blob)) return false;
if (lgGugun && !tokenMatches(lgGugun, sigungu, blob)) return false;
return true;
}
function bind() {
var btn = document.getElementById(btnId);
if (!btn) return;
var form = btn.closest('form');
if (!form) return;
function field(n) {
return form.querySelector('[name="' + n + '"]');
}
btn.addEventListener('click', function () {
if (typeof daum === 'undefined' || !daum.Postcode) {
window.alert('주소 검색 스크립트를 불러오지 못했습니다. 네트워크를 확인해 주세요.');
return;
}
new daum.Postcode({
oncomplete: function (data) {
if (!addressAllowedByTenant(data)) {
window.alert('작업 중인 지자체 관할이 아닌 주소입니다. 해당 시·구 주소를 검색해 주세요.');
return;
}
var zipEl = field(zipName);
var roadEl = field(roadName);
var jibunEl = field(jibunName);
if (zipEl) zipEl.value = data.zonecode || '';
var roadAddr = data.roadAddress || '';
if (!roadBaseOnly && data.buildingName !== '') {
roadAddr += (roadAddr !== '' ? ' (' + data.buildingName + ')' : data.buildingName);
}
if (roadEl) roadEl.value = roadAddr;
if (jibunEl) jibunEl.value = data.jibunAddress || '';
if (sidoFieldName) {
var sidoEl = field(sidoFieldName);
if (sidoEl) sidoEl.value = data.sido || '';
}
if (sigunguFieldName) {
var sigEl = field(sigunguFieldName);
if (sigEl) sigEl.value = data.sigungu || '';
}
if (detailFieldName && roadBaseOnly) {
var detEl = field(detailFieldName);
if (detEl) detEl.value = data.buildingName || '';
}
}
}).open();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bind);
} else {
bind();
}
})();
</script>

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
/** @var string $buttonId 버튼 id (폼마다 고유) */
$buttonId = $buttonId ?? 'btn-kakao-map-open';
/** @var string $label 버튼 텍스트 */
$label = $label ?? '지도';
?>
<button type="button" id="<?= esc($buttonId, 'attr') ?>" class="no-print border border-btn-print-border text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50 transition shrink-0" title="카카오맵에서 이 주소 검색"><?= esc($label) ?></button>
<script>
(function () {
var bid = <?= json_encode($buttonId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function bind() {
var btn = document.getElementById(bid);
if (!btn) return;
btn.addEventListener('click', function () {
var form = btn.closest('form');
if (!form) return;
function val(name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? String(el.value || '').trim() : '';
}
var road = val('ds_addr');
var jibun = val('ds_addr_jibun');
var detail = val('ds_addr_detail');
var q = road || jibun;
if (detail) {
q = q ? (q + ' ' + detail) : detail;
}
if (!q) {
window.alert('주소 검색으로 도로명·지번을 먼저 입력한 뒤 지도를 열 수 있습니다.');
return;
}
if (typeof window.openDesignatedShopKakaoMap === 'function') {
window.openDesignatedShopKakaoMap(q);
return;
}
window.open('https://map.kakao.com/link/search/' + encodeURIComponent(q), '_blank', 'noopener,noreferrer');
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bind);
} else {
bind();
}
})();
</script>

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
$key = trim((string) ($kakaoJavascriptKey ?? ''));
?>
<div id="kakao-map-modal" class="hidden fixed inset-0 z-[300] flex items-center justify-center p-4" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="kakao-map-modal-title">
<div class="absolute inset-0 bg-black/50" id="kakao-map-modal-backdrop"></div>
<div class="relative z-[301] w-full max-w-2xl max-h-[90vh] flex flex-col rounded border border-gray-300 bg-white shadow-lg overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-200 bg-gray-50 shrink-0">
<span id="kakao-map-modal-title" class="text-sm font-bold text-gray-800">위치</span>
<button type="button" id="kakao-map-modal-close" class="text-gray-600 hover:text-gray-900 text-xl leading-none px-1" aria-label="닫기">&times;</button>
</div>
<div id="kakao-map-modal-container" class="w-full bg-gray-100" style="min-height: 380px; height: 50vh;"></div>
</div>
</div>
<script>
(function () {
var APP_KEY = <?= json_encode($key, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var modal = document.getElementById('kakao-map-modal');
var backdrop = document.getElementById('kakao-map-modal-backdrop');
var btnClose = document.getElementById('kakao-map-modal-close');
var mapContainer = document.getElementById('kakao-map-modal-container');
var mapInstance = null;
var markerInstance = null;
var scriptLoading = false;
var pendingAfterLoad = [];
function hideModal() {
if (!modal) return;
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
function showModal() {
if (!modal) return;
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
}
function runPending() {
var q = pendingAfterLoad;
pendingAfterLoad = [];
q.forEach(function (fn) {
try {
fn();
} catch (e) {}
});
}
function ensureScript(cb) {
if (!APP_KEY) {
window.alert('카카오맵 JavaScript 키가 설정되지 않았습니다. .env에 kakao.javascriptKey를 설정해 주세요. (Kakao Developers → 앱 키 → JavaScript 키)');
return;
}
if (typeof kakao !== 'undefined' && kakao.maps) {
cb();
return;
}
pendingAfterLoad.push(cb);
if (scriptLoading) {
return;
}
scriptLoading = true;
var s = document.createElement('script');
s.charset = 'UTF-8';
s.async = true;
// 동적 삽입 시 autoload=false 후 kakao.maps.load() 필수 (카카오 웹 가이드)
s.src = 'https://dapi.kakao.com/v2/maps/sdk.js?appkey=' + encodeURIComponent(APP_KEY) + '&libraries=services&autoload=false';
s.onload = function () {
scriptLoading = false;
if (typeof kakao === 'undefined' || !kakao.maps || typeof kakao.maps.load !== 'function') {
pendingAfterLoad = [];
window.alert(
'카카오맵 API를 불러올 수 없습니다.\n\n' +
'Kakao Developers → 내 애플리케이션 → 해당 앱 → 「제품 설정」에서 「Kakao Map」(지도) / 로컬 API를 사용 설정으로 켜 주세요.\n' +
'(비활성 시 서버에서 OPEN_MAP_AND_LOCAL 오류가 납니다.)\n\n' +
'또한 플랫폼(Web)에 이 사이트 주소(예: http://localhost:8080)가 등록되어 있어야 합니다.'
);
return;
}
kakao.maps.load(function () {
runPending();
});
};
s.onerror = function () {
scriptLoading = false;
pendingAfterLoad = [];
window.alert(
'카카오맵 스크립트를 불러오지 못했습니다.\n\n' +
'• 네트워크·차단(광고 차단) 확인\n' +
'• Kakao Developers → 제품 설정에서 「Kakao Map」활성화\n' +
'• 플랫폼(Web)에 접속 중인 URL 등록'
);
};
document.head.appendChild(s);
}
if (btnClose) {
btnClose.addEventListener('click', hideModal);
}
if (backdrop) {
backdrop.addEventListener('click', hideModal);
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' || !modal || modal.classList.contains('hidden')) {
return;
}
hideModal();
});
window.openDesignatedShopKakaoMap = function (addressQuery) {
var q = String(addressQuery || '').trim();
if (!q) {
window.alert('주소가 없습니다.');
return;
}
ensureScript(function () {
if (typeof kakao === 'undefined' || !kakao.maps || !kakao.maps.services) {
window.alert('카카오맵을 초기화할 수 없습니다.');
return;
}
var geocoder = new kakao.maps.services.Geocoder();
geocoder.addressSearch(q, function (result, status) {
if (status !== kakao.maps.services.Status.OK || !result || !result[0]) {
window.alert('주소를 지도에서 찾을 수 없습니다.');
return;
}
var coords = new kakao.maps.LatLng(result[0].y, result[0].x);
showModal();
if (!mapInstance) {
mapInstance = new kakao.maps.Map(mapContainer, {
center: coords,
level: 3
});
} else {
mapInstance.setCenter(coords);
mapInstance.setLevel(3);
}
if (markerInstance) {
markerInstance.setMap(null);
}
markerInstance = new kakao.maps.Marker({ position: coords, map: mapInstance });
setTimeout(function () {
if (mapInstance) {
mapInstance.relayout();
mapInstance.setCenter(coords);
}
}, 100);
});
});
};
})();
</script>

View File

@@ -41,7 +41,31 @@ test.describe('관리자 패널 — 지자체관리자', () => {
test('지정판매소 목록 접근', async ({ page }) => { test('지정판매소 목록 접근', async ({ page }) => {
await page.goto('/bag/designated-shops'); await page.goto('/bag/designated-shops');
await expect(page).toHaveURL(/\/admin\/designated-shops/); await expect(page).toHaveURL(/\/bag\/designated-shops$/);
await expect(page.getByText('지정판매소 관리').first()).toBeVisible();
await expect(page.getByRole('link', { name: '지정판매소 등록' })).toBeVisible();
});
test('지정판매소 조회 전용(browse) 접근', async ({ page }) => {
await page.goto('/bag/designated-shops/browse');
await expect(page).toHaveURL(/\/bag\/designated-shops\/browse/);
await expect(page.getByText('지정판매소 조회').first()).toBeVisible();
await expect(page.getByRole('link', { name: '지정판매소 등록' })).toHaveCount(0);
});
test('지정판매소 소메뉴는 현재 경로 1개만 활성화', async ({ page }) => {
const activeSubmenu = page.locator('nav a.text-blue-700.font-semibold.bg-blue-50');
await page.goto('/bag/designated-shops');
await expect(page).toHaveURL(/\/bag\/designated-shops$/);
await expect(activeSubmenu.filter({ hasText: '지정판매소 관리' })).toHaveCount(1);
await expect(activeSubmenu.filter({ hasText: '지정판매소 바코드출력' })).toHaveCount(0);
await expect(activeSubmenu.filter({ hasText: '지정판매소 조회' })).toHaveCount(0);
await page.goto('/bag/designated-shops/browse');
await expect(page).toHaveURL(/\/bag\/designated-shops\/browse/);
await expect(activeSubmenu.filter({ hasText: '지정판매소 조회' })).toHaveCount(1);
await expect(activeSubmenu.filter({ hasText: '지정판매소 관리' })).toHaveCount(0);
}); });
test('지자체 관리는 Super Admin 전용 — 지자체관리자 접근 시 리다이렉트', async ({ page }) => { test('지자체 관리는 Super Admin 전용 — 지자체관리자 접근 시 리다이렉트', async ({ page }) => {

View File

@@ -60,7 +60,12 @@ const BAG_PATHS = [
'/bag/free-recipients', '/bag/free-recipients',
'/bag/free-recipients/create', '/bag/free-recipients/create',
'/bag/designated-shops', '/bag/designated-shops',
'/bag/designated-shops/browse',
'/bag/designated-shops/status', '/bag/designated-shops/status',
'/bag/designated-shops/status/export?year=2026&ds_gugun_code=&granularity=gugun',
'/bag/designated-shops/district-new-cancel',
'/bag/designated-shops/district-new-cancel/export?year=2026',
'/bag/designated-shops/barcode',
'/bag/bag-prices', '/bag/bag-prices',
'/bag/bag-prices/create', '/bag/bag-prices/create',
'/bag/packaging-units/manage', '/bag/packaging-units/manage',

View File

@@ -85,13 +85,15 @@ test.describe('P2-15: 지정판매소 다조건 조회', () => {
await loginAsLocal(page); await loginAsLocal(page);
await page.goto('/bag/designated-shops?ds_name=CU'); await page.goto('/bag/designated-shops?ds_name=CU');
await expect(page).toHaveURL(/ds_name=CU/); await expect(page).toHaveURL(/ds_name=CU/);
await expect(page.locator('table.data-table')).toBeVisible(); await expect(page.locator('input[name="ds_name"]')).toHaveValue('CU');
await expect(page.locator('#ds-list-body')).toBeAttached();
}); });
test('상태 필터', async ({ page }) => { test('상태 필터', async ({ page }) => {
await loginAsLocal(page); await loginAsLocal(page);
await page.goto('/bag/designated-shops?ds_state=1'); await page.goto('/bag/designated-shops?ds_state=1');
await expect(page.locator('table.data-table')).toBeVisible(); await expect(page.locator('select[name="ds_state"]')).toHaveValue('1');
await expect(page.locator('#ds-list-body')).toBeAttached();
}); });
test('검색 폼에서 이름 입력 후 조회', async ({ page }) => { test('검색 폼에서 이름 입력 후 조회', async ({ page }) => {
@@ -126,6 +128,17 @@ test.describe('P2-18: 지정판매소 현황', () => {
await page.goto('/bag/designated-shops/status'); await page.goto('/bag/designated-shops/status');
await expect(page).toHaveURL(/\/status/); await expect(page).toHaveURL(/\/status/);
await expect(page.locator('table.data-table').first()).toBeVisible(); await expect(page.locator('table.data-table').first()).toBeVisible();
await expect(page.getByRole('columnheader', { name: '종전(전년도말)' })).toBeVisible();
await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible();
});
test('GBMS형 신규/취소 현황(구·군 고정)', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/bag/designated-shops/district-new-cancel');
await expect(page).toHaveURL(/district-new-cancel/);
await expect(page.locator('.gbms-dnc-table')).toBeVisible();
await expect(page.getByRole('columnheader', { name: '군·구' })).toBeVisible();
await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible();
}); });
}); });
@@ -279,9 +292,9 @@ test.describe('사이트 메뉴 CRUD 동작', () => {
test.describe('엑셀 내보내기 다운로드', () => { test.describe('엑셀 내보내기 다운로드', () => {
test('지정판매소 엑셀', async ({ page }) => { test('지정판매소 엑셀', async ({ page }) => {
await loginAsLocal(page); await loginAsLocal(page);
await page.goto('/bag/designated-shops'); await page.goto('/bag/designated-shops/browse');
const downloadPromise = page.waitForEvent('download', { timeout: 10000 }); const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
await page.locator('a[href*="export"]').first().click(); await page.locator('a[href*="designated-shops/export"]').first().click();
const download = await downloadPromise; const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('.csv'); expect(download.suggestedFilename()).toContain('.csv');
}); });

View File

@@ -11,7 +11,7 @@ module.exports = defineConfig({
timeout: 60000, timeout: 60000,
use: { use: {
baseURL: 'http://localhost:8045', baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8045',
trace: 'on-first-retry', trace: 'on-first-retry',
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
locale: 'ko-KR', locale: 'ko-KR',

View File

@@ -29,16 +29,25 @@ CREATE TABLE IF NOT EXISTS `designated_shop` (
`ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '상호명', `ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '상호명',
`ds_biz_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '사업자번호', `ds_biz_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '사업자번호',
`ds_rep_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '대표자명', `ds_rep_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '대표자명',
`ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '고정 가상계좌 번호', `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업태',
`ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업종',
`ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '가상계좌(표시용 번호, 계좌번호와 동기화 가능)',
`ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '가상계좌(은행)',
`ds_va_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '계좌번호',
`ds_zip` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '우편번호', `ds_zip` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '우편번호',
`ds_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '도로명주소', `ds_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '도로명주소',
`ds_addr_jibun` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '지번주소', `ds_addr_jibun` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '지번주소',
`ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '상세주소(동·호 등)',
`ds_tel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '일반전화', `ds_tel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '일반전화',
`ds_rep_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '개인전화', `ds_rep_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '개인전화',
`ds_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일', `ds_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
`ds_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구코드', `ds_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구코드',
`ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '구역',
`ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '종사업장번호',
`ds_designated_at` DATE NULL DEFAULT NULL COMMENT '지정일자', `ds_designated_at` DATE NULL DEFAULT NULL COMMENT '지정일자',
`ds_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=폐업, 3=직권해지', `ds_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=폐업, 3=직권해지',
`ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT '변경일자',
`ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '변경사유',
`ds_regdate` DATETIME NOT NULL COMMENT '등록일시', `ds_regdate` DATETIME NOT NULL COMMENT '등록일시',
PRIMARY KEY (`ds_idx`), PRIMARY KEY (`ds_idx`),
KEY `idx_ds_lg_idx` (`ds_lg_idx`), KEY `idx_ds_lg_idx` (`ds_lg_idx`),

View File

@@ -0,0 +1,5 @@
-- 지정판매소 상세주소(건물명·동·호 등) — 주소 검색으로 채운 도로명/지번과 별도 입력
SET NAMES utf8mb4;
ALTER TABLE `designated_shop`
ADD COLUMN `ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '상세주소(동·호 등)' AFTER `ds_addr_jibun`;

View File

@@ -0,0 +1,108 @@
-- 지정판매소: 앱(DesignatedShopModel / Admin\DesignatedShop)이 기대하는 컬럼을
-- 없을 때만 추가합니다. 기존 DB를 login_tables.sql 최신 정의와 맞출 때 사용.
-- 실행 예: mysql -h 127.0.0.1 -u USER -p DBNAME < writable/database/designated_shop_ensure_app_columns.sql
--
-- kr_address 등 외부 테이블 불필요. INFORMATION_SCHEMA 로 존재 여부만 확인합니다.
SET NAMES utf8mb4;
SET @db = DATABASE();
-- ds_biz_type
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_biz_type') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '''' COMMENT ''업태'' AFTER `ds_rep_name`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_biz_kind
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_biz_kind') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '''' COMMENT ''업종'' AFTER `ds_biz_type`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_va_bank (ds_va_number 뒤 — 없으면 ds_biz_kind 뒤에 붙임)
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_bank') > 0,
'SELECT 1',
IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_number') > 0,
'ALTER TABLE `designated_shop` ADD COLUMN `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '''' COMMENT ''가상계좌(은행)'' AFTER `ds_va_number`',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '''' COMMENT ''가상계좌(은행)'' AFTER `ds_biz_kind`'
)
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_va_account
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_account') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_va_account` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''계좌번호'' AFTER `ds_va_bank`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_addr_detail
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_addr_detail') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '''' COMMENT ''상세주소(동·호 등)'' AFTER `ds_addr_jibun`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_zone_code
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_zone_code') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '''' COMMENT ''구역'' AFTER `ds_gugun_code`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_branch_no
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_branch_no') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '''' COMMENT ''종사업장번호'' AFTER `ds_zone_code`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_state_changed_at
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_state_changed_at') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT ''변경일자'' AFTER `ds_state`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_change_reason
SET @s = (SELECT IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_change_reason') > 0,
'SELECT 1',
'ALTER TABLE `designated_shop` ADD COLUMN `ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''변경사유'' AFTER `ds_state_changed_at`'
));
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ds_va_number 뒤에 va_bank를 넣었을 수 있음 — 구 스키마에 ds_designated_at 등만 있는 경우
UPDATE `designated_shop`
SET `ds_va_account` = `ds_va_number`
WHERE EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_account'
)
AND EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 'designated_shop' AND COLUMN_NAME = 'ds_va_number'
)
AND (`ds_va_account` = '' OR `ds_va_account` IS NULL)
AND `ds_va_number` IS NOT NULL
AND `ds_va_number` != '';

View File

@@ -0,0 +1,25 @@
-- 지정판매소 확장 컬럼 (업태·업종·구역·종사업장·가상계좌 은행/계좌·변경일자·변경사유)
-- 기존 DB: mysql ... < writable/database/designated_shop_extended_columns.sql
-- 컬럼이 이미 있으면 수동으로 스킵하거나 에러 무시 후 진행
--
-- 권장: 컬럼 유무를 자동 판별하려면 대신
-- writable/database/designated_shop_ensure_app_columns.sql
-- 를 실행하세요(여러 번 실행해도 안전).
SET NAMES utf8mb4;
ALTER TABLE `designated_shop`
ADD COLUMN `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업태' AFTER `ds_rep_name`,
ADD COLUMN `ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업종' AFTER `ds_biz_type`,
ADD COLUMN `ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '구역' AFTER `ds_gugun_code`,
ADD COLUMN `ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '종사업장번호' AFTER `ds_zone_code`,
ADD COLUMN `ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '가상계좌(은행)' AFTER `ds_va_number`,
ADD COLUMN `ds_va_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '계좌번호' AFTER `ds_va_bank`,
ADD COLUMN `ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT '변경일자' AFTER `ds_state`,
ADD COLUMN `ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '변경사유' AFTER `ds_state_changed_at`;
UPDATE `designated_shop`
SET `ds_va_account` = `ds_va_number`
WHERE (`ds_va_account` = '' OR `ds_va_account` IS NULL)
AND `ds_va_number` IS NOT NULL
AND `ds_va_number` != '';

View File

@@ -106,16 +106,25 @@ CREATE TABLE IF NOT EXISTS `designated_shop` (
`ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '상호명', `ds_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '상호명',
`ds_biz_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '사업자번호', `ds_biz_no` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '사업자번호',
`ds_rep_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '대표자명', `ds_rep_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '대표자명',
`ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '고정 가상계좌 번호', `ds_biz_type` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업태',
`ds_biz_kind` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '업종',
`ds_va_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '가상계좌(표시용 번호, 계좌번호와 동기화 가능)',
`ds_va_bank` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '가상계좌(은행)',
`ds_va_account` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '계좌번호',
`ds_zip` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '우편번호', `ds_zip` VARCHAR(10) NOT NULL DEFAULT '' COMMENT '우편번호',
`ds_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '도로명주소', `ds_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '도로명주소',
`ds_addr_jibun` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '지번주소', `ds_addr_jibun` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '지번주소',
`ds_addr_detail` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '상세주소(동·호 등)',
`ds_tel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '일반전화', `ds_tel` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '일반전화',
`ds_rep_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '개인전화', `ds_rep_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '개인전화',
`ds_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일', `ds_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '이메일',
`ds_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구코드', `ds_gugun_code` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '구코드',
`ds_zone_code` VARCHAR(80) NOT NULL DEFAULT '' COMMENT '구역',
`ds_branch_no` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '종사업장번호',
`ds_designated_at` DATE NULL DEFAULT NULL COMMENT '지정일자', `ds_designated_at` DATE NULL DEFAULT NULL COMMENT '지정일자',
`ds_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=폐업, 3=직권해지', `ds_state` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=정상, 2=폐업, 3=직권해지',
`ds_state_changed_at` DATE NULL DEFAULT NULL COMMENT '변경일자',
`ds_change_reason` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '변경사유',
`ds_regdate` DATETIME NOT NULL COMMENT '등록일시', `ds_regdate` DATETIME NOT NULL COMMENT '등록일시',
PRIMARY KEY (`ds_idx`), PRIMARY KEY (`ds_idx`),
KEY `idx_ds_lg_idx` (`ds_lg_idx`), KEY `idx_ds_lg_idx` (`ds_lg_idx`),

View File

@@ -0,0 +1,7 @@
-- 기존 DB: '지정판매소 바코드 출력' 메뉴를 전용 URL로 변경
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `menu` m
INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site'
SET m.mm_link = 'bag/designated-shops/barcode'
WHERE m.mm_name = '지정판매소 바코드 출력';

View File

@@ -0,0 +1,10 @@
-- 기존 DB: 지정 판매소 신규/취소 현황 메뉴를 GBMS형 전용 URL로 변경
-- UTF-8: mysql --default-character-set=utf8mb4 ...
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `menu` m
INNER JOIN `menu_type` t ON t.mt_idx = m.mt_idx AND t.mt_code = 'site'
SET m.mm_link = 'bag/designated-shops/district-new-cancel'
WHERE m.mm_name IN ('지정 판매소 신규/취소 현황', '지정 판매소 현황')
AND m.mm_link IN ('bag/designated-shops/status', '');

View File

@@ -19,12 +19,12 @@ SET m.mm_link = CASE m.mm_name
WHEN '업체 관리' THEN 'bag/companies' WHEN '업체 관리' THEN 'bag/companies'
WHEN '무료용 대상자 관리' THEN 'bag/free-recipients' WHEN '무료용 대상자 관리' THEN 'bag/free-recipients'
WHEN '지정 판매소 관리' THEN 'bag/designated-shops' WHEN '지정 판매소 관리' THEN 'bag/designated-shops'
WHEN '지정 판매소 조회' THEN 'bag/designated-shops' WHEN '지정 판매소 조회' THEN 'bag/designated-shops/browse'
WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/status' WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops' WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops/barcode'
WHEN 'PASSWORD 변경' THEN 'bag/password-change' WHEN 'PASSWORD 변경' THEN 'bag/password-change'
WHEN '환경 설정' THEN 'dashboard' WHEN '환경 설정' THEN 'dashboard'
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/status' WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '발주 등록' THEN 'bag/order/create' WHEN '발주 등록' THEN 'bag/order/create'
WHEN '발주 변경' THEN 'bag/bag-orders' WHEN '발주 변경' THEN 'bag/bag-orders'
WHEN '발주 현황' THEN 'bag/bag-orders' WHEN '발주 현황' THEN 'bag/bag-orders'

View File

@@ -33,12 +33,12 @@ SELECT @mt_site, 1, t.mm_name,
WHEN '업체 관리' THEN 'bag/companies' WHEN '업체 관리' THEN 'bag/companies'
WHEN '무료용 대상자 관리' THEN 'bag/free-recipients' WHEN '무료용 대상자 관리' THEN 'bag/free-recipients'
WHEN '지정 판매소 관리' THEN 'bag/designated-shops' WHEN '지정 판매소 관리' THEN 'bag/designated-shops'
WHEN '지정 판매소 조회' THEN 'bag/designated-shops' WHEN '지정 판매소 조회' THEN 'bag/designated-shops/browse'
WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/status' WHEN '지정 판매소 신규/취소 현황' THEN 'bag/designated-shops/district-new-cancel'
WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops' WHEN '지정판매소 바코드 출력' THEN 'bag/designated-shops/barcode'
WHEN 'PASSWORD 변경' THEN 'bag/password-change' WHEN 'PASSWORD 변경' THEN 'bag/password-change'
WHEN '환경 설정' THEN 'dashboard' WHEN '환경 설정' THEN 'dashboard'
WHEN '지정 판매소 현황' THEN 'bag/designated-shops/status' WHEN '지정 판매소 현황' THEN 'bag/designated-shops/district-new-cancel'
ELSE '' ELSE ''
END, END,
@parent_basic, 1, t.mm_num, 0, '', 'Y' @parent_basic, 1, t.mm_num, 0, '', 'Y'

View File

@@ -0,0 +1,64 @@
-- 지정판매소 신규/취소 현황(연도별) 테스트 데이터
-- 목적: district-new-cancel/status 화면에서 2022~2025 연도 전환 테스트
-- 기본값: ds_lg_idx=1, ds_gugun_code=110209(북구). 환경에 맞게 값 변경 후 실행하세요.
-- 실행 예:
-- mysql -h 127.0.0.1 -u jongryangje -p jongryangje_dev < writable/database/seed_designated_shops_status_multi_years.sql
SET NAMES utf8mb4;
-- 재실행 가능하도록 테스트 prefix 데이터만 정리
DELETE FROM `designated_shop` WHERE `ds_shop_no` LIKE 'ZZSTAT-%';
INSERT INTO `designated_shop` (
`ds_lg_idx`, `ds_mb_idx`, `ds_shop_no`, `ds_name`, `ds_biz_no`, `ds_rep_name`,
`ds_va_number`, `ds_zip`, `ds_addr`, `ds_addr_jibun`, `ds_addr_detail`,
`ds_tel`, `ds_rep_phone`, `ds_email`,
`ds_gugun_code`, `ds_zone_code`,
`ds_designated_at`, `ds_state`, `ds_state_changed_at`, `ds_change_reason`, `ds_regdate`
) VALUES
-- 2022 지정 2건
(1, NULL, 'ZZSTAT-2201', '현황테스트 A', '901-22-00001', '홍길동', '', '41590', '대구광역시 북구 테스트로 2201', '대구 북구 테스트동 2201', '101호', '053-220-0001', '01022000001', 'zzstat2201@test.local', '110209', '북구-A', '2022-03-01', 1, NULL, '', '2022-03-01 09:00:00'),
(1, NULL, 'ZZSTAT-2202', '현황테스트 J', '901-22-00002', '김현황', '', '41590', '대구광역시 북구 테스트로 2202', '대구 북구 테스트동 2202', '102호', '053-220-0002', '01022000002', 'zzstat2202@test.local', '110209', '북구-A', '2022-08-08', 2, '2023-12-20', '테스트 취소(2023)', '2022-08-08 09:00:00'),
-- 2023 지정 2건 (이 중 1건은 2024에 취소)
(1, NULL, 'ZZSTAT-2301', '현황테스트 B', '901-23-00001', '이현황', '', '41590', '대구광역시 북구 테스트로 2301', '대구 북구 테스트동 2301', '201호', '053-230-0001', '01023000001', 'zzstat2301@test.local', '110209', '북구-B', '2023-04-10', 1, NULL, '', '2023-04-10 09:00:00'),
(1, NULL, 'ZZSTAT-2302', '현황테스트 C', '901-23-00002', '박현황', '', '41590', '대구광역시 북구 테스트로 2302', '대구 북구 테스트동 2302', '202호', '053-230-0002', '01023000002', 'zzstat2302@test.local', '110209', '북구-B', '2023-07-01', 2, '2024-05-02', '테스트 취소(2024)', '2023-07-01 09:00:00'),
-- 2024 지정 4건 (이 중 1건은 2024에 취소)
(1, NULL, 'ZZSTAT-2401', '현황테스트 D', '901-24-00001', '최현황', '', '41590', '대구광역시 북구 테스트로 2401', '대구 북구 테스트동 2401', '301호', '053-240-0001', '01024000001', 'zzstat2401@test.local', '110209', '북구-C', '2024-01-15', 1, NULL, '', '2024-01-15 09:00:00'),
(1, NULL, 'ZZSTAT-2402', '현황테스트 E', '901-24-00002', '정현황', '', '41590', '대구광역시 북구 테스트로 2402', '대구 북구 테스트동 2402', '302호', '053-240-0002', '01024000002', 'zzstat2402@test.local', '110209', '북구-C', '2024-06-20', 1, NULL, '', '2024-06-20 09:00:00'),
(1, NULL, 'ZZSTAT-2403', '현황테스트 F', '901-24-00003', '강현황', '', '41590', '대구광역시 북구 테스트로 2403', '대구 북구 테스트동 2403', '303호', '053-240-0003', '01024000003', 'zzstat2403@test.local', '110209', '북구-D', '2024-09-01', 3, '2024-12-01', '테스트 해지(2024)', '2024-09-01 09:00:00'),
(1, NULL, 'ZZSTAT-2404', '현황테스트 G', '901-24-00004', '신현황', '', '41590', '대구광역시 북구 테스트로 2404', '대구 북구 테스트동 2404', '304호', '053-240-0004', '01024000004', 'zzstat2404@test.local', '110209', '북구-D', '2024-11-12', 1, NULL, '', '2024-11-12 09:00:00'),
-- 2025 지정 2건 (이 중 1건은 2025에 취소)
(1, NULL, 'ZZSTAT-2501', '현황테스트 H', '901-25-00001', '오현황', '', '41590', '대구광역시 북구 테스트로 2501', '대구 북구 테스트동 2501', '401호', '053-250-0001', '01025000001', 'zzstat2501@test.local', '110209', '북구-E', '2025-02-01', 1, NULL, '', '2025-02-01 09:00:00'),
(1, NULL, 'ZZSTAT-2502', '현황테스트 I', '901-25-00002', '유현황', '', '41590', '대구광역시 북구 테스트로 2502', '대구 북구 테스트동 2502', '402호', '053-250-0002', '01025000002', 'zzstat2502@test.local', '110209', '북구-E', '2025-03-20', 2, '2025-10-15', '테스트 취소(2025)', '2025-03-20 09:00:00');
-- 참고: 추가된 "지정" 건수(연도별)
-- 2022: 2건 / 2023: 2건 / 2024: 4건 / 2025: 2건
-- 검증 1) prefix 데이터의 연도별 지정/취소 건수
-- SELECT
-- YEAR(ds_designated_at) AS yr,
-- COUNT(*) AS designated_cnt,
-- SUM(CASE WHEN ds_state IN (2,3) AND ds_state_changed_at IS NOT NULL THEN YEAR(ds_state_changed_at)=YEAR(ds_designated_at) ELSE 0 END) AS same_year_cancel_cnt
-- FROM designated_shop
-- WHERE ds_shop_no LIKE 'ZZSTAT-%'
-- GROUP BY YEAR(ds_designated_at)
-- ORDER BY yr;
-- 검증 2) 2024 현황 기대값(prefix 데이터만)
-- 종전(2023말)=3, 지정(2024)=4, 취소(2024)=2, 현행(2024말)=5
-- SELECT
-- SUM(CASE
-- WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= '2023-12-31'
-- AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > '2023-12-31'))
-- THEN 1 ELSE 0 END) AS prev_end_2023,
-- SUM(CASE WHEN YEAR(COALESCE(ds_designated_at, DATE(ds_regdate))) = 2024 THEN 1 ELSE 0 END) AS designated_2024,
-- SUM(CASE WHEN ds_state IN (2,3) AND YEAR(COALESCE(ds_state_changed_at, DATE(ds_regdate))) = 2024 THEN 1 ELSE 0 END) AS cancelled_2024,
-- SUM(CASE
-- WHEN COALESCE(ds_designated_at, DATE(ds_regdate)) <= '2024-12-31'
-- AND (ds_state = 1 OR (ds_state IN (2,3) AND COALESCE(ds_state_changed_at, DATE(ds_regdate)) > '2024-12-31'))
-- THEN 1 ELSE 0 END) AS curr_end_2024
-- FROM designated_shop
-- WHERE ds_shop_no LIKE 'ZZSTAT-%';

View File

@@ -0,0 +1,43 @@
-- 테스트 지정판매소 30건 (동일 스크립트 재실행 시 기존 ZZTEST 행 삭제 후 삽입)
-- 기본: ds_lg_idx = 1 (북구청), ds_gugun_code = 110209 — 환경에 맞게 아래 DELETE/INSERT의 1, 110209만 조정하세요.
-- 실행: mysql -h 127.0.0.1 -u jongryangje -p jongryangje_dev < writable/database/seed_designated_shops_test_30.sql
SET NAMES utf8mb4;
DELETE FROM `designated_shop` WHERE `ds_shop_no` LIKE 'ZZTEST-%';
INSERT INTO `designated_shop` (
`ds_lg_idx`, `ds_mb_idx`, `ds_shop_no`, `ds_name`, `ds_biz_no`, `ds_rep_name`,
`ds_va_number`, `ds_zip`, `ds_addr`, `ds_addr_jibun`, `ds_tel`, `ds_rep_phone`, `ds_email`,
`ds_gugun_code`, `ds_designated_at`, `ds_state`, `ds_regdate`
) VALUES
(1, NULL, 'ZZTEST-00001', '테스트편의점 01', '784-12-00001', '김테01', '', '41590', '대구광역시 북구 테스트로 1', '대구 북구 테스트동 1', '053-000-0001', '01010000001', 'seed01@test.local', '110209', '2026-01-01', 1, NOW()),
(1, NULL, 'ZZTEST-00002', '테스트편의점 02', '784-12-00002', '김테02', '', '41590', '대구광역시 북구 테스트로 2', '대구 북구 테스트동 2', '053-000-0002', '01010000002', 'seed02@test.local', '110209', '2026-01-02', 1, NOW()),
(1, NULL, 'ZZTEST-00003', '테스트편의점 03', '784-12-00003', '김테03', '', '41590', '대구광역시 북구 테스트로 3', '대구 북구 테스트동 3', '053-000-0003', '01010000003', 'seed03@test.local', '110209', '2026-01-03', 1, NOW()),
(1, NULL, 'ZZTEST-00004', '테스트편의점 04', '784-12-00004', '김테04', '', '41590', '대구광역시 북구 테스트로 4', '대구 북구 테스트동 4', '053-000-0004', '01010000004', 'seed04@test.local', '110209', '2026-01-04', 1, NOW()),
(1, NULL, 'ZZTEST-00005', '테스트편의점 05', '784-12-00005', '김테05', '', '41590', '대구광역시 북구 테스트로 5', '대구 북구 테스트동 5', '053-000-0005', '01010000005', 'seed05@test.local', '110209', '2026-01-05', 1, NOW()),
(1, NULL, 'ZZTEST-00006', '테스트편의점 06', '784-12-00006', '김테06', '', '41590', '대구광역시 북구 테스트로 6', '대구 북구 테스트동 6', '053-000-0006', '01010000006', 'seed06@test.local', '110209', '2026-01-06', 1, NOW()),
(1, NULL, 'ZZTEST-00007', '테스트편의점 07', '784-12-00007', '김테07', '', '41590', '대구광역시 북구 테스트로 7', '대구 북구 테스트동 7', '053-000-0007', '01010000007', 'seed07@test.local', '110209', '2026-01-07', 1, NOW()),
(1, NULL, 'ZZTEST-00008', '테스트편의점 08', '784-12-00008', '김테08', '', '41590', '대구광역시 북구 테스트로 8', '대구 북구 테스트동 8', '053-000-0008', '01010000008', 'seed08@test.local', '110209', '2026-01-08', 1, NOW()),
(1, NULL, 'ZZTEST-00009', '테스트편의점 09', '784-12-00009', '김테09', '', '41590', '대구광역시 북구 테스트로 9', '대구 북구 테스트동 9', '053-000-0009', '01010000009', 'seed09@test.local', '110209', '2026-01-09', 1, NOW()),
(1, NULL, 'ZZTEST-00010', '테스트편의점 10', '784-12-00010', '김테10', '', '41590', '대구광역시 북구 테스트로 10', '대구 북구 테스트동 10', '053-000-0010', '01010000010', 'seed10@test.local', '110209', '2026-01-10', 1, NOW()),
(1, NULL, 'ZZTEST-00011', '테스트마트 11', '784-12-00011', '이테11', '', '41590', '대구광역시 북구 테스트로 11', '대구 북구 테스트동 11', '053-000-0011', '01010000011', 'seed11@test.local', '110209', '2026-01-11', 2, NOW()),
(1, NULL, 'ZZTEST-00012', '테스트마트 12', '784-12-00012', '이테12', '', '41590', '대구광역시 북구 테스트로 12', '대구 북구 테스트동 12', '053-000-0012', '01010000012', 'seed12@test.local', '110209', '2026-01-12', 1, NOW()),
(1, NULL, 'ZZTEST-00013', '테스트마트 13', '784-12-00013', '이테13', '', '41590', '대구광역시 북구 테스트로 13', '대구 북구 테스트동 13', '053-000-0013', '01010000013', 'seed13@test.local', '110209', '2026-01-13', 1, NOW()),
(1, NULL, 'ZZTEST-00014', '테스트마트 14', '784-12-00014', '이테14', '', '41590', '대구광역시 북구 테스트로 14', '대구 북구 테스트동 14', '053-000-0014', '01010000014', 'seed14@test.local', '110209', '2026-01-14', 1, NOW()),
(1, NULL, 'ZZTEST-00015', '테스트마트 15', '784-12-00015', '이테15', '', '41590', '대구광역시 북구 테스트로 15', '대구 북구 테스트동 15', '053-000-0015', '01010000015', 'seed15@test.local', '110209', '2026-01-15', 1, NOW()),
(1, NULL, 'ZZTEST-00016', '테스트슈퍼 16', '784-12-00016', '박테16', '', '41590', '대구광역시 북구 테스트로 16', '대구 북구 테스트동 16', '053-000-0016', '01010000016', 'seed16@test.local', '110209', '2026-01-16', 1, NOW()),
(1, NULL, 'ZZTEST-00017', '테스트슈퍼 17', '784-12-00017', '박테17', '', '41590', '대구광역시 북구 테스트로 17', '대구 북구 테스트동 17', '053-000-0017', '01010000017', 'seed17@test.local', '110209', '2026-01-17', 1, NOW()),
(1, NULL, 'ZZTEST-00018', '테스트슈퍼 18', '784-12-00018', '박테18', '', '41590', '대구광역시 북구 테스트로 18', '대구 북구 테스트동 18', '053-000-0018', '01010000018', 'seed18@test.local', '110209', '2026-01-18', 1, NOW()),
(1, NULL, 'ZZTEST-00019', '테스트슈퍼 19', '784-12-00019', '박테19', '', '41590', '대구광역시 북구 테스트로 19', '대구 북구 테스트동 19', '053-000-0019', '01010000019', 'seed19@test.local', '110209', '2026-01-19', 1, NOW()),
(1, NULL, 'ZZTEST-00020', '테스트슈퍼 20', '784-12-00020', '박테20', '', '41590', '대구광역시 북구 테스트로 20', '대구 북구 테스트동 20', '053-000-0020', '01010000020', 'seed20@test.local', '110209', '2026-01-20', 1, NOW()),
(1, NULL, 'ZZTEST-00021', '테스트상회 21', '784-12-00021', '최테21', '', '41590', '대구광역시 북구 테스트로 21', '대구 북구 테스트동 21', '053-000-0021', '01010000021', 'seed21@test.local', '110209', '2026-01-21', 1, NOW()),
(1, NULL, 'ZZTEST-00022', '테스트상회 22', '784-12-00022', '최테22', '', '41590', '대구광역시 북구 테스트로 22', '대구 북구 테스트동 22', '053-000-0022', '01010000022', 'seed22@test.local', '110209', '2026-01-22', 1, NOW()),
(1, NULL, 'ZZTEST-00023', '테스트상회 23', '784-12-00023', '최테23', '', '41590', '대구광역시 북구 테스트로 23', '대구 북구 테스트동 23', '053-000-0023', '01010000023', 'seed23@test.local', '110209', '2026-01-23', 1, NOW()),
(1, NULL, 'ZZTEST-00024', '테스트상회 24', '784-12-00024', '최테24', '', '41590', '대구광역시 북구 테스트로 24', '대구 북구 테스트동 24', '053-000-0024', '01010000024', 'seed24@test.local', '110209', '2026-01-24', 1, NOW()),
(1, NULL, 'ZZTEST-00025', '테스트상회 25', '784-12-00025', '최테25', '', '41590', '대구광역시 북구 테스트로 25', '대구 북구 테스트동 25', '053-000-0025', '01010000025', 'seed25@test.local', '110209', '2026-01-25', 1, NOW()),
(1, NULL, 'ZZTEST-00026', '테스트복합 26', '784-12-00026', '정테26', '', '41590', '대구광역시 북구 테스트로 26', '대구 북구 테스트동 26', '053-000-0026', '01010000026', 'seed26@test.local', '110209', '2026-01-26', 3, NOW()),
(1, NULL, 'ZZTEST-00027', '테스트복합 27', '784-12-00027', '정테27', '', '41590', '대구광역시 북구 테스트로 27', '대구 북구 테스트동 27', '053-000-0027', '01010000027', 'seed27@test.local', '110209', '2026-01-27', 1, NOW()),
(1, NULL, 'ZZTEST-00028', '테스트복합 28', '784-12-00028', '정테28', '', '41590', '대구광역시 북구 테스트로 28', '대구 북구 테스트동 28', '053-000-0028', '01010000028', 'seed28@test.local', '110209', '2026-01-28', 1, NOW()),
(1, NULL, 'ZZTEST-00029', '테스트복합 29', '784-12-00029', '정테29', '', '41590', '대구광역시 북구 테스트로 29', '대구 북구 테스트동 29', '053-000-0029', '01010000029', 'seed29@test.local', '110209', '2026-01-29', 1, NOW()),
(1, NULL, 'ZZTEST-00030', '테스트복합 30', '784-12-00030', '정테30', '', '41590', '대구광역시 북구 테스트로 30', '대구 북구 테스트동 30', '053-000-0030', '01010000030', 'seed30@test.local', '110209', '2026-01-30', 1, NOW());

View File

@@ -0,0 +1,56 @@
-- 테스터 계정 (비밀번호: test1234!) — 관리자 회원 등록과 동일 필드 구성
-- 비밀번호 해시: PHP password_hash('test1234!', PASSWORD_DEFAULT)
-- tester_local → local_government 중구청 (lg_idx=10, lg_code=110201)
-- 실행 예: mysql -h 116.122.157.166 -P 3306 -u jongryangje -p jongryangje_dev < writable/database/seed_tester_accounts_trash_host.sql
SET NAMES utf8mb4;
SET @pw := '$2y$10$D.rk9Dtce7qitSCaPO0W2.DROcEwpe3otxE.QF0qWPb63bCBhtE5u';
START TRANSACTION;
DELETE mar FROM member_approval_request mar
INNER JOIN member m ON m.mb_idx = mar.mb_idx
WHERE m.mb_id IN ('tester_badmin', 'tester_admin', 'tester_local', 'tester_shop', 'tester_user');
INSERT INTO `member` (
`mb_id`, `mb_passwd`, `mb_totp_secret`, `mb_totp_enabled`,
`mb_name`, `mb_email`, `mb_phone`, `mb_lang`,
`mb_level`, `mb_group`, `mb_lg_idx`, `mb_state`, `mb_regdate`
) VALUES
('tester_badmin', @pw, NULL, 0, '테스터본부', 'tester_badmin@test.com', '010-0000-0005', 'ko', 5, '', NULL, 1, NOW()),
('tester_admin', @pw, NULL, 0, '테스터관리자', 'tester_admin@test.com', '010-0000-0001', 'ko', 4, '', NULL, 1, NOW()),
('tester_local', @pw, NULL, 0, '테스터지자체(중구)', 'tester_local@test.com', '010-0000-0002', 'ko', 3, '', 10, 1, NOW()),
('tester_shop', @pw, NULL, 0, '테스터판매소', 'tester_shop@test.com', '010-0000-0003', 'ko', 2, '', NULL, 1, NOW()),
('tester_user', @pw, NULL, 0, '테스터사용자', 'tester_user@test.com', '010-0000-0004', 'ko', 1, '', NULL, 1, NOW())
AS new
ON DUPLICATE KEY UPDATE
`mb_passwd` = new.`mb_passwd`,
`mb_totp_secret` = NULL,
`mb_totp_enabled` = 0,
`mb_name` = new.`mb_name`,
`mb_email` = new.`mb_email`,
`mb_phone` = new.`mb_phone`,
`mb_level` = new.`mb_level`,
`mb_group` = new.`mb_group`,
`mb_lg_idx` = new.`mb_lg_idx`,
`mb_state` = 1;
INSERT INTO `member_approval_request` (
`mb_idx`, `mar_requested_level`, `mar_status`, `mar_request_note`,
`mar_reject_reason`, `mar_requested_at`, `mar_requested_by`,
`mar_processed_at`, `mar_processed_by`
)
SELECT
m.`mb_idx`,
m.`mb_level`,
'approved',
'테스트 계정 시드',
NULL,
NOW(),
m.`mb_idx`,
NOW(),
m.`mb_idx`
FROM `member` m
WHERE m.`mb_id` IN ('tester_badmin', 'tester_admin', 'tester_local', 'tester_shop', 'tester_user');
COMMIT;