Compare commits

19 Commits

Author SHA1 Message Date
taekyoungc
21e7b91871 실사/불출/lot-seed 관련 사이트 라우트를 운영 반영한다.
운영 404를 유발한 inspection-select 및 inspection-work 라우트 누락을 포함해 사이트 경로 변경분을 Routes.php에 반영한다.

Made-with: Cursor
2026-04-29 15:15:48 +09:00
taekyoungc
c708d30660 실사 저장 안정화와 메뉴 운영 정책을 일관되게 반영한다.
실사 저장값이 페이지 이동 후 원복되지 않도록 저장/조회 경로를 보강하고, 코드 범위 보정과 bis 간 동기화를 추가했다. 또한 메뉴 관리를 레벨4 이상으로 제한하고 메뉴 변경 사항을 모든 지자체에 일괄 반영하도록 동기화 로직을 도입했다.

Made-with: Cursor
2026-04-29 14:59:49 +09:00
taekyoungc
215d4d2c7c 발주 변경·입고 화면을 사이트 흐름에 맞게 반영한다.
발주 등록/변경 및 스캐너·일괄·입고현황 화면의 라우팅과 화면 구성을 운영과 동일한 최신 형태로 정리한다.

Made-with: Cursor
2026-04-23 15:53:33 +09:00
taekyoungc
6db9d119c1 발주 최신 헤드 조회 메서드 누락을 복구한다.
/bag/order/create 경로에서 발생한 undefined method(whereLatestHead) 오류를 막기 위해 BagOrderModel에 조회 스코프를 추가한다.

Made-with: Cursor
2026-04-23 15:43:19 +09:00
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
71 changed files with 11770 additions and 700 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

@@ -26,8 +26,18 @@ $routes->get('bag/code-details/(:num)', 'Bag::codeDetails/$1');
// 옛 주소 호환: 세부 목록만 사이트로 이동 // 옛 주소 호환: 세부 목록만 사이트로 이동
$routes->get('admin/code-details/(:num)', 'Admin\CodeDetail::index/$1'); $routes->get('admin/code-details/(:num)', 'Admin\CodeDetail::index/$1');
$routes->get('bag/purchase-inbound', 'Bag::purchaseInbound'); $routes->get('bag/purchase-inbound', 'Bag::purchaseInbound');
$routes->get('bag/issue', 'Bag::issue'); $routes->get('bag/issue', 'Bag::issueLegacy');
$routes->get('bag/issue/cancel', 'Bag::issue');
$routes->get('bag/inventory', 'Bag::inventory'); $routes->get('bag/inventory', 'Bag::inventory');
$routes->get('bag/inventory/export', 'Bag::inventoryExport');
$routes->get('bag/inventory/inspection-select', 'Bag::inspectionSelect');
$routes->get('bag/inventory/inspection-work', 'Bag::inspectionWork');
$routes->post('bag/inventory/inspection-run', 'Bag::inspectionRun');
$routes->post('bag/inventory/inspection-select/save', 'Bag::inspectionSelectSave');
$routes->post('bag/inventory/inspection-select/confirm', 'Bag::inspectionSelectConfirm');
$routes->get('bag/inventory/inspection/(:num)', 'Bag::inspectionDetail/$1');
$routes->post('bag/inventory/inspection/(:num)/save', 'Bag::inspectionSave/$1');
$routes->post('bag/inventory/inspection/(:num)/apply', 'Bag::inspectionApply/$1');
$routes->get('bag/sales', 'Bag::sales'); $routes->get('bag/sales', 'Bag::sales');
$routes->get('bag/sales-stats', 'Bag::salesStats'); $routes->get('bag/sales-stats', 'Bag::salesStats');
$routes->get('bag/flow', 'Bag::flow'); $routes->get('bag/flow', 'Bag::flow');
@@ -41,11 +51,24 @@ $routes->post('bag/inventory/adjust', 'Bag::inventoryAdjustStore');
$routes->get('bag/issue/create', 'Bag::issueCreate'); $routes->get('bag/issue/create', 'Bag::issueCreate');
$routes->post('bag/issue/store', 'Bag::issueStore'); $routes->post('bag/issue/store', 'Bag::issueStore');
$routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1'); $routes->post('bag/issue/cancel/(:num)', 'Bag::issueCancel/$1');
$routes->post('bag/issue/cancel-save', 'Bag::issueCancelSave');
$routes->get('bag/order/create', 'Bag::orderCreate'); $routes->get('bag/order/create', 'Bag::orderCreate');
$routes->get('bag/order/change', 'Bag::orderChange');
$routes->get('bag/order/revise/(:num)', 'Bag::orderRevise/$1');
$routes->get('bag/order/lot-seed', 'Bag::orderLotSeed');
$routes->post('bag/order/lot-seed/generate', 'Bag::orderLotSeedGenerate');
$routes->post('bag/order/store', 'Bag::orderStore'); $routes->post('bag/order/store', 'Bag::orderStore');
$routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1'); $routes->post('bag/order/cancel/(:num)', 'Bag::orderCancel/$1');
$routes->post('bag/order/delete', 'Bag::orderDeletePost');
$routes->post('bag/order/delete/(:num)', 'Bag::orderDelete/$1');
$routes->get('bag/receiving/create', 'Bag::receivingCreate'); $routes->get('bag/receiving/create', 'Bag::receivingCreate');
$routes->post('bag/receiving/store', 'Bag::receivingStore'); $routes->post('bag/receiving/store', 'Bag::receivingStore');
$routes->get('bag/receiving/scanner', 'Bag::receivingScanner');
$routes->post('bag/receiving/scanner/store', 'Bag::receivingScannerStore');
$routes->get('bag/receiving/batch', 'Bag::receivingBatch');
$routes->post('bag/receiving/batch/store', 'Bag::receivingBatchStore');
$routes->get('bag/receiving/status', 'Bag::receivingStatus');
$routes->get('bag/receiving/status/export', 'Bag::receivingStatusExport');
$routes->get('bag/sale/create', 'Bag::saleCreate'); $routes->get('bag/sale/create', 'Bag::saleCreate');
$routes->post('bag/sale/store', 'Bag::saleStore'); $routes->post('bag/sale/store', 'Bag::saleStore');
$routes->get('bag/shop-order/create', 'Bag::shopOrderCreate'); $routes->get('bag/shop-order/create', 'Bag::shopOrderCreate');
@@ -83,7 +106,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');
@@ -102,6 +131,7 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void
$routes->get('bag-orders/export', 'Admin\BagOrder::export'); $routes->get('bag-orders/export', 'Admin\BagOrder::export');
$routes->get('bag-orders', 'Admin\BagOrder::index'); $routes->get('bag-orders', 'Admin\BagOrder::index');
$routes->get('bag-orders/create', 'Admin\BagOrder::create'); $routes->get('bag-orders/create', 'Admin\BagOrder::create');
$routes->get('bag-orders/revise/(:num)', 'Admin\BagOrder::revise/$1');
$routes->post('bag-orders/store', 'Admin\BagOrder::store'); $routes->post('bag-orders/store', 'Admin\BagOrder::store');
$routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1'); $routes->get('bag-orders/detail/(:num)', 'Admin\BagOrder::detail/$1');
$routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1'); $routes->post('bag-orders/cancel/(:num)', 'Admin\BagOrder::cancel/$1');

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

@@ -50,7 +50,7 @@ class BagReceiving extends BaseController
return redirect()->to(mgmt_url('bag-receivings'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(mgmt_url('bag-receivings'))->with('error', '지자체를 선택해 주세요.');
} }
$orders = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll(); $orders = model(BagOrderModel::class)->where('bo_lg_idx', $lgIdx)->whereLatestHead($lgIdx)->where('bo_status', 'normal')->orderBy('bo_order_date', 'DESC')->findAll();
return $this->renderWorkPage('입고 처리', 'admin/bag_receiving/create', compact('orders')); return $this->renderWorkPage('입고 처리', 'admin/bag_receiving/create', compact('orders'));
} }

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

@@ -23,6 +23,9 @@ class Menu extends BaseController
*/ */
public function index() public function index()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
helper('admin'); helper('admin');
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
@@ -93,6 +96,9 @@ class Menu extends BaseController
*/ */
public function list() public function list()
{ {
if ($deny = $this->denyUnlessLevel4Plus(true)) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']); return $this->response->setJSON(['status' => 0, 'msg' => '지자체를 선택하세요.']);
@@ -112,6 +118,9 @@ class Menu extends BaseController
*/ */
public function store() public function store()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -144,6 +153,7 @@ class Menu extends BaseController
if ($mmPidx > 0) { if ($mmPidx > 0) {
$this->menuModel->updateCnode($mmPidx, 1); $this->menuModel->updateCnode($mmPidx, 1);
} }
$this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx);
return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); return redirect()->back()->with('success', '메뉴가 등록되었습니다.');
} }
@@ -152,6 +162,9 @@ class Menu extends BaseController
*/ */
public function update(int $id) public function update(int $id)
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -171,6 +184,7 @@ class Menu extends BaseController
'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N',
]; ];
$this->menuModel->update($id, $data); $this->menuModel->update($id, $data);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return redirect()->back()->with('success', '메뉴가 수정되었습니다.'); return redirect()->back()->with('success', '메뉴가 수정되었습니다.');
} }
@@ -179,6 +193,9 @@ class Menu extends BaseController
*/ */
public function delete(int $id) public function delete(int $id)
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -190,6 +207,7 @@ class Menu extends BaseController
} }
$result = $this->menuModel->deleteSafe($id); $result = $this->menuModel->deleteSafe($id);
if ($result['ok']) { if ($result['ok']) {
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return redirect()->back()->with('success', '메뉴가 삭제되었습니다.'); return redirect()->back()->with('success', '메뉴가 삭제되었습니다.');
} }
return redirect()->back()->with('error', $result['msg']); return redirect()->back()->with('error', $result['msg']);
@@ -200,6 +218,9 @@ class Menu extends BaseController
*/ */
public function move() public function move()
{ {
if ($deny = $this->denyUnlessLevel4Plus()) {
return $deny;
}
$lgIdx = admin_effective_lg_idx(); $lgIdx = admin_effective_lg_idx();
if ($lgIdx === null) { if ($lgIdx === null) {
return redirect()->to(base_url('admin/select-local-government')) return redirect()->to(base_url('admin/select-local-government'))
@@ -209,7 +230,12 @@ class Menu extends BaseController
if (! is_array($ids) || empty($ids)) { if (! is_array($ids) || empty($ids)) {
return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.'); return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.');
} }
$firstId = (int) ($ids[0] ?? 0);
$firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null;
$this->menuModel->setOrder($ids, $lgIdx); $this->menuModel->setOrder($ids, $lgIdx);
if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
}
return redirect()->back()->with('success', '순서가 적용되었습니다.'); return redirect()->back()->with('success', '순서가 적용되었습니다.');
} }
@@ -266,4 +292,27 @@ class Menu extends BaseController
return (int) $types[0]->mt_idx; return (int) $types[0]->mt_idx;
} }
/**
* 메뉴 관리는 레벨4 이상(슈퍼/본부 관리자)만 허용.
*
* @return \CodeIgniter\HTTP\RedirectResponse|\CodeIgniter\HTTP\ResponseInterface|null
*/
private function denyUnlessLevel4Plus(bool $json = false)
{
$level = (int) session()->get('mb_level');
if (Roles::isSuperAdminEquivalent($level)) {
return null;
}
if ($json) {
return $this->response->setJSON([
'status' => 0,
'msg' => '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.',
]);
}
return redirect()->to(base_url('admin/dashboard'))
->with('error', '메뉴 관리는 레벨4 이상만 접근할 수 있습니다.');
}
} }

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

@@ -10,9 +10,26 @@ class BagOrderModel extends Model
protected $primaryKey = 'bo_idx'; protected $primaryKey = 'bo_idx';
protected $returnType = 'object'; protected $returnType = 'object';
protected $useTimestamps = false; protected $useTimestamps = false;
/**
* 동일 발주 UUID에 대해 bo_version이 최대인 행만 (수정으로 생긴 이전 버전 행은 목록·이력에서 제외).
* DB에는 버전별 행이 그대로 남고, 조회 시에만 필터한다.
*/
public function whereLatestHead(int $lgIdx): self
{
$lg = (int) $lgIdx;
return $this->where(
"(bo_uuid, bo_version) IN (SELECT bo_uuid, MAX(bo_version) FROM {$this->table} WHERE bo_lg_idx = {$lg} GROUP BY bo_uuid)",
null,
false
);
}
protected $allowedFields = [ protected $allowedFields = [
'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code', 'bo_uuid', 'bo_version', 'bo_lg_idx', 'bo_gugun_code', 'bo_dong_code',
'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date', 'bo_company_idx', 'bo_agency_idx', 'bo_fee_rate', 'bo_order_date',
'bo_bag_types', 'bo_unit_prices', 'bo_qty_boxes',
'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx', 'bo_lot_no', 'bo_hash', 'bo_status', 'bo_orderer_idx',
'bo_regdate', 'bo_moddate', 'bo_regdate', 'bo_moddate',
]; ];

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

@@ -189,4 +189,67 @@ class MenuModel extends Model
} }
} }
/**
* 특정 메뉴 타입(mt_idx)을 source 지자체 기준으로 모든 지자체에 재배포.
* 기존 대상 지자체의 해당 타입 메뉴는 삭제 후 source 구조로 재생성한다.
*/
public function syncTypeToAllLgs(int $mtIdx, int $sourceLg): void
{
if ($mtIdx <= 0 || $sourceLg <= 0) {
return;
}
$source = $this->where('mt_idx', $mtIdx)
->where('lg_idx', $sourceLg)
->orderBy('mm_dep', 'ASC')
->orderBy('mm_num', 'ASC')
->findAll();
if (empty($source)) {
return;
}
$lgRows = $this->db->table('local_government')
->select('lg_idx')
->orderBy('lg_idx', 'ASC')
->get()
->getResultArray();
foreach ($lgRows as $lgRow) {
$destLg = (int) ($lgRow['lg_idx'] ?? 0);
if ($destLg <= 0 || $destLg === $sourceLg) {
continue;
}
$this->db->transStart();
$this->where('mt_idx', $mtIdx)
->where('lg_idx', $destLg)
->delete();
$idMap = [];
foreach ($source as $row) {
$oldId = (int) ($row->mm_idx ?? 0);
$oldP = (int) ($row->mm_pidx ?? 0);
$newPidx = 0;
if ($oldP > 0 && isset($idMap[$oldP])) {
$newPidx = (int) $idMap[$oldP];
}
$this->insert([
'mt_idx' => $mtIdx,
'lg_idx' => $destLg,
'mm_name' => (string) ($row->mm_name ?? ''),
'mm_link' => (string) ($row->mm_link ?? ''),
'mm_pidx' => $newPidx,
'mm_dep' => (int) ($row->mm_dep ?? 0),
'mm_num' => (int) ($row->mm_num ?? 0),
'mm_cnode' => (int) ($row->mm_cnode ?? 0),
'mm_level' => (string) ($row->mm_level ?? ''),
'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'),
]);
$idMap[$oldId] = (int) $this->getInsertID();
}
$this->db->transComplete();
}
}
} }

View File

@@ -1,83 +1,443 @@
<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-4xl">
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2"> <?php
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label> $oldBagCodes = old('item_bag_code');
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bo_order_date" type="date" value="<?= esc(old('bo_order_date', date('Y-m-d'))) ?>" required/> $oldQtyBoxes = old('item_qty_box');
$oldQtySheets = old('item_qty_sheet');
$oldBagCodes = is_array($oldBagCodes) ? $oldBagCodes : [];
$oldQtyBoxes = is_array($oldQtyBoxes) ? $oldQtyBoxes : [];
$oldQtySheets = is_array($oldQtySheets) ? $oldQtySheets : [];
$defaultOrderDate = old('bo_order_date', date('Y-m-d'));
$defaultOrderMonth = old('bo_order_month_ui', substr($defaultOrderDate, 0, 7));
$bagMeta = [];
foreach (($bagReferenceRows ?? []) as $row) {
$bagMeta[$row['code']] = [
'name' => $row['name'],
'orderPrice' => (float) $row['orderPrice'],
'boxPerPack' => (int) $row['boxPerPack'],
'packPerSheet' => (int) $row['packPerSheet'],
'totalPerBox' => max(1, (int) $row['totalPerBox']),
];
}
$initialSelectedItems = [];
$maxOldCount = max(count($oldBagCodes), count($oldQtySheets), count($oldQtyBoxes));
for ($i = 0; $i < $maxOldCount; $i++) {
$code = trim((string) ($oldBagCodes[$i] ?? ''));
if ($code === '' || ! isset($bagMeta[$code])) {
continue;
}
$fallbackQtyBox = (int) ($oldQtyBoxes[$i] ?? 0);
$rawQtySheet = (int) ($oldQtySheets[$i] ?? 0);
$fallbackTotalPerBox = (int) ($bagMeta[$code]['totalPerBox'] ?? 1);
if ($fallbackQtyBox <= 0 && $rawQtySheet > 0) {
$fallbackQtyBox = intdiv($rawQtySheet, max(1, $fallbackTotalPerBox));
}
$initialSelectedItems[] = [
'code' => $code,
'qtyBox' => max(0, $fallbackQtyBox),
];
}
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
?>
<form action="<?= mgmt_url('bag-orders/store') ?>" method="POST" class="mt-2 space-y-2">
<?= csrf_field() ?>
<div class="border border-gray-300 bg-white p-2">
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
<input id="bo_order_month_ui" name="bo_order_month_ui" type="month" value="<?= esc($defaultOrderMonth) ?>" class="border border-gray-300 rounded px-2 py-1" />
</div>
<div class="flex items-center gap-2">
<label for="bo_order_date" class="font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1" />
</div>
<p class="text-blue-600 font-bold">※ 발주수량은 박스단위로 입력해 주세요. (발주일은 미래일도 선택 가능)</p>
</div> </div>
</div>
<div class="flex flex-wrap items-center gap-2"> <div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label> <section class="xl:col-span-5 border border-gray-300 bg-white">
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>"/> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<span class="text-sm text-gray-500">%</span> <div class="overflow-auto max-h-[410px]">
</div> <table class="w-full data-table text-sm">
<div class="flex flex-wrap items-center gap-2">
<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="bo_company_idx">
<option value="">선택</option>
<?php foreach ($companies as $cp): ?>
<option value="<?= esc($cp->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $cp->cp_idx ? 'selected' : '' ?>>
<?= esc($cp->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<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="bo_agency_idx">
<option value="">선택</option>
<?php foreach ($agencies as $ag): ?>
<option value="<?= esc($ag->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $ag->sa_idx ? 'selected' : '' ?>>
[<?= esc($ag->sa_kind ?? '') ?>] <?= esc($ag->sa_code ?? '') ?> — <?= esc($ag->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">발주 품목</label>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">순번</th> <th class="w-28">발주일</th>
<th>봉투</th> <th>제작업체</th>
<th class="w-32">박스수</th> <th>입고처</th>
<th class="w-16">상태</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php for ($i = 0; $i < 3; $i++): ?> <?php foreach (($recentOrders ?? []) as $history): ?>
<tr> <tr>
<td class="text-center"><?= $i + 1 ?></td> <td class="text-center"><?= esc((string) $history->bo_order_date) ?></td>
<td> <td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]"> <td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<option value="">선택</option> <td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
<?php foreach ($bagCodes as $cd): ?>
<option value="<?= esc($cd->cd_code) ?>">
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty_box[]" type="number" min="0" value="0"/>
</td>
</tr> </tr>
<?php endfor; ?> <?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </section>
<div class="flex gap-2 pt-2"> <section class="xl:col-span-7 border border-gray-300 bg-white">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button> <div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 Form</div>
<a href="<?= mgmt_url('bag-orders') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a> <div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex items-center gap-2 text-sm">
<label for="bo_fee_rate" class="w-20 font-bold text-gray-700">수수료</label>
<input id="bo_fee_rate" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>" class="border border-gray-300 rounded px-2 py-1 w-24 text-right" />
<span>%</span>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($associations ?? []) as $association): ?>
<option value="<?= esc((string) $association->cp_idx) ?>" <?= (int) old('bo_association_idx') === (int) $association->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $association->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_company_idx" class="w-20 font-bold text-gray-700">제작업체</label>
<select id="bo_company_idx" name="bo_company_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= esc((string) $company->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $company->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_agency_idx" class="w-20 font-bold text-gray-700">입고처</label>
<select id="bo_agency_idx" name="bo_agency_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($agencies ?? []) as $agency): ?>
<option value="<?= esc((string) $agency->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $agency->sa_idx ? 'selected' : '' ?>>
[<?= esc((string) ($agency->sa_kind ?? '')) ?>] <?= esc((string) ($agency->sa_code ?? '')) ?> — <?= esc((string) $agency->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm order-input-table" id="order-item-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-16">선택</th>
<th>품명</th>
<th class="w-28">수량(BOX)</th>
<th class="w-24">단가</th>
<th class="w-24">환산수량</th>
<th class="w-28">금액</th>
</tr>
</thead>
<tbody id="selected-order-items-body"></tbody>
<tfoot>
<tr>
<th colspan="3" class="text-center">계</th>
<th class="text-right pr-2" id="sum-box-qty">0</th>
<th></th>
<th class="text-right pr-2" id="sum-sheet-qty">0</th>
<th class="text-right pr-2" id="sum-amount">0</th>
</tr>
</tfoot>
</table>
</div>
<div class="flex gap-2 pt-1">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">발주</button>
<a href="<?= mgmt_url('bag-orders') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</div>
</section>
</div>
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div>
<p class="text-xs text-gray-600 px-2 py-1">아래 목록에서 봉투를 선택하면 발주 품목에 추가됩니다. (개수 제한 없음)</p>
<div class="overflow-auto">
<table class="w-full data-table text-sm order-reference-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-20">선택</th>
<th>봉투 종류</th>
<th class="w-24">발주단가</th>
<th class="w-24">Box당 팩</th>
<th class="w-24">팩당 낱장</th>
<th class="w-28">1박스 총 낱장</th>
</tr>
</thead>
<tbody>
<?php foreach (($bagReferenceRows ?? []) as $idx => $row): ?>
<tr data-reference-row data-code="<?= esc((string) $row['code']) ?>" class="cursor-pointer">
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center">
<button type="button" class="js-toggle-bag border border-gray-300 rounded px-2 py-0.5 text-xs hover:bg-gray-100" data-code="<?= esc((string) $row['code']) ?>">선택</button>
</td>
<td class="text-left pl-2"><?= esc((string) $row['name']) ?></td>
<td class="text-right pr-2"><?= number_format((float) $row['orderPrice'], 2) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['boxPerPack']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['packPerSheet']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['totalPerBox']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($bagReferenceRows)): ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">표시할 봉투 기준 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div> </div>
</form> </section>
</div> </form>
<style>
.order-input-table tbody tr,
.order-reference-table tbody tr {
height: 34px;
}
.order-input-table tbody td,
.order-reference-table tbody td {
padding-top: 4px;
padding-bottom: 4px;
}
</style>
<script>
(() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const initialSelectedItems = <?= json_encode($initialSelectedItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const selectedBody = document.getElementById('selected-order-items-body');
const referenceRows = Array.from(document.querySelectorAll('[data-reference-row]'));
const sumBoxQtyEl = document.getElementById('sum-box-qty');
const sumSheetQtyEl = document.getElementById('sum-sheet-qty');
const sumAmountEl = document.getElementById('sum-amount');
const monthInput = document.getElementById('bo_order_month_ui');
const orderDateInput = document.getElementById('bo_order_date');
const orderForm = document.querySelector('form[action*="bag-orders/store"]');
const selectedItems = new Map();
let activeCode = null;
const formatNumber = (value) => new Intl.NumberFormat('ko-KR').format(Number.isFinite(value) ? value : 0);
const escapeHtml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const syncMonthFromDate = () => {
if (!orderDateInput || !monthInput || !orderDateInput.value) return;
monthInput.value = orderDateInput.value.substring(0, 7);
};
const syncDateFromMonth = () => {
if (!orderDateInput || !monthInput || !monthInput.value) return;
const parts = monthInput.value.split('-');
if (parts.length !== 2) return;
const year = Number(parts[0]);
const month = Number(parts[1]);
if (!Number.isFinite(year) || !Number.isFinite(month)) return;
const currentDay = orderDateInput.value ? Number(orderDateInput.value.split('-')[2]) : 1;
const lastDay = new Date(year, month, 0).getDate();
const day = String(Math.min(Math.max(currentDay, 1), lastDay)).padStart(2, '0');
orderDateInput.value = `${String(year)}-${String(month).padStart(2, '0')}-${day}`;
};
const updateReferenceSelectionUi = () => {
referenceRows.forEach((row) => {
const code = row.dataset.code || '';
const button = row.querySelector('.js-toggle-bag');
const isSelected = selectedItems.has(code);
row.classList.toggle('bg-blue-50', isSelected);
if (button) {
button.textContent = isSelected ? '선택됨' : '선택';
button.classList.toggle('bg-blue-600', isSelected);
button.classList.toggle('text-white', isSelected);
button.classList.toggle('border-blue-600', isSelected);
}
});
};
const updateTotals = () => {
let sumBoxQty = 0;
let sumSheetQty = 0;
let sumAmount = 0;
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
const code = row.dataset.code || '';
const qtyInput = row.querySelector('.item-qty-box');
const qtyBox = Math.max(0, parseInt(qtyInput?.value || '0', 10));
const meta = bagMeta[code] || { orderPrice: 0, totalPerBox: 1 };
const unitPrice = Number(meta.orderPrice || 0);
const totalPerBox = Math.max(1, Number(meta.totalPerBox || 1));
const qtySheet = qtyBox * totalPerBox;
const amount = qtySheet * unitPrice;
const unitPriceEl = row.querySelector('.item-unit-price');
const qtySheetEl = row.querySelector('.item-qty-sheet');
const sheetHelpEl = row.querySelector('.item-sheet-help');
const amountEl = row.querySelector('.item-amount');
if (unitPriceEl) unitPriceEl.textContent = formatNumber(unitPrice);
if (qtySheetEl) qtySheetEl.textContent = formatNumber(qtySheet);
if (sheetHelpEl) sheetHelpEl.textContent = `낱장 ${formatNumber(qtySheet)}장`;
if (amountEl) amountEl.textContent = formatNumber(amount);
selectedItems.set(code, { qtyBox });
sumBoxQty += qtyBox;
sumSheetQty += qtySheet;
sumAmount += amount;
});
if (sumBoxQtyEl) sumBoxQtyEl.textContent = formatNumber(sumBoxQty);
if (sumSheetQtyEl) sumSheetQtyEl.textContent = formatNumber(sumSheetQty);
if (sumAmountEl) sumAmountEl.textContent = formatNumber(sumAmount);
};
const setActiveRow = (code) => {
activeCode = code || null;
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
row.classList.toggle('bg-amber-50', row.dataset.code === activeCode);
});
};
const renderSelectedRows = () => {
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
if (codes.length === 0) {
selectedBody.innerHTML = '<tr><td colspan="7" class="text-center text-gray-400 py-4">아래 "발주 등록 종류"에서 봉투를 선택해 주세요.</td></tr>';
setActiveRow(null);
updateTotals();
updateReferenceSelectionUi();
return;
}
selectedBody.innerHTML = codes.map((code, idx) => {
const meta = bagMeta[code];
const qtyBox = Math.max(0, parseInt(String(selectedItems.get(code)?.qtyBox ?? 0), 10));
const name = meta?.name || code;
return `
<tr data-item-row data-code="${escapeHtml(code)}" class="cursor-pointer">
<td class="text-center">${idx + 1}</td>
<td class="text-center">
<button type="button" class="js-remove-selected text-xs text-red-600 hover:underline" data-code="${escapeHtml(code)}">해제</button>
</td>
<td class="text-left pl-2">
${escapeHtml(name)}
<input type="hidden" name="item_bag_code[]" value="${escapeHtml(code)}" />
</td>
<td>
<input name="item_qty_box[]" type="number" min="0" step="1" value="${qtyBox}" class="item-qty-box border border-gray-300 rounded px-2 py-1 text-sm w-full text-right leading-tight" />
<p class="text-[11px] text-gray-500 mt-1 item-sheet-help">낱장 0장</p>
</td>
<td class="text-right pr-2 item-unit-price">0</td>
<td class="text-right pr-2 item-qty-sheet">0</td>
<td class="text-right pr-2 item-amount">0</td>
</tr>
`;
}).join('');
if (!activeCode || !selectedItems.has(activeCode)) {
activeCode = codes[0];
}
setActiveRow(activeCode);
updateTotals();
updateReferenceSelectionUi();
};
const toggleSelection = (code) => {
if (!code || !bagMeta[code]) return;
if (selectedItems.has(code)) {
selectedItems.delete(code);
if (activeCode === code) activeCode = null;
} else {
selectedItems.set(code, { qtyBox: 0 });
activeCode = code;
}
renderSelectedRows();
};
initialSelectedItems.forEach((item) => {
if (!item || !item.code || !bagMeta[item.code]) return;
selectedItems.set(item.code, { qtyBox: Math.max(0, parseInt(String(item.qtyBox ?? 0), 10)) });
activeCode = item.code;
});
selectedBody.addEventListener('click', (event) => {
const removeButton = event.target.closest('.js-remove-selected');
if (removeButton) {
toggleSelection(removeButton.dataset.code || '');
return;
}
const row = event.target.closest('tr[data-item-row]');
if (!row) return;
const code = row.dataset.code || '';
setActiveRow(code);
const qtyInput = row.querySelector('.item-qty-box');
if (qtyInput) qtyInput.focus();
});
selectedBody.addEventListener('input', (event) => {
const qtyInput = event.target.closest('.item-qty-box');
if (!qtyInput) return;
const row = qtyInput.closest('tr[data-item-row]');
if (!row) return;
const code = row.dataset.code || '';
selectedItems.set(code, { qtyBox: Math.max(0, parseInt(qtyInput.value || '0', 10)) });
updateTotals();
});
referenceRows.forEach((row) => {
row.addEventListener('click', (event) => {
const button = event.target.closest('.js-toggle-bag');
if (button) {
toggleSelection(button.dataset.code || '');
return;
}
if (event.target.closest('td')) {
toggleSelection(row.dataset.code || '');
}
});
});
if (monthInput) monthInput.addEventListener('change', () => { syncDateFromMonth(); updateTotals(); });
if (orderDateInput) orderDateInput.addEventListener('change', syncMonthFromDate);
if (orderForm) {
orderForm.addEventListener('submit', (event) => {
const hasValidItem = Array.from(selectedBody.querySelectorAll('tr[data-item-row]')).some((row) => {
const qtyInput = row.querySelector('.item-qty-box');
return Math.max(0, parseInt(qtyInput?.value || '0', 10)) > 0;
});
if (!hasValidItem) {
event.preventDefault();
alert('봉투를 선택하고 수량을 1 이상 입력해 주세요.');
}
});
}
syncMonthFromDate();
renderSelectedRows();
})();
</script>

View File

@@ -1,81 +1,200 @@
<?= view('components/print_header', ['printTitle' => '발주 현황']) ?> <?php
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> // 발주기간: native month 입력은 로케일에 따라 Jan 등 영문 표기될 수 있어 YYYY-MM select + 한글 라벨 사용
$bagOrderYmChoices = [];
$bagOrderYmCenterY = (int) date('Y');
for ($by = $bagOrderYmCenterY - 4; $by <= $bagOrderYmCenterY + 2; $by++) {
for ($bm = 1; $bm <= 12; $bm++) {
$bagOrderYmChoices[] = sprintf('%04d-%02d', $by, $bm);
}
}
foreach ([(string) ($startMonth ?? ''), (string) ($endMonth ?? '')] as $ymExtra) {
if (preg_match('/^\d{4}-\d{2}$/', $ymExtra) && ! in_array($ymExtra, $bagOrderYmChoices, true)) {
$bagOrderYmChoices[] = $ymExtra;
}
}
sort($bagOrderYmChoices);
$bagOrderYmLabel = static function (string $ym): string {
if (preg_match('/^(\d{4})-(\d{2})$/', $ym, $m)) {
return $m[1] . '년 ' . (int) $m[2] . '월';
}
return $ym;
};
?>
<?= view('components/print_header', ['printTitle' => '봉투 발주 현황', 'printShowApproval' => false]) ?>
<section class="no-print 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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="<?= mgmt_url('bag-orders/export') . '?' . http_build_query(array_filter(['start_date' => $startDate ?? '', 'end_date' => $endDate ?? '', 'status' => $status ?? ''])) ?>" 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('bag-orders/export') . '?' . http_build_query(array_filter(['start_month' => $startMonth ?? '', 'end_month' => $endMonth ?? '', 'company_idx' => $companyIdx ?? 0, 'bag_code' => $bagCode ?? '', 'receive_type' => $receiveType ?? ''])) ?>" 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 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="<?= mgmt_url('bag-orders/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-orders/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>
<section class="p-2 bg-white border-b border-gray-200">
<form method="GET" action="<?= mgmt_url('bag-orders') ?>" class="flex flex-wrap items-center gap-2"> <section class="no-print p-2 bg-white border-b border-gray-200">
<label class="text-sm text-gray-600">발주일</label> <!-- GBMS 발주현황: 발주기간은 [시작] ~ [끝] 한 줄 고정, 필터 블록은 가로 나열 후 좁으면 블록 단위로만 줄바꿈 -->
<input type="date" name="start_date" value="<?= esc($startDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <form method="GET" action="<?= mgmt_url('bag-orders') ?>" class="flex flex-wrap items-end gap-x-5 gap-y-3 w-full">
<label class="text-sm text-gray-600">~</label> <div class="flex flex-nowrap items-center gap-2 shrink-0">
<input type="date" name="end_date" value="<?= esc($endDate ?? '') ?>" class="border border-gray-300 rounded px-2 py-1 text-sm"/> <label class="text-sm text-gray-600 whitespace-nowrap">발주 기간</label>
<label class="text-sm text-gray-600">상태</label> <select name="start_month" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11.5rem] max-w-[13rem] shrink-0">
<select name="status" class="border border-gray-300 rounded px-2 py-1 text-sm"> <?php foreach ($bagOrderYmChoices as $ym): ?>
<option value="">전체</option> <option value="<?= esc($ym) ?>" <?= ($startMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
<option value="normal" <?= ($status ?? '') === 'normal' ? 'selected' : '' ?>>정상</option> <?php endforeach; ?>
<option value="cancelled" <?= ($status ?? '') === 'cancelled' ? 'selected' : '' ?>>취소</option> </select>
<option value="deleted" <?= ($status ?? '') === 'deleted' ? 'selected' : '' ?>>삭제</option> <span class="text-sm text-gray-500 select-none">~</span>
</select> <select name="end_month" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11.5rem] max-w-[13rem] shrink-0">
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button> <?php foreach ($bagOrderYmChoices as $ym): ?>
<a href="<?= mgmt_url('bag-orders') ?>" class="text-sm text-gray-500 hover:underline">초기화</a> <option value="<?= esc($ym) ?>" <?= ($endMonth ?? date('Y-m')) === $ym ? 'selected' : '' ?>><?= esc($bagOrderYmLabel($ym)) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">제작 업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11rem] max-w-[14rem]">
<option value="0">전 체</option>
<?php foreach (($companyOptions ?? []) as $company): ?>
<option value="<?= (int) $company->cp_idx ?>" <?= (int) ($companyIdx ?? 0) === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $company->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">품 명</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm w-[11rem] max-w-[16rem]">
<option value="">전 체</option>
<?php foreach (($bagCodeOptions ?? []) as $bag): ?>
<option value="<?= esc((string) $bag->cd_code) ?>" <?= (string) ($bagCode ?? '') === (string) $bag->cd_code ? 'selected' : '' ?>>
<?= esc((string) $bag->cd_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0">
<label class="text-sm text-gray-600 whitespace-nowrap">입고 구분</label>
<select name="receive_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-[8.5rem]">
<option value="all" <?= ($receiveType ?? 'all') === 'all' ? 'selected' : '' ?>>전 체</option>
<option value="received" <?= ($receiveType ?? 'all') === 'received' ? 'selected' : '' ?>>입고완료</option>
<option value="pending" <?= ($receiveType ?? 'all') === 'pending' ? 'selected' : '' ?>>미입고</option>
</select>
</div>
<div class="flex flex-nowrap items-center gap-2 shrink-0 sm:ml-auto">
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= mgmt_url('bag-orders') ?>" class="text-sm text-gray-500 hover:underline whitespace-nowrap">초기화</a>
</div>
</form> </form>
</section> </section>
<div class="border border-gray-300 overflow-auto mt-2">
<table class="w-full data-table"> <div class="bag-order-print-wrap border border-gray-300 overflow-auto mt-2">
<table class="bag-order-print-table w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">번호</th> <th class="w-32">발주일자</th>
<th>LOT번호</th> <th class="min-w-[10rem]">제작 업체</th>
<th>발주일</th> <th class="min-w-[12rem]">품 명</th>
<th>제작업체</th> <th class="w-28">발주 수량</th>
<th>입고처</th> <th class="w-28">입고 수량</th>
<th>품목수</th> <th class="w-28">미입고수량</th>
<th>총수량</th> <th class="w-32">발주 금액</th>
<th>총금액</th> <th class="min-w-[9rem]">입고처</th>
<th class="w-20">상태</th> <th class="min-w-[8rem]">비 고</th>
<th class="w-44">작업</th>
</tr> </tr>
</thead> </thead>
<tbody class="text-right"> <tbody class="text-right">
<?php foreach ($list as $row): ?> <?php $printedGroup = []; ?>
<?php foreach (($rows ?? []) as $row): ?>
<?php if (! empty($row['is_subtotal'])): ?>
<tr class="bg-gray-50 font-semibold">
<td colspan="3" class="text-center"><?= esc((string) ($row['label'] ?? '소계')) ?></td>
<td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
<td></td>
<td></td>
</tr>
<?php continue; ?>
<?php endif; ?>
<?php
$boIdx = (int) ($row['bo_idx'] ?? 0);
$showGroup = ! isset($printedGroup[$boIdx]);
$rowspan = (int) (($groupRows[$boIdx] ?? 1));
if ($showGroup) {
$printedGroup[$boIdx] = true;
}
?>
<tr> <tr>
<td class="text-center"><?= esc($row->bo_idx) ?></td> <?php if ($showGroup): ?>
<td class="text-center font-mono"><?= esc($row->bo_lot_no) ?></td> <td class="text-center align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-center"><?= esc($row->bo_order_date) ?></td> <td class="text-left pl-2 align-top" rowspan="<?= $rowspan ?>"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc($companyMap[$row->bo_company_idx] ?? '') ?></td> <?php endif; ?>
<td class="text-left pl-2"><?= esc($agencyMap[$row->bo_agency_idx] ?? '') ?></td> <td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['count'] ?? 0)) ?></td> <td><?= number_format((int) ($row['order_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['qty'] ?? 0)) ?></td> <td><?= number_format((int) ($row['received_qty'] ?? 0)) ?></td>
<td><?= number_format((int) ($itemSummary[$row->bo_idx]['amount'] ?? 0)) ?></td> <td><?= number_format((int) ($row['pending_qty'] ?? 0)) ?></td>
<td class="text-center"> <td><?= number_format((float) ($row['amount'] ?? 0)) ?></td>
<?php <td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제']; <td></td>
echo esc($statusMap[$row->bo_status] ?? $row->bo_status);
?>
</td>
<td class="text-center">
<a href="<?= mgmt_url('bag-orders/detail/' . (int) $row->bo_idx) ?>" class="text-blue-600 hover:underline text-sm mr-1">상세</a>
<form action="<?= mgmt_url('bag-orders/cancel/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('취소하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-orange-600 hover:underline text-sm mr-1">취소</button>
</form>
<form action="<?= mgmt_url('bag-orders/delete/' . (int) $row->bo_idx) ?>" method="POST" class="inline" onsubmit="return confirm('삭제하시겠습니까?');">
<?= csrf_field() ?>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php if (empty($list)): ?>
<tr><td colspan="10" class="text-center text-gray-400 py-4">등록된 발주가 없습니다.</td></tr> <?php if (empty($rows ?? [])): ?>
<tr><td colspan="9" class="text-center text-gray-400 py-6">조회 조건에 해당하는 발주 내역이 없습니다.</td></tr>
<?php endif; ?> <?php endif; ?>
</tbody> </tbody>
<tfoot>
<tr class="bg-gray-100 font-bold">
<td colspan="3" class="text-center">총계</td>
<td class="text-right"><?= number_format((int) ($grandTotals['order_qty'] ?? 0)) ?></td>
<td class="text-right"><?= number_format((int) ($grandTotals['received_qty'] ?? 0)) ?></td>
<td class="text-right"><?= number_format((int) ($grandTotals['pending_qty'] ?? 0)) ?></td>
<td class="text-right"><?= number_format((float) ($grandTotals['amount'] ?? 0)) ?></td>
<td></td>
<td></td>
</tr>
</tfoot>
</table> </table>
</div> </div>
<?php if (isset($pager)): ?><div class="mt-3"><?= $pager->links() ?></div><?php endif; ?>
<style>
@media print {
#debug-icon,
#debug-bar,
#debug-bar-contents,
#debug-toolbar,
.debug-toolbar,
.ci-debug-toolbar,
[id^='debug-bar-'],
[id^='debug-icon'],
[class*='debug-toolbar'] {
display: none !important;
visibility: hidden !important;
}
.bag-order-print-wrap {
overflow: visible !important;
border: none !important;
margin-top: 0 !important;
}
.bag-order-print-table {
width: 100% !important;
table-layout: auto !important;
}
.bag-order-print-table th,
.bag-order-print-table td {
white-space: nowrap !important;
word-break: keep-all !important;
overflow-wrap: normal !important;
font-size: 10px !important;
padding: 2px 3px !important;
}
}
</style>

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

@@ -1,83 +1,701 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel"> <?php
<span class="text-sm font-bold text-gray-700">발주 등록</span> $editDefaults = is_array($editDefaults ?? null) ? $editDefaults : [];
$editMode = (bool) ($editMode ?? false);
$oldBagCodes = old('item_bag_code', $editDefaults['item_bag_code'] ?? []);
$oldQtySheets = old('item_qty_sheet', $editDefaults['item_qty_sheet'] ?? []);
$oldQtyBoxes = old('item_qty_box', $editDefaults['item_qty_box'] ?? []);
$oldBagCodes = is_array($oldBagCodes) ? $oldBagCodes : [];
$oldQtySheets = is_array($oldQtySheets) ? $oldQtySheets : [];
$oldQtyBoxes = is_array($oldQtyBoxes) ? $oldQtyBoxes : [];
$itemRowCount = max(8, count($oldBagCodes), count($oldQtySheets), count($oldQtyBoxes));
$defaultOrderDate = old('bo_order_date', $editDefaults['bo_order_date'] ?? date('Y-m-d'));
$defaultOrderMonth = old('bo_order_month_ui', substr($defaultOrderDate, 0, 7));
$defaultOrderYear = (int) substr($defaultOrderMonth, 0, 4);
$monthOptionValues = [];
for ($year = $defaultOrderYear - 2; $year <= $defaultOrderYear + 2; $year++) {
for ($month = 1; $month <= 12; $month++) {
$monthValue = sprintf('%04d-%02d', $year, $month);
$monthLabel = $year . '년 ' . $month . '월';
$monthOptionValues[] = ['value' => $monthValue, 'label' => $monthLabel];
}
}
$bagMeta = [];
foreach (($bagReferenceRows ?? []) as $row) {
$bagMeta[$row['code']] = [
'name' => $row['name'],
'orderPrice' => (float) $row['orderPrice'],
'boxPerPack' => (int) $row['boxPerPack'],
'packPerSheet' => (int) $row['packPerSheet'],
'totalPerBox' => max(1, (int) $row['totalPerBox']),
];
}
$initialSelectedItems = [];
$maxOldCount = max(count($oldBagCodes), count($oldQtySheets), count($oldQtyBoxes));
for ($i = 0; $i < $maxOldCount; $i++) {
$code = trim((string) ($oldBagCodes[$i] ?? ''));
if ($code === '' || !isset($bagMeta[$code])) {
continue;
}
$fallbackQtyBox = (int) ($oldQtyBoxes[$i] ?? 0);
$rawQtySheet = (int) ($oldQtySheets[$i] ?? 0);
$fallbackTotalPerBox = (int) ($bagMeta[$code]['totalPerBox'] ?? 1);
if ($fallbackQtyBox <= 0 && $rawQtySheet > 0) {
$fallbackQtyBox = intdiv($rawQtySheet, max(1, $fallbackTotalPerBox));
}
$initialSelectedItems[] = [
'code' => $code,
'qtyBox' => max(0, $fallbackQtyBox),
];
}
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
$hubReturn = (bool) ($hubReturn ?? false);
$changeMode = $editMode ? (string) ($changeMode ?? 'meta') : 'meta';
if (! in_array($changeMode, ['price', 'meta', 'delete'], true)) {
$changeMode = 'meta';
}
$orderLotNo = (string) ($orderLotNo ?? '');
$orderReturnMonth = (string) ($orderReturnMonth ?? '');
$deleteMode = $editMode && $changeMode === 'delete';
$defaultDeleteBoIdx = (int) ($editDefaults['bo_source_idx'] ?? 0);
$firstNormalBoIdx = null;
foreach (($recentOrders ?? []) as $_h) {
if ((string) ($_h->bo_status ?? '') === 'normal') {
$firstNormalBoIdx = (int) $_h->bo_idx;
break;
}
}
$defaultDeleteOk = false;
foreach (($recentOrders ?? []) as $_h) {
if ((int) $_h->bo_idx === $defaultDeleteBoIdx && (string) ($_h->bo_status ?? '') === 'normal') {
$defaultDeleteOk = true;
break;
}
}
$selectedDeleteBoIdx = $defaultDeleteOk ? $defaultDeleteBoIdx : (int) ($firstNormalBoIdx ?? 0);
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700"><?= $editMode ? '발주 변경' : '발주 등록' ?></span>
<?php if ($hubReturn && $orderReturnMonth !== ''): ?>
<a href="<?= base_url('bag/order/change?month=' . rawurlencode($orderReturnMonth) . '&hub_mode=' . rawurlencode($changeMode)) ?>" class="text-sm text-blue-600 hover:underline">발주 변경 목록</a>
<?php endif; ?>
</section> </section>
<div class="border border-gray-300 p-4 mt-2 bg-white max-w-4xl">
<form action="<?= base_url('bag/order/store') ?>" method="POST" class="space-y-4">
<?= csrf_field() ?>
<div class="flex flex-wrap items-center gap-2"> <?php if ($deleteMode): ?>
<label class="block text-sm font-bold text-gray-700 w-28">발주일 <span class="text-red-500">*</span></label> <form action="<?= base_url('bag/order/delete') ?>" method="post" class="mt-2 space-y-2">
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-44" name="bo_order_date" type="date" value="<?= esc(old('bo_order_date', date('Y-m-d'))) ?>" required/> <?= csrf_field() ?>
</div> <div class="border border-gray-300 bg-white p-2 text-sm">
<span class="font-bold text-gray-700">발주월</span>
<div class="flex flex-wrap items-center gap-2"> <span class="ml-2"><?= esc($defaultOrderMonth) ?></span>
<label class="block text-sm font-bold text-gray-700 w-28">수수료율</label> <span class="text-gray-600 ml-2">왼쪽 목록에서 삭제할 발주를 선택한 뒤 「삭제 실행」을 누르세요.</span>
<input class="border border-gray-300 rounded px-3 py-1.5 text-sm w-32 text-right" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', '0')) ?>"/> </div>
<span class="text-sm text-gray-500">%</span> <div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
</div> <section class="xl:col-span-5 border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<div class="flex flex-wrap items-center gap-2"> <div class="overflow-auto max-h-[410px]">
<label class="block text-sm font-bold text-gray-700 w-28">제작업체</label> <table class="w-full data-table text-sm">
<select class="border border-gray-300 rounded px-3 py-1.5 text-sm w-60" name="bo_company_idx">
<option value="">선택</option>
<?php foreach ($companies as $cp): ?>
<option value="<?= esc($cp->cp_idx) ?>" <?= (int) old('bo_company_idx') === (int) $cp->cp_idx ? 'selected' : '' ?>>
<?= esc($cp->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex flex-wrap items-center gap-2">
<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="bo_agency_idx">
<option value="">선택</option>
<?php foreach ($agencies as $ag): ?>
<option value="<?= esc($ag->sa_idx) ?>" <?= (int) old('bo_agency_idx') === (int) $ag->sa_idx ? 'selected' : '' ?>>
[<?= esc($ag->sa_kind ?? '') ?>] <?= esc($ag->sa_code ?? '') ?> — <?= esc($ag->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="mt-4">
<label class="block text-sm font-bold text-gray-700 mb-2">발주 품목</label>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table">
<thead> <thead>
<tr> <tr>
<th class="w-16">순번</th> <th class="w-10">선택</th>
<th>봉투</th> <th class="w-28">발주일</th>
<th class="w-32">박스수</th> <th>제작업체</th>
<th>입고처</th>
<th class="w-16">상태</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php for ($i = 0; $i < 3; $i++): ?> <?php $deleteRadioFirst = true; ?>
<tr> <?php foreach (($recentOrders ?? []) as $history): ?>
<td class="text-center"><?= $i + 1 ?></td> <?php $rowNormal = (string) ($history->bo_status ?? '') === 'normal'; ?>
<td> <tr class="<?= $rowNormal ? '' : 'opacity-60' ?>">
<select class="border border-gray-300 rounded px-2 py-1 text-sm w-full" name="item_bag_code[]"> <td class="text-center align-middle">
<option value="">선택</option> <?php if ($rowNormal): ?>
<?php foreach ($bagCodes as $cd): ?> <input type="radio" name="bo_idx" value="<?= (int) $history->bo_idx ?>" class="accent-red-600" <?= (int) $history->bo_idx === $selectedDeleteBoIdx ? 'checked' : '' ?> <?= $deleteRadioFirst ? 'required' : '' ?> />
<option value="<?= esc($cd->cd_code) ?>"> <?php $deleteRadioFirst = false; ?>
<?= esc($cd->cd_code) ?> — <?= esc($cd->cd_name) ?> <?php else: ?>
</option> <span class="text-gray-400" title="삭제할 수 없는 상태">—</span>
<?php endforeach; ?> <?php endif; ?>
</select>
</td>
<td>
<input class="border border-gray-300 rounded px-2 py-1 text-sm w-full text-right" name="item_qty_box[]" type="number" min="0" value="0"/>
</td> </td>
<td class="text-center"><?= esc((string) $history->bo_order_date) ?></td>
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
</tr> </tr>
<?php endfor; ?> <?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </section>
<section class="xl:col-span-7 border border-red-200 bg-red-50 p-4">
<p class="text-sm font-bold text-red-800 mb-2">발주 삭제</p>
<p class="text-sm text-gray-700 mb-4">목록에서 선택한 발주를 삭제 처리합니다. 계속하시겠습니까?</p>
<div class="flex flex-wrap gap-2 items-center">
<button type="submit" class="bg-red-600 text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90" <?= $firstNormalBoIdx === null ? 'disabled' : '' ?>>삭제 실행</button>
<a href="<?= base_url('bag/order/change?month=' . rawurlencode($orderReturnMonth !== '' ? $orderReturnMonth : substr($defaultOrderDate, 0, 7))) ?>" class="bg-gray-200 text-gray-800 px-6 py-1.5 rounded-sm text-sm">취소</a>
</div>
</section>
</div>
</form>
<?php else: ?>
<div class="flex gap-2 pt-2"> <form action="<?= base_url('bag/order/store') ?>" method="POST" class="mt-2 space-y-2" id="bag-order-store-form">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition">등록</button> <?= csrf_field() ?>
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a> <?php if ($editMode): ?>
<input type="hidden" name="bo_source_idx" value="<?= esc((string) ($editDefaults['bo_source_idx'] ?? 0)) ?>" />
<fieldset class="border border-gray-200 rounded px-3 py-2 mb-1 text-sm bg-white">
<legend class="text-xs font-bold text-gray-600 px-1">변경 구분</legend>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="bo_change_mode" value="price" <?= $changeMode === 'price' ? 'checked' : '' ?> />
<span>발주·도매·판매 단가</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="bo_change_mode" value="meta" <?= $changeMode === 'meta' ? 'checked' : '' ?> />
<span>업체·수수료·협회·발주</span>
</label>
</div> </div>
</form> <p class="text-xs text-gray-500 mt-1">
</div> 발주 삭제는 <a class="text-blue-600 hover:underline" href="<?= base_url('bag/order/revise/' . (int) ($editDefaults['bo_source_idx'] ?? 0) . '?change_mode=delete') ?>">발주 삭제 화면</a>으로 이동합니다.
</p>
</fieldset>
<?php endif; ?>
<?php if ($hubReturn && $orderReturnMonth !== ''): ?>
<input type="hidden" name="order_return_hub" value="1" />
<input type="hidden" name="order_return_month" value="<?= esc($orderReturnMonth) ?>" />
<?php endif; ?>
<div class="border border-gray-300 bg-white p-2">
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<label for="bo_order_month_ui" class="font-bold text-gray-700">발주월</label>
<select id="bo_order_month_ui" name="bo_order_month_ui" class="border border-gray-300 rounded px-2 py-1 w-40">
<?php foreach ($monthOptionValues as $monthOption): ?>
<option value="<?= esc($monthOption['value']) ?>" <?= $monthOption['value'] === $defaultOrderMonth ? 'selected' : '' ?>>
<?= esc($monthOption['label']) ?>
</option>
<?php endforeach; ?>
</select>
<span id="bo_order_month_ko" class="text-sm text-gray-700"></span>
</div>
<p class="text-blue-600 font-bold">※ 발주수량을 박스단위로 떨어지게 입력해 주세요.</p>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2">
<section class="xl:col-span-5 border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 이력</div>
<div class="overflow-auto max-h-[410px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-28">발주일</th>
<th>제작업체</th>
<th>입고처</th>
<th class="w-16">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($recentOrders ?? []) as $history): ?>
<tr>
<td class="text-center">
<a href="<?= base_url('bag/order/revise/' . (int) $history->bo_idx) ?>" class="text-blue-600 hover:underline">
<?= esc((string) $history->bo_order_date) ?>
</a>
</td>
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) $history->bo_company_idx] ?? '-')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($agencyMap[(int) $history->bo_agency_idx] ?? '-')) ?></td>
<td class="text-center"><?= esc((string) ($statusMap[(string) $history->bo_status] ?? $history->bo_status)) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recentOrders)): ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">발주 이력이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="xl:col-span-7 border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700"><?= $editMode ? '발주 변경 수정' : '발주 Form' ?></div>
<div class="p-2 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="flex items-center gap-2 text-sm">
<label for="bo_order_date" class="w-20 font-bold text-gray-700">발주일 <span class="text-red-500">*</span></label>
<input id="bo_order_date" name="bo_order_date" type="date" value="<?= esc($defaultOrderDate) ?>" required class="border border-gray-300 rounded px-2 py-1 w-full" />
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_association_idx" class="w-20 font-bold text-gray-700">협회</label>
<select id="bo_association_idx" name="bo_association_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($associations ?? []) as $association): ?>
<option value="<?= esc((string) $association->cp_idx) ?>" <?= (int) old('bo_association_idx', $editDefaults['bo_association_idx'] ?? 0) === (int) $association->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $association->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_company_idx" class="w-20 font-bold text-gray-700">제작업체</label>
<select id="bo_company_idx" name="bo_company_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= esc((string) $company->cp_idx) ?>" <?= (int) old('bo_company_idx', $editDefaults['bo_company_idx'] ?? 0) === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) $company->cp_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="flex items-center gap-2 text-sm">
<label for="bo_agency_idx" class="w-20 font-bold text-gray-700">입고처</label>
<select id="bo_agency_idx" name="bo_agency_idx" class="border border-gray-300 rounded px-2 py-1 w-full">
<option value="">선택</option>
<?php foreach (($agencies ?? []) as $agency): ?>
<option value="<?= esc((string) $agency->sa_idx) ?>" <?= (int) old('bo_agency_idx', $editDefaults['bo_agency_idx'] ?? 0) === (int) $agency->sa_idx ? 'selected' : '' ?>>
[<?= esc((string) ($agency->sa_kind ?? '')) ?>] <?= esc((string) ($agency->sa_code ?? '')) ?> — <?= esc((string) $agency->sa_name) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="border border-gray-300 overflow-auto">
<table class="w-full data-table text-sm order-input-table" id="order-item-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-16">선택</th>
<th>품명</th>
<th class="w-24">수량</th>
<th class="w-24">단가</th>
<th class="w-24">낱장환산</th>
<?php if ($editMode): ?>
<th class="w-24">선정수량</th>
<th class="w-20">LOT</th>
<?php endif; ?>
<th class="w-28">금액</th>
</tr>
</thead>
<tbody id="selected-order-items-body"></tbody>
<tfoot>
<tr>
<th colspan="3" class="text-center">계</th>
<th class="text-right pr-2" id="sum-box-qty">0</th>
<th></th>
<th class="text-right pr-2" id="sum-sheet-qty">0</th>
<?php if ($editMode): ?>
<th></th>
<th></th>
<?php endif; ?>
<th class="text-right pr-2" id="sum-amount">0</th>
</tr>
</tfoot>
</table>
</div>
<div class="border border-gray-300 bg-gray-50 p-2 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1">
<div class="flex items-center justify-between">
<span class="text-gray-700">품목 금액 합계</span>
<strong id="sum-item-amount">0</strong>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-700">조달수수료</span>
<span class="flex items-center gap-1">
<input id="bo_fee_rate_summary" name="bo_fee_rate" type="number" step="0.01" value="<?= esc(old('bo_fee_rate', $editDefaults['bo_fee_rate'] ?? '0')) ?>" class="border border-gray-300 rounded px-2 py-0.5 w-20 text-right" />
<strong>%</strong>
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-700">조달수수료액</span>
<strong id="sum-fee-amount">0</strong>
</div>
<div class="flex items-center justify-between border-t border-red-300 pt-1">
<span class="font-bold text-gray-800">품목 + 수수료 합계</span>
<strong id="sum-grand-amount" class="text-red-700">0</strong>
</div>
</div>
</div>
<div class="flex gap-2 pt-1">
<button type="submit" class="bg-btn-search text-white px-6 py-1.5 rounded-sm text-sm shadow hover:opacity-90 transition"><?= $editMode ? '변경 저장' : '발주' ?></button>
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm hover:bg-gray-300 transition">취소</a>
</div>
</div>
</section>
</div>
<section class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 등록 종류</div>
<div class="overflow-auto">
<table class="w-full data-table text-sm order-reference-table">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-20">선택</th>
<th>봉투 종류</th>
<th class="w-24">발주단가</th>
<th class="w-24">Box당 팩</th>
<th class="w-24">팩당 낱장</th>
<th class="w-28">1박스 총 낱장</th>
</tr>
</thead>
<tbody>
<?php foreach (($bagReferenceRows ?? []) as $idx => $row): ?>
<tr data-reference-row data-code="<?= esc((string) $row['code']) ?>" class="cursor-pointer">
<td class="text-center"><?= $idx + 1 ?></td>
<td class="text-center">
<button type="button" class="js-toggle-bag border border-gray-300 rounded px-2 py-0.5 text-xs hover:bg-gray-100" data-code="<?= esc((string) $row['code']) ?>">선택</button>
</td>
<td class="text-left pl-2"><?= esc((string) $row['name']) ?></td>
<td class="text-right pr-2"><?= number_format((float) $row['orderPrice'], 2) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['boxPerPack']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['packPerSheet']) ?></td>
<td class="text-right pr-2"><?= number_format((int) $row['totalPerBox']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($bagReferenceRows)): ?>
<tr><td colspan="6" class="text-center text-gray-400 py-4">표시할 봉투 기준 데이터가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</form>
<?php endif; ?>
<?php if (! $deleteMode): ?>
<style>
.order-input-table tbody tr,
.order-reference-table tbody tr {
height: 34px;
}
.order-input-table tbody td,
.order-reference-table tbody td {
padding-top: 4px;
padding-bottom: 4px;
}
</style>
<script>
(() => {
const bagMeta = <?= json_encode($bagMeta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const initialSelectedItems = <?= json_encode($initialSelectedItems, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const selectedBody = document.getElementById('selected-order-items-body');
const referenceRows = Array.from(document.querySelectorAll('[data-reference-row]'));
const sumBoxQtyEl = document.getElementById('sum-box-qty');
const sumSheetQtyEl = document.getElementById('sum-sheet-qty');
const sumAmountEl = document.getElementById('sum-amount');
const sumItemAmountEl = document.getElementById('sum-item-amount');
const sumFeeAmountEl = document.getElementById('sum-fee-amount');
const sumGrandAmountEl = document.getElementById('sum-grand-amount');
const monthInput = document.getElementById('bo_order_month_ui');
const monthKoLabel = document.getElementById('bo_order_month_ko');
const orderDateInput = document.getElementById('bo_order_date');
const feeRateSummaryInput = document.getElementById('bo_fee_rate_summary');
const orderForm = document.querySelector('form[action*="bag/order/store"]');
const selectedItems = new Map();
let activeCode = null;
const editMode = <?= $editMode ? 'true' : 'false' ?>;
const changeMode = <?= json_encode($changeMode, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const orderLotNo = <?= json_encode($orderLotNo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const colEmpty = editMode ? 9 : 7;
const formatNumber = (value) => new Intl.NumberFormat('ko-KR').format(Number.isFinite(value) ? value : 0);
const escapeHtml = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const ensureMonthOption = (monthValue) => {
if (!monthInput || !monthValue) return;
const hasOption = Array.from(monthInput.options || []).some((option) => option.value === monthValue);
if (hasOption) return;
const [year, month] = monthValue.split('-');
const label = `${Number(year)}년 ${Number(month)}월`;
const option = new Option(label, monthValue, false, false);
monthInput.add(option);
};
const syncMonthFromDate = () => {
if (!orderDateInput || !monthInput || !orderDateInput.value) return;
const monthValue = orderDateInput.value.substring(0, 7);
ensureMonthOption(monthValue);
monthInput.value = monthValue;
updateMonthKoLabel();
};
const syncDateFromMonth = () => {
if (!orderDateInput || !monthInput || !monthInput.value) return;
const parts = monthInput.value.split('-');
if (parts.length !== 2) return;
const year = Number(parts[0]);
const month = Number(parts[1]);
if (!Number.isFinite(year) || !Number.isFinite(month)) return;
const currentDay = orderDateInput.value ? Number(orderDateInput.value.split('-')[2]) : 1;
const lastDay = new Date(year, month, 0).getDate();
const day = String(Math.min(Math.max(currentDay, 1), lastDay)).padStart(2, '0');
orderDateInput.value = `${String(year)}-${String(month).padStart(2, '0')}-${day}`;
updateMonthKoLabel();
};
const updateMonthKoLabel = () => {
if (!monthKoLabel || !monthInput || !monthInput.value) {
if (monthKoLabel) monthKoLabel.textContent = '';
return;
}
const [year, month] = monthInput.value.split('-');
if (!year || !month) {
monthKoLabel.textContent = '';
return;
}
monthKoLabel.textContent = `${Number(year)}년 ${Number(month)}월`;
};
const updateReferenceSelectionUi = () => {
referenceRows.forEach((row) => {
const code = row.dataset.code || '';
const button = row.querySelector('.js-toggle-bag');
const isSelected = selectedItems.has(code);
row.classList.toggle('bg-blue-50', isSelected);
if (button) {
button.textContent = isSelected ? '선택됨' : '선택';
button.classList.toggle('bg-blue-600', isSelected);
button.classList.toggle('text-white', isSelected);
button.classList.toggle('border-blue-600', isSelected);
}
});
};
const applyModeLock = () => {
if (!editMode) return;
const isPrice = changeMode === 'price';
selectedBody.querySelectorAll('.item-qty-box').forEach((el) => {
el.readOnly = isPrice;
});
};
const updateTotals = () => {
let sumBoxQty = 0;
let sumSheetQty = 0;
let sumAmount = 0;
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
const code = row.dataset.code || '';
const qtyInput = row.querySelector('.item-qty-box');
const qtyBox = Math.max(0, parseInt(qtyInput?.value || '0', 10));
const meta = bagMeta[code] || { orderPrice: 0, totalPerBox: 1 };
const priceInput = row.querySelector('.item-unit-price-input');
let unitPrice = Number(meta.orderPrice || 0);
if (priceInput) {
unitPrice = Math.max(0, parseFloat(priceInput.value || '0') || 0);
}
const totalPerBox = Math.max(1, Number(meta.totalPerBox || 1));
const qtySheet = qtyBox * totalPerBox;
const amount = qtySheet * unitPrice;
const unitPriceEl = row.querySelector('.item-unit-price');
const qtySheetEl = row.querySelector('.item-qty-sheet');
const selQtyEl = row.querySelector('.item-sel-qty');
const sheetHelpEl = row.querySelector('.item-sheet-help');
const amountEl = row.querySelector('.item-amount');
if (unitPriceEl) unitPriceEl.textContent = formatNumber(unitPrice);
if (qtySheetEl) qtySheetEl.textContent = formatNumber(qtySheet);
if (selQtyEl) selQtyEl.textContent = formatNumber(qtySheet);
if (sheetHelpEl) sheetHelpEl.textContent = `낱장 ${formatNumber(qtySheet)}장`;
if (amountEl) amountEl.textContent = formatNumber(amount);
selectedItems.set(code, { qtyBox });
sumBoxQty += qtyBox;
sumSheetQty += qtySheet;
sumAmount += amount;
});
if (sumBoxQtyEl) sumBoxQtyEl.textContent = formatNumber(sumBoxQty);
if (sumSheetQtyEl) sumSheetQtyEl.textContent = formatNumber(sumSheetQty);
if (sumAmountEl) sumAmountEl.textContent = formatNumber(sumAmount);
if (sumItemAmountEl) sumItemAmountEl.textContent = formatNumber(sumAmount);
const feeRateSource = feeRateSummaryInput?.value ?? '0';
const feeRate = Math.max(0, parseFloat(feeRateSource) || 0);
const feeAmount = Math.round(sumAmount * (feeRate / 100));
const grandAmount = sumAmount + feeAmount;
if (feeRateSummaryInput && feeRateSummaryInput.value !== feeRate.toString()) {
feeRateSummaryInput.value = feeRate.toString();
}
if (sumFeeAmountEl) sumFeeAmountEl.textContent = formatNumber(feeAmount);
if (sumGrandAmountEl) sumGrandAmountEl.textContent = formatNumber(grandAmount);
};
const setActiveRow = (code) => {
activeCode = code || null;
selectedBody.querySelectorAll('tr[data-item-row]').forEach((row) => {
row.classList.toggle('bg-amber-50', row.dataset.code === activeCode);
});
};
const renderSelectedRows = () => {
const codes = Object.keys(bagMeta).filter((code) => selectedItems.has(code));
if (codes.length === 0) {
selectedBody.innerHTML = `<tr><td colspan="${colEmpty}" class="text-center text-gray-400 py-4">아래 "발주 등록 종류"에서 봉투를 선택해 주세요.</td></tr>`;
setActiveRow(null);
updateTotals();
updateReferenceSelectionUi();
applyModeLock();
return;
}
selectedBody.innerHTML = codes.map((code, idx) => {
const meta = bagMeta[code];
const qtyBox = Math.max(0, parseInt(String(selectedItems.get(code)?.qtyBox ?? 0), 10));
const name = meta?.name || code;
const up = Number(meta.orderPrice || 0);
const priceCell = editMode && changeMode === 'price'
? `<td class="text-right pr-1"><input type="number" name="item_unit_price[]" step="0.01" min="0" class="item-unit-price-input border border-gray-300 rounded px-1 py-0.5 w-full text-sm text-right" value="${up}" /></td>`
: `<td class="text-right pr-2 item-unit-price">0</td>`;
const extraCols = editMode
? `<td class="text-right pr-2 item-sel-qty">0</td><td class="text-center text-[11px] item-lot-cell leading-tight text-gray-700">생성<br /><span class="font-mono">${escapeHtml(orderLotNo || '—')}</span></td>`
: '';
return `
<tr data-item-row data-code="${escapeHtml(code)}" class="cursor-pointer">
<td class="text-center">${idx + 1}</td>
<td class="text-center">
<button type="button" class="js-remove-selected text-xs text-red-600 hover:underline" data-code="${escapeHtml(code)}">해제</button>
</td>
<td class="text-left pl-2">
${escapeHtml(name)}
<input type="hidden" name="item_bag_code[]" value="${escapeHtml(code)}" />
</td>
<td>
<input name="item_qty_box[]" type="number" min="0" step="1" value="${qtyBox}" class="item-qty-box border border-gray-300 rounded px-2 py-1 text-sm w-full text-right leading-tight" />
<p class="text-[11px] text-gray-500 mt-1 item-sheet-help">낱장 0장</p>
</td>
${priceCell}
<td class="text-right pr-2 item-qty-sheet">0</td>
${extraCols}
<td class="text-right pr-2 item-amount">0</td>
</tr>
`;
}).join('');
if (!activeCode || !selectedItems.has(activeCode)) {
activeCode = codes[0];
}
setActiveRow(activeCode);
updateTotals();
updateReferenceSelectionUi();
applyModeLock();
};
const toggleSelection = (code) => {
if (!code || !bagMeta[code]) return;
if (selectedItems.has(code)) {
selectedItems.delete(code);
if (activeCode === code) activeCode = null;
} else {
selectedItems.set(code, { qtyBox: 0 });
activeCode = code;
}
renderSelectedRows();
};
initialSelectedItems.forEach((item) => {
if (!item || !item.code || !bagMeta[item.code]) return;
selectedItems.set(item.code, { qtyBox: Math.max(0, parseInt(String(item.qtyBox ?? 0), 10)) });
activeCode = item.code;
});
selectedBody.addEventListener('click', (event) => {
const removeButton = event.target.closest('.js-remove-selected');
if (removeButton) {
toggleSelection(removeButton.dataset.code || '');
return;
}
const row = event.target.closest('tr[data-item-row]');
if (!row) return;
const code = row.dataset.code || '';
setActiveRow(code);
const qtyInput = row.querySelector('.item-qty-box');
if (qtyInput) qtyInput.focus();
});
selectedBody.addEventListener('input', (event) => {
const qtyInput = event.target.closest('.item-qty-box');
const priceField = event.target.closest('.item-unit-price-input');
if (qtyInput) {
const row = qtyInput.closest('tr[data-item-row]');
if (!row) return;
const code = row.dataset.code || '';
selectedItems.set(code, { qtyBox: Math.max(0, parseInt(qtyInput.value || '0', 10)) });
updateTotals();
return;
}
if (priceField) {
updateTotals();
}
});
referenceRows.forEach((row) => {
row.addEventListener('click', (event) => {
const button = event.target.closest('.js-toggle-bag');
if (button) {
toggleSelection(button.dataset.code || '');
return;
}
if (event.target.closest('td')) {
toggleSelection(row.dataset.code || '');
}
});
});
if (monthInput) monthInput.addEventListener('change', () => { syncDateFromMonth(); updateTotals(); });
if (orderDateInput) orderDateInput.addEventListener('change', syncMonthFromDate);
if (feeRateSummaryInput) {
feeRateSummaryInput.addEventListener('input', () => {
updateTotals();
});
}
if (orderForm) {
orderForm.addEventListener('submit', (event) => {
const hasValidItem = Array.from(selectedBody.querySelectorAll('tr[data-item-row]')).some((row) => {
const qtyInput = row.querySelector('.item-qty-box');
return Math.max(0, parseInt(qtyInput?.value || '0', 10)) > 0;
});
if (!hasValidItem) {
event.preventDefault();
alert('봉투를 선택하고 수량을 1 이상 입력해 주세요.');
}
});
}
syncMonthFromDate();
updateMonthKoLabel();
renderSelectedRows();
if (editMode) {
document.querySelectorAll('input[name="bo_change_mode"]').forEach((r) => {
r.addEventListener('change', () => {
const u = new URL(window.location.href);
u.searchParams.set('change_mode', r.value);
window.location.href = u.toString();
});
});
}
})();
</script>
<?php endif; ?>

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

@@ -0,0 +1,396 @@
<?php
$startDate = (string) ($startDate ?? date('Y-m-01'));
$endDate = (string) ($endDate ?? date('Y-m-d'));
$workDate = (string) ($workDate ?? date('Y-m-d'));
$itemCode = (string) ($itemCode ?? '');
$viewType = (string) ($viewType ?? 'box');
$inspectionRuns = is_array($inspectionRuns ?? null) ? $inspectionRuns : [];
$popupItems = is_array($popupItems ?? null) ? $popupItems : [];
$items = is_array($items ?? null) ? $items : [];
$overviewRows = is_array($overviewRows ?? null) ? $overviewRows : [];
$selectedInspectionItemId = (int) ($selectedInspectionItemId ?? 0);
$selectedInspectionId = (int) ($selectedInspectionId ?? 0);
$boxRows = is_array($boxRows ?? null) ? $boxRows : [];
$selectedBoxCode = (string) ($selectedBoxCode ?? '');
$selectedPackCode = (string) ($selectedPackCode ?? '');
$sheetRows = is_array($sheetRows ?? null) ? $sheetRows : [];
$overviewTotalQty = 0;
foreach ($overviewRows as $row) {
$overviewTotalQty += (int) ($row['bisi_system_qty'] ?? 0);
}
$overviewTotalActual = 0;
foreach ($overviewRows as $row) {
$overviewTotalActual += (int) ($row['bisi_actual_qty'] ?? 0);
}
$overviewTotalDiff = $overviewTotalActual - $overviewTotalQty;
$packTotalQty = 0;
foreach ($boxRows as $row) {
$packTotalQty += (int) ($row['bisp_sheet_qty'] ?? 0);
}
$packTotalActual = 0;
foreach ($boxRows as $row) {
$packTotalActual += (int) ($row['bisp_actual_qty'] ?? 0);
}
$packTotalDiff = $packTotalActual - $packTotalQty;
$sheetTotalQty = 0;
foreach ($sheetRows as $row) {
$sheetTotalQty += (int) ($row['biss_system_qty'] ?? 0);
}
$sheetTotalActual = 0;
foreach ($sheetRows as $row) {
$sheetTotalActual += (int) ($row['biss_actual_qty'] ?? 0);
}
$sheetTotalDiff = $sheetTotalActual - $sheetTotalQty;
?>
<div class="space-y-2">
<section class="border border-gray-300 bg-white p-2">
<form method="get" class="flex flex-wrap items-end justify-between gap-2 text-sm">
<div class="flex flex-wrap items-end gap-2">
<label class="font-bold text-gray-700">실사기간</label>
<input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1">
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1">
<label class="font-bold text-gray-700 ml-2">실사품목</label>
<select name="item_code" class="border border-gray-300 rounded px-2 py-1 min-w-[11rem]">
<option value="">전체</option>
<?php foreach ($items as $it): ?>
<?php $code = (string) ($it['bag_code'] ?? ''); ?>
<option value="<?= esc($code) ?>" <?= $itemCode === $code ? 'selected' : '' ?>>
<?= esc((string) ($it['bag_name'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700 ml-2">조회구분</label>
<select name="view_type" class="border border-gray-300 rounded px-2 py-1 min-w-[7rem]">
<option value="box" <?= $viewType === 'box' ? 'selected' : '' ?>>박스별</option>
<option value="pack" <?= $viewType === 'pack' ? 'selected' : '' ?>>팩별</option>
</select>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm">조회</button>
<button type="button" id="open-inspection-popup" class="border border-blue-300 text-blue-700 px-3 py-1 rounded-sm hover:bg-blue-50">실사 선별</button>
</div>
</form>
<p class="mt-1 text-xs text-blue-700">※ 해당 박스와 팩을 클릭하면 팩과 낱장이 조회됩니다.</p>
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-2">
<div class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">실사 선별자</div>
<div class="overflow-auto max-h-[500px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-24">실사일자</th>
<th>종류</th>
<th class="w-40">박스</th>
<th class="w-20">전산재고</th>
<th class="w-20">실사재고</th>
</tr>
</thead>
<tbody>
<?php if ($overviewRows !== []): ?>
<?php $prevBagName = null; ?>
<?php foreach ($overviewRows as $row): ?>
<?php
$itemId = (int) ($row['bisi_idx'] ?? 0);
$bagName = (string) ($row['bisi_bag_name'] ?? '');
$showBagName = $prevBagName !== $bagName;
$prevBagName = $bagName;
$boxCode = (string) ($row['box_code'] ?? '');
$isSelected = $itemId === $selectedInspectionItemId
&& ($selectedBoxCode === '' || $selectedBoxCode === $boxCode);
$url = base_url('bag/inventory/inspection-work?' . http_build_query([
'start_date' => $startDate,
'end_date' => $endDate,
'bis_id' => $selectedInspectionId,
'item_code' => $itemCode,
'view_type' => $viewType,
'sel_item_id' => $itemId,
'sel_box_code' => $boxCode,
'sel_pack_code' => '',
]));
?>
<tr class="<?= $isSelected ? 'bg-blue-100' : 'cursor-pointer hover:bg-blue-50' ?>" onclick="window.location.href='<?= esc($url, 'attr') ?>'">
<td class="text-center"><?= esc((string) ($row['bis_work_date'] ?? '')) ?></td>
<td class="pl-2"><?= $showBagName ? esc($bagName) : '' ?></td>
<td class="text-center"><?= esc((string) ($row['box_code'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['bisi_system_qty'] ?? 0)) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['bisi_actual_qty'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">조회 결과가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-semibold">
<td class="text-center" colspan="3">합계</td>
<td class="text-right pr-2"><?= number_format($overviewTotalQty) ?></td>
<td class="text-right pr-2"><?= number_format($overviewTotalActual) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="space-y-2">
<div class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">실사 선별 품목(선택 박스 조회)</div>
<form method="post" action="<?= base_url('bag/inventory/inspection-select/save') ?>" id="inspection-save-form">
<?= csrf_field() ?>
<input type="hidden" name="bisi_idx" value="<?= esc((string) $selectedInspectionItemId) ?>">
<input type="hidden" name="bis_id" value="<?= esc((string) $selectedInspectionId) ?>">
<input type="hidden" name="start_date" value="<?= esc($startDate) ?>">
<input type="hidden" name="end_date" value="<?= esc($endDate) ?>">
<input type="hidden" name="item_code" value="<?= esc($itemCode) ?>">
<input type="hidden" name="view_type" value="<?= esc($viewType) ?>">
<input type="hidden" name="sel_item_id" value="<?= esc((string) $selectedInspectionItemId) ?>">
<input type="hidden" name="sel_box_code" value="<?= esc($selectedBoxCode) ?>">
<input type="hidden" name="sel_pack_code" value="<?= esc($selectedPackCode) ?>">
<input type="hidden" name="pack_actual_json" value="">
<div class="overflow-auto max-h-[260px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>팩코드</th>
<th class="w-16">포장량</th>
<th class="w-16">재고</th>
<th class="w-20">실사재고</th>
<th class="w-16">차이</th>
<th>낱장(시작)</th>
<th>낱장(끝)</th>
</tr>
</thead>
<tbody>
<?php if ($boxRows !== []): ?>
<?php foreach ($boxRows as $row): ?>
<?php
$code = (string) ($row['bisp_box_code'] ?? '');
$packCode = (string) ($row['bisp_pack_code'] ?? '');
$systemQty = (int) ($row['bisp_sheet_qty'] ?? 0);
$actualQty = isset($row['bisp_actual_qty']) ? (int) $row['bisp_actual_qty'] : $systemQty;
$url = base_url('bag/inventory/inspection-work?' . http_build_query([
'start_date' => $startDate,
'end_date' => $endDate,
'bis_id' => $selectedInspectionId,
'item_code' => $itemCode,
'view_type' => $viewType,
'sel_item_id' => $selectedInspectionItemId,
'sel_box_code' => $code,
'sel_pack_code' => $packCode,
]));
$isSelected = $selectedBoxCode === $code && $selectedPackCode === $packCode;
?>
<tr class="<?= $isSelected ? 'bg-blue-100' : 'cursor-pointer hover:bg-blue-50' ?>" onclick="window.location.href='<?= esc($url, 'attr') ?>'">
<td class="pl-2"><?= esc($packCode) ?></td>
<td class="text-center js-pack-system"><?= number_format($systemQty) ?></td>
<td class="text-right pr-2 js-pack-stock"><?= number_format($systemQty) ?></td>
<td class="text-right pr-2">
<input type="number" min="0"
value="<?= esc((string) $actualQty) ?>"
data-pack-idx="<?= esc((string) ($row['bisp_idx'] ?? 0), 'attr') ?>"
data-original-value="<?= esc((string) $systemQty, 'attr') ?>"
data-system-qty="<?= esc((string) $systemQty, 'attr') ?>"
class="border border-gray-300 rounded px-1 py-0.5 w-20 text-right"
onclick="event.stopPropagation();">
</td>
<?php $diff = (int) ($row['bisp_diff_qty'] ?? 0); ?>
<td class="text-right pr-2 js-pack-diff <?= $diff === 0 ? '' : ($diff > 0 ? 'text-blue-700' : 'text-red-700') ?>"><?= number_format($diff) ?></td>
<td class="text-center"><?= esc((string) ($row['bisp_sheet_start_code'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['bisp_sheet_end_code'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="7" class="text-center text-gray-400 py-4">선택된 품목이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-semibold">
<td class="text-center" colspan="2">합계</td>
<td class="text-right pr-2"><?= number_format($packTotalQty) ?></td>
<td class="text-right pr-2"><?= number_format($packTotalActual) ?></td>
<td class="text-right pr-2 <?= $packTotalDiff === 0 ? '' : ($packTotalDiff > 0 ? 'text-blue-700' : 'text-red-700') ?>"><?= number_format($packTotalDiff) ?></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
<div class="p-2 border-t border-gray-300 flex items-center justify-between">
<span class="text-xs text-gray-500">선택 품목의 팩 실사수량을 입력 후 저장하세요.</span>
<div class="flex items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm text-sm">실사 저장</button>
</div>
</div>
</form>
</div>
<div class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">실사 선별 내용(선택 낱장 코드 조회)</div>
<div class="overflow-auto max-h-[240px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12">No</th>
<th>낱장</th>
<th class="w-16">전산</th>
<th class="w-16">실사</th>
<th class="w-16">차이</th>
</tr>
</thead>
<tbody>
<?php if ($sheetRows !== []): ?>
<?php foreach ($sheetRows as $row): ?>
<?php
$sSystem = (int) ($row['biss_system_qty'] ?? 1);
$sActual = (int) ($row['biss_actual_qty'] ?? 0);
$sDiff = (int) ($row['biss_diff_qty'] ?? 0);
?>
<tr>
<td class="text-center"><?= esc((string) ($row['no'] ?? '')) ?></td>
<td class="pl-2"><?= esc((string) ($row['biss_sheet_code'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format($sSystem) ?></td>
<td class="text-right pr-2"><?= number_format($sActual) ?></td>
<td class="text-right pr-2 <?= $sDiff === 0 ? '' : ($sDiff > 0 ? 'text-blue-700' : 'text-red-700') ?>"><?= number_format($sDiff) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">선택된 팩/박스가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-semibold">
<td class="text-center" colspan="2">합계</td>
<td class="text-right pr-2"><?= number_format($sheetTotalQty) ?></td>
<td class="text-right pr-2"><?= number_format($sheetTotalActual) ?></td>
<td class="text-right pr-2 <?= $sheetTotalDiff === 0 ? '' : ($sheetTotalDiff > 0 ? 'text-blue-700' : 'text-red-700') ?>"><?= number_format($sheetTotalDiff) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="border border-gray-300 bg-white p-2 text-sm text-gray-600">
실사 저장 시 차이수량이 즉시 장부 재고에 반영됩니다.
</div>
</div>
</section>
</div>
<div id="inspection-popup" class="fixed inset-0 bg-black/40 hidden items-center justify-center z-[999]">
<div class="bg-white border border-gray-400 w-[min(720px,95vw)] max-h-[90vh] overflow-auto p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold">실사 선별</h3>
<button type="button" id="close-inspection-popup" class="text-gray-600 hover:text-gray-900">닫기</button>
</div>
<form method="post" action="<?= base_url('bag/inventory/inspection-run') ?>" id="inspection-run-form" class="space-y-3">
<?= csrf_field() ?>
<div class="flex items-center gap-2">
<label class="font-bold text-gray-700 text-sm">작업 일자</label>
<input type="date" name="work_date" value="<?= esc($workDate) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm">
</div>
<p class="text-red-600 font-semibold">바코드가 없는 봉투는 실사에서 제외 됩니다.</p>
<div class="overflow-auto border border-gray-300 max-h-[55vh]">
<table class="w-full data-table text-sm">
<thead>
<tr><th>종류</th><th class="w-20">선택구분</th></tr>
</thead>
<tbody>
<?php foreach ($popupItems as $row): ?>
<?php $hasBarcode = (bool) ($row['has_barcode'] ?? false); ?>
<tr class="<?= $hasBarcode ? '' : 'bg-gray-50 text-gray-400' ?>">
<td class="pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-center">
<input type="checkbox" name="bag_codes[]" value="<?= esc((string) ($row['bag_code'] ?? ''), 'attr') ?>" <?= $hasBarcode ? '' : 'disabled' ?>>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="flex justify-end gap-2">
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm">실행</button>
</div>
</form>
</div>
</div>
<script>
(() => {
const popup = document.getElementById('inspection-popup');
const openBtn = document.getElementById('open-inspection-popup');
const closeBtn = document.getElementById('close-inspection-popup');
if (openBtn && popup) {
openBtn.addEventListener('click', () => {
popup.classList.remove('hidden');
popup.classList.add('flex');
});
}
if (closeBtn && popup) {
closeBtn.addEventListener('click', () => {
popup.classList.add('hidden');
popup.classList.remove('flex');
});
}
const form = document.getElementById('inspection-run-form');
if (form) {
form.addEventListener('submit', (event) => {
if (!window.confirm('전산 선별 처리를 실행하시겠습니까?')) {
event.preventDefault();
}
});
}
const saveForm = document.getElementById('inspection-save-form');
if (saveForm) {
const formatNumber = (value) => {
const n = Number.isFinite(value) ? value : 0;
return n.toLocaleString('ko-KR');
};
const updateDiff = (input) => {
const row = input.closest('tr');
if (!row) {
return;
}
const system = parseInt(String(input.getAttribute('data-system-qty') ?? '0'), 10) || 0;
const actual = Math.max(0, parseInt(String(input.value ?? '0'), 10) || 0);
const diff = actual - system;
const diffCell = row.querySelector('.js-pack-diff');
if (!diffCell) {
return;
}
diffCell.textContent = formatNumber(diff);
diffCell.classList.remove('text-blue-700', 'text-red-700');
if (diff > 0) {
diffCell.classList.add('text-blue-700');
} else if (diff < 0) {
diffCell.classList.add('text-red-700');
}
};
const qtyInputs = saveForm.querySelectorAll('input[data-pack-idx]');
qtyInputs.forEach((input) => {
input.addEventListener('input', () => updateDiff(input));
updateDiff(input);
});
saveForm.addEventListener('submit', () => {
const payload = {};
qtyInputs.forEach((input) => {
const idx = String(input.getAttribute('data-pack-idx') ?? '').trim();
if (idx === '') {
return;
}
payload[idx] = Math.max(0, parseInt(String(input.value ?? '0'), 10) || 0);
});
const jsonInput = saveForm.querySelector('input[name="pack_actual_json"]');
if (jsonInput) {
jsonInput.value = JSON.stringify(payload);
}
});
}
})();
</script>

View File

@@ -0,0 +1,273 @@
<?php
$startDate = (string) ($startDate ?? date('Y-m-01'));
$endDate = (string) ($endDate ?? date('Y-m-d'));
$workDate = (string) ($workDate ?? date('Y-m-d'));
$itemCode = (string) ($itemCode ?? '');
$viewType = (string) ($viewType ?? 'box');
$inspectionRuns = is_array($inspectionRuns ?? null) ? $inspectionRuns : [];
$items = is_array($items ?? null) ? $items : [];
$popupItems = is_array($popupItems ?? null) ? $popupItems : [];
$overviewRows = is_array($overviewRows ?? null) ? $overviewRows : [];
$boxRows = is_array($boxRows ?? null) ? $boxRows : [];
$sheetRows = is_array($sheetRows ?? null) ? $sheetRows : [];
$selectedInspectionId = (int) ($selectedInspectionId ?? 0);
$selectedInspectionItemId = (int) ($selectedInspectionItemId ?? 0);
$selectedBoxCode = (string) ($selectedBoxCode ?? '');
$selectedPackCode = (string) ($selectedPackCode ?? '');
$overviewTotalQty = 0;
foreach ($overviewRows as $row) {
$overviewTotalQty += (int) ($row['bisi_system_qty'] ?? 0);
}
$packTotalQty = 0;
foreach ($boxRows as $row) {
$packTotalQty += (int) ($row['bisp_sheet_qty'] ?? 0);
}
$sheetTotalQty = 0;
foreach ($sheetRows as $row) {
$sheetTotalQty += (int) ($row['biss_system_qty'] ?? 0);
}
?>
<div class="space-y-2">
<section class="border border-gray-300 bg-white p-2">
<form method="get" class="flex flex-wrap items-end justify-between gap-2 text-sm">
<div class="flex flex-wrap items-end gap-2">
<label class="font-bold text-gray-700">실사기간</label>
<input type="date" name="start_date" value="<?= esc($startDate) ?>" class="border border-gray-300 rounded px-2 py-1">
<span>~</span>
<input type="date" name="end_date" value="<?= esc($endDate) ?>" class="border border-gray-300 rounded px-2 py-1">
<label class="font-bold text-gray-700 ml-2">실사품목</label>
<select name="item_code" class="border border-gray-300 rounded px-2 py-1 min-w-[11rem]">
<option value="">전체</option>
<?php foreach ($items as $it): ?>
<?php $code = (string) ($it['bag_code'] ?? ''); ?>
<option value="<?= esc($code) ?>" <?= $itemCode === $code ? 'selected' : '' ?>><?= esc((string) ($it['bag_name'] ?? '')) ?></option>
<?php endforeach; ?>
</select>
<label class="font-bold text-gray-700 ml-2">조회구분</label>
<select name="view_type" class="border border-gray-300 rounded px-2 py-1 min-w-[7rem]">
<option value="box" <?= $viewType === 'box' ? 'selected' : '' ?>>박스별</option>
<option value="pack" <?= $viewType === 'pack' ? 'selected' : '' ?>>팩별</option>
</select>
</div>
<div class="flex items-center gap-2">
<button type="submit" class="bg-btn-search text-white px-3 py-1 rounded-sm">조회</button>
<button type="button" id="open-inspection-popup" class="border border-blue-300 text-blue-700 px-3 py-1 rounded-sm hover:bg-blue-50">실사 선별</button>
</div>
</form>
<p class="mt-1 text-xs text-blue-700">※ 실사 선별 처리 결과를 조회하는 화면입니다(읽기 전용).</p>
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-2">
<div class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">실사 선별자</div>
<div class="overflow-auto max-h-[500px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-24">실사일자</th>
<th>종류</th>
<th class="w-40">박스</th>
<th class="w-20">전산재고</th>
</tr>
</thead>
<tbody>
<?php if ($overviewRows !== []): ?>
<?php $prevBagName = null; ?>
<?php foreach ($overviewRows as $row): ?>
<?php
$itemId = (int) ($row['bisi_idx'] ?? 0);
$bagName = (string) ($row['bisi_bag_name'] ?? '');
$showBagName = $prevBagName !== $bagName;
$prevBagName = $bagName;
$isSelected = $itemId === $selectedInspectionItemId;
$url = base_url('bag/inventory/inspection-select?' . http_build_query([
'start_date' => $startDate,
'end_date' => $endDate,
'bis_id' => $selectedInspectionId,
'item_code' => $itemCode,
'view_type' => $viewType,
'sel_item_id' => $itemId,
]));
?>
<tr class="<?= $isSelected ? 'bg-blue-100' : 'cursor-pointer hover:bg-blue-50' ?>" onclick="window.location.href='<?= esc($url, 'attr') ?>'">
<td class="text-center"><?= esc((string) ($row['bis_work_date'] ?? '')) ?></td>
<td class="pl-2"><?= $showBagName ? esc($bagName) : '' ?></td>
<td class="text-center"><?= esc((string) ($row['box_code'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['bisi_system_qty'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="4" class="text-center text-gray-400 py-4">조회 결과가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-semibold">
<td class="text-center" colspan="3">합계</td>
<td class="text-right pr-2"><?= number_format($overviewTotalQty) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="space-y-2">
<div class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">실사 선별 품목(읽기 전용)</div>
<div class="overflow-auto max-h-[260px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>팩코드</th>
<th class="w-16">포장량</th>
<th class="w-16">재고</th>
<th>낱장(시작)</th>
<th>낱장(끝)</th>
</tr>
</thead>
<tbody>
<?php if ($boxRows !== []): ?>
<?php foreach ($boxRows as $row): ?>
<?php
$code = (string) ($row['bisp_box_code'] ?? '');
$packCode = (string) ($row['bisp_pack_code'] ?? '');
$systemQty = (int) ($row['bisp_sheet_qty'] ?? 0);
$url = base_url('bag/inventory/inspection-select?' . http_build_query([
'start_date' => $startDate,
'end_date' => $endDate,
'bis_id' => $selectedInspectionId,
'item_code' => $itemCode,
'view_type' => $viewType,
'sel_item_id' => $selectedInspectionItemId,
'sel_box_code' => $code,
'sel_pack_code' => $packCode,
]));
$isSelected = $selectedBoxCode === $code && $selectedPackCode === $packCode;
?>
<tr class="<?= $isSelected ? 'bg-blue-100' : 'cursor-pointer hover:bg-blue-50' ?>" onclick="window.location.href='<?= esc($url, 'attr') ?>'">
<td class="pl-2"><?= esc($packCode) ?></td>
<td class="text-center"><?= number_format($systemQty) ?></td>
<td class="text-right pr-2"><?= number_format($systemQty) ?></td>
<td class="text-center"><?= esc((string) ($row['bisp_sheet_start_code'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['bisp_sheet_end_code'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="5" class="text-center text-gray-400 py-4">선택된 품목이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-semibold">
<td class="text-center" colspan="2">합계</td>
<td class="text-right pr-2"><?= number_format($packTotalQty) ?></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">실사 선별 내용(읽기 전용)</div>
<div class="overflow-auto max-h-[240px]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12">No</th>
<th>낱장</th>
<th class="w-16">수량</th>
</tr>
</thead>
<tbody>
<?php if ($sheetRows !== []): ?>
<?php foreach ($sheetRows as $row): ?>
<tr>
<td class="text-center"><?= esc((string) ($row['no'] ?? '')) ?></td>
<td class="pl-2"><?= esc((string) ($row['biss_sheet_code'] ?? '')) ?></td>
<td class="text-right pr-2"><?= number_format((int) ($row['biss_system_qty'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="3" class="text-center text-gray-400 py-4">선택된 팩/박스가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-semibold">
<td class="text-center" colspan="2">합계</td>
<td class="text-right pr-2"><?= number_format($sheetTotalQty) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</section>
</div>
<div id="inspection-popup" class="fixed inset-0 bg-black/40 hidden items-center justify-center z-[999]">
<div class="bg-white border border-gray-400 w-[min(720px,95vw)] max-h-[90vh] overflow-auto p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-lg font-bold">실사 선별</h3>
<button type="button" id="close-inspection-popup" class="text-gray-600 hover:text-gray-900">닫기</button>
</div>
<form method="post" action="<?= base_url('bag/inventory/inspection-run') ?>" id="inspection-run-form" class="space-y-3">
<?= csrf_field() ?>
<div class="flex items-center gap-2">
<label class="font-bold text-gray-700 text-sm">작업 일자</label>
<input type="date" name="work_date" value="<?= esc($workDate) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm">
</div>
<p class="text-red-600 font-semibold">바코드가 없는 봉투는 실사에서 제외 됩니다.</p>
<div class="overflow-auto border border-gray-300 max-h-[55vh]">
<table class="w-full data-table text-sm">
<thead>
<tr><th>종류</th><th class="w-20">선택구분</th></tr>
</thead>
<tbody>
<?php foreach ($popupItems as $row): ?>
<?php $hasBarcode = (bool) ($row['has_barcode'] ?? false); ?>
<tr class="<?= $hasBarcode ? '' : 'bg-gray-50 text-gray-400' ?>">
<td class="pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-center">
<input type="checkbox" name="bag_codes[]" value="<?= esc((string) ($row['bag_code'] ?? ''), 'attr') ?>" <?= $hasBarcode ? '' : 'disabled' ?>>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="flex justify-end gap-2">
<button type="submit" class="bg-btn-search text-white px-5 py-1.5 rounded-sm text-sm">실행</button>
</div>
</form>
</div>
</div>
<script>
(() => {
const popup = document.getElementById('inspection-popup');
const openBtn = document.getElementById('open-inspection-popup');
const closeBtn = document.getElementById('close-inspection-popup');
if (openBtn && popup) {
openBtn.addEventListener('click', () => {
popup.classList.remove('hidden');
popup.classList.add('flex');
});
}
if (closeBtn && popup) {
closeBtn.addEventListener('click', () => {
popup.classList.add('hidden');
popup.classList.remove('flex');
});
}
const form = document.getElementById('inspection-run-form');
if (form) {
form.addEventListener('submit', (event) => {
if (!window.confirm('전산 선별 처리를 실행하시겠습니까?')) {
event.preventDefault();
}
});
}
})();
</script>

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,106 @@
<?php
$month = $month ?? date('Y-m');
$hubMode = in_array(($hubMode ?? 'meta'), ['price', 'meta', 'delete'], true) ? (string) ($hubMode ?? 'meta') : 'meta';
$monthOptionValues = [];
$y = (int) substr($month, 0, 4);
for ($year = $y - 2; $year <= $y + 2; $year++) {
for ($m = 1; $m <= 12; $m++) {
$monthValue = sprintf('%04d-%02d', $year, $m);
$monthOptionValues[] = ['value' => $monthValue, 'label' => $year . '년 ' . $m . '월'];
}
}
$statusMap = ['normal' => '정상', 'cancelled' => '취소', 'deleted' => '삭제'];
?>
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">발주 변경</span>
<a href="<?= base_url('bag/purchase-inbound') ?>" class="text-sm text-gray-600 hover:underline">발주 입고 관리로</a>
</section>
<div class="mt-2 space-y-3">
<?php if (session()->getFlashdata('success')): ?>
<div class="border border-green-300 bg-green-50 text-green-800 px-3 py-2 text-sm"><?= esc((string) session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="border border-red-300 bg-red-50 text-red-800 px-3 py-2 text-sm"><?= esc((string) session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<form method="get" action="<?= base_url('bag/order/change') ?>" class="border border-gray-300 bg-white p-3 flex flex-wrap items-end gap-4">
<div class="flex items-center gap-2">
<label for="hub_month" class="text-sm font-bold text-gray-700">발주월</label>
<select id="hub_month" name="month" class="border border-gray-300 rounded px-2 py-1 w-44 text-sm">
<?php foreach ($monthOptionValues as $opt): ?>
<option value="<?= esc($opt['value']) ?>" <?= $opt['value'] === $month ? 'selected' : '' ?>><?= esc($opt['label']) ?></option>
<?php endforeach; ?>
</select>
</div>
<fieldset class="border border-gray-200 rounded px-3 py-2">
<legend class="text-xs font-bold text-gray-600 px-1">변경 구분</legend>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="hub_mode" value="price" <?= $hubMode === 'price' ? 'checked' : '' ?> />
<span>발주·도매·판매 단가</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="hub_mode" value="meta" <?= $hubMode === 'meta' ? 'checked' : '' ?> />
<span>업체·수수료·협회·발주</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="hub_mode" value="delete" <?= $hubMode === 'delete' ? 'checked' : '' ?> />
<span>발주 삭제</span>
</label>
</div>
</fieldset>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shadow hover:opacity-90">조회</button>
</form>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2">
<section class="lg:col-span-5 border border-gray-300 bg-white">
<div class="border-b border-gray-300 bg-gray-50 px-2 py-1 text-sm font-bold text-gray-700">발주 목록 (발주일 · 제작업체)</div>
<div class="overflow-auto max-h-[min(420px,60vh)]">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-28">발주일</th>
<th>제작업체</th>
<th class="w-20">상태</th>
</tr>
</thead>
<tbody>
<?php foreach (($monthOrders ?? []) as $row): ?>
<?php
$canEdit = ((string) ($row->bo_status ?? '')) === 'normal';
$href = base_url('bag/order/revise/' . (int) $row->bo_idx . '?change_mode=' . rawurlencode($hubMode));
?>
<tr class="<?= $canEdit ? '' : 'opacity-70' ?>">
<td class="text-center">
<?php if ($canEdit): ?>
<a href="<?= esc($href) ?>" class="text-blue-600 hover:underline font-medium"><?= esc((string) ($row->bo_order_date ?? '')) ?></a>
<?php else: ?>
<span><?= esc((string) ($row->bo_order_date ?? '')) ?></span>
<?php endif; ?>
</td>
<td class="text-left pl-2"><?= esc((string) ($companyMap[(int) ($row->bo_company_idx ?? 0)] ?? '-')) ?></td>
<td class="text-center text-xs"><?= esc((string) ($statusMap[(string) ($row->bo_status ?? '')] ?? $row->bo_status)) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($monthOrders)): ?>
<tr><td colspan="3" class="text-center text-gray-400 py-6">해당 월 발주가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<section class="lg:col-span-7 border border-dashed border-gray-300 bg-gray-50 p-4 text-sm text-gray-600">
<p class="font-bold text-gray-800 mb-2">안내</p>
<ul class="list-disc pl-5 space-y-1">
<li><strong class="text-gray-900">발주 삭제</strong>를 선택하고 <strong class="text-gray-900">조회</strong>하면 발주 삭제 화면으로 이동합니다 (해당 월에 정상 발주가 있을 때).</li>
<li>그 외 변경 구분에서는 왼쪽 목록에서 <strong class="text-gray-900">발주일</strong>을 클릭하면 선택한 <strong class="text-gray-900">변경 구분</strong>으로 발주 변경 화면이 열립니다.</li>
<li><strong class="text-gray-900">발주·도매·판매 단가</strong>: 품목별 단가를 수정해 저장합니다 (발주 변경 시에만).</li>
<li><strong class="text-gray-900">업체·수수료·협회·발주</strong>: 발주일·입고처·수량 등을 수정해 저장합니다.</li>
<li><strong class="text-gray-900">발주 삭제</strong> 화면에서 목록을 선택해 삭제 처리합니다 (복구 불가에 가깝게 동작하므로 확인 후 진행하세요).</li>
</ul>
</section>
</div>
</div>

View File

@@ -58,7 +58,11 @@
<section class="mt-4"> <section class="mt-4">
<div class="flex items-center justify-between mb-2 border-b pb-1"> <div class="flex items-center justify-between 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>
<a href="<?= base_url('bag/receiving/create') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">입고 처리</a> <div class="flex items-center gap-2">
<a href="<?= base_url('bag/receiving/scanner') ?>" class="bg-btn-search text-white px-3 py-1.5 rounded-sm text-sm">입고 처리</a>
<a href="<?= base_url('bag/receiving/batch') ?>" class="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">일괄 입고</a>
<a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1.5 rounded-sm text-sm hover:bg-gray-50">입고 현황 리포트</a>
</div>
</div> </div>
<table class="data-table"> <table class="data-table">
<thead><tr> <thead><tr>

View File

@@ -0,0 +1,119 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">일괄 입고</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('bag/receiving/scanner') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">스캐너 입고</a>
<a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a>
</div>
</section>
<?php if (session()->getFlashdata('success')): ?>
<div class="mt-2 border border-green-300 bg-green-50 text-green-800 px-3 py-2 text-sm"><?= esc((string) session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mt-2 border border-red-300 bg-red-50 text-red-800 px-3 py-2 text-sm"><?= esc((string) session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<section class="p-2 bg-white border border-gray-300 mt-2">
<form method="get" action="<?= base_url('bag/receiving/batch') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">제작 업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-56">
<option value="0">전 체</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= (int) $company->cp_idx ?>" <?= (int) ($companyIdx ?? 0) === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) ($company->cp_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
</form>
</section>
<form action="<?= base_url('bag/receiving/batch/store') ?>" method="post" class="mt-2 space-y-2">
<?= csrf_field() ?>
<input type="hidden" name="company_idx" value="<?= (int) ($companyIdx ?? 0) ?>" />
<section class="p-2 bg-white border border-gray-300">
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-end">
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인수자 (대행소)</label>
<select name="br_receiver_ref" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required>
<?php foreach (($receiverOptions ?? []) as $opt): ?>
<option value="<?= esc((string) ($opt['ref'] ?? '')) ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>>
<?= esc((string) ($opt['label'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인계자 (제작업체)</label>
<select name="br_sender_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-full">
<?php foreach (($senders ?? []) as $sender): ?>
<option value="<?= (int) ($sender->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($sender->mg_idx ?? 0) ? 'selected' : '' ?>>
<?= esc((string) ($sender->mg_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-2 flex items-center gap-2">
<label class="text-sm text-gray-600 w-16">입고일</label>
<input type="date" name="br_receive_date" value="<?= esc((string) old('br_receive_date', date('Y-m-d'))) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required />
</div>
<div class="xl:col-span-2">
<button type="submit" class="w-full border border-blue-600 text-blue-700 px-2 py-1 rounded-sm text-sm hover:bg-blue-50">입고 처리</button>
</div>
<div class="xl:col-span-2 text-xs text-gray-500">체크한 LOT-봉투 행의 미입고량을 전부 입고 처리합니다.</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto bg-white">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th class="w-12"><input type="checkbox" id="check-all" /></th>
<th>발주일자</th>
<th>봉투종류</th>
<th>발주량(매)</th>
<th>미입고량(매)</th>
<th>제작업체</th>
<th>LOT NO</th>
<th>발주NO</th>
</tr>
</thead>
<tbody>
<?php foreach (($rows ?? []) as $row): ?>
<tr>
<td class="text-center">
<input type="checkbox" name="selected_rows[]" value="<?= esc((string) ($row['row_key'] ?? '')) ?>" />
</td>
<td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-right"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
<td class="text-right text-blue-700 font-semibold"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['order_no'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows ?? [])): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4">일괄 입고 가능한 미입고 행이 없습니다.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="flex items-center gap-2">
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm">취소</a>
</div>
</form>
<script>
(() => {
const all = document.getElementById('check-all');
if (!all) return;
all.addEventListener('change', () => {
document.querySelectorAll('input[name="selected_rows[]"]').forEach((el) => {
el.checked = all.checked;
});
});
})();
</script>

View File

@@ -0,0 +1,153 @@
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-bold text-gray-700">발주 입고(스캐너 대체 수동입력)</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('bag/receiving/batch') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">일괄 입고</a>
<a href="<?= base_url('bag/receiving/status') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 현황</a>
</div>
</section>
<?php if (session()->getFlashdata('success')): ?>
<div class="mt-2 border border-green-300 bg-green-50 text-green-800 px-3 py-2 text-sm"><?= esc((string) session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="mt-2 border border-red-300 bg-red-50 text-red-800 px-3 py-2 text-sm"><?= esc((string) session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<section class="p-2 bg-white border border-gray-300 mt-2">
<form method="get" action="<?= base_url('bag/receiving/scanner') ?>" class="flex flex-wrap items-end gap-2">
<div class="flex flex-col min-w-[14rem] max-w-[22rem]">
<label class="text-xs text-gray-500 mb-1">제작업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1.5 text-sm bg-white">
<option value="0">제작업체 선택</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= (int) ($company->cp_idx ?? 0) ?>" <?= (int) ($companyIdx ?? 0) === (int) ($company->cp_idx ?? 0) ? 'selected' : '' ?>>
<?= esc((string) ($company->cp_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded-sm text-sm shrink-0">조회</button>
</form>
<?php if ((int) ($companyIdx ?? 0) <= 0): ?>
<p class="text-xs text-gray-600 mt-2">제작업체를 선택하면 해당 업체 발주 중 미입고 내역을 조회합니다.</p>
<?php elseif (empty($rows ?? [])): ?>
<p class="text-xs text-amber-700 mt-2">미입고 잔량이 있는 발주가 없습니다. 발주 등록 후 다시 확인해 주세요.</p>
<?php endif; ?>
</section>
<form action="<?= base_url('bag/receiving/scanner/store') ?>" method="post" class="mt-2 space-y-2">
<?= csrf_field() ?>
<input type="hidden" name="company_idx" value="<?= (int) ($companyIdx ?? 0) ?>" />
<section class="p-2 bg-white border border-gray-300">
<div class="grid grid-cols-1 xl:grid-cols-12 gap-2 items-end">
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인수자 (대행소)</label>
<select name="br_receiver_ref" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required>
<?php foreach (($receiverOptions ?? []) as $opt): ?>
<option value="<?= esc((string) ($opt['ref'] ?? '')) ?>" <?= (string) ($receiverRef ?? '') === (string) ($opt['ref'] ?? '') ? 'selected' : '' ?>>
<?= esc((string) ($opt['label'] ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-3 flex items-center gap-2">
<label class="text-sm text-gray-600 shrink-0 w-28">인계자 (제작업체)</label>
<select name="br_sender_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-full">
<?php foreach (($senders ?? []) as $sender): ?>
<option value="<?= (int) ($sender->mg_idx ?? 0) ?>" <?= (int) ($senderIdx ?? 0) === (int) ($sender->mg_idx ?? 0) ? 'selected' : '' ?>>
<?= esc((string) ($sender->mg_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="xl:col-span-2 flex items-center gap-2">
<label class="text-sm text-gray-600 w-16">입고일</label>
<input type="date" name="br_receive_date" value="<?= esc((string) old('br_receive_date', date('Y-m-d'))) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm w-full" required />
</div>
<div class="xl:col-span-2">
<button type="submit" class="w-full border border-blue-600 text-blue-700 px-2 py-1 rounded-sm text-sm hover:bg-blue-50">입고 처리</button>
</div>
<div class="xl:col-span-2 text-xs text-gray-500">상단에서 제작업체를 조회한 뒤, 아래에서 입고량(매)을 입력해 저장합니다.</div>
</div>
</section>
<div class="border border-gray-300 overflow-auto bg-white">
<table class="w-full data-table text-sm">
<thead>
<tr>
<th>발주일자</th>
<th>봉투종류</th>
<th>발주량(매)</th>
<th>미입고량(매)</th>
<th>입고량(매)</th>
<th>제작업체</th>
<th>LOT NO</th>
<th>발주NO</th>
</tr>
</thead>
<tbody>
<?php foreach (($rows ?? []) as $row): ?>
<?php $k = (string) ($row['row_key'] ?? ''); ?>
<tr data-row-key="<?= esc($k) ?>" data-lot-no="<?= esc((string) ($row['lot_no'] ?? '')) ?>" data-bag-code="<?= esc((string) ($row['bag_code'] ?? '')) ?>" data-total-per-box="<?= (int) ($row['total_per_box'] ?? 1) ?>" data-pack-per-sheet="<?= (int) ($row['pack_per_sheet'] ?? 1) ?>" data-pending-original="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>">
<td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-right"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
<td class="text-right pending-cell"><?= number_format((int) ($row['pending_qty_sheet'] ?? 0)) ?></td>
<td class="text-right">
<input type="number" min="0" max="<?= (int) ($row['pending_qty_sheet'] ?? 0) ?>" name="receive_qty_sheet[<?= esc($k) ?>]" value="<?= esc((string) old('receive_qty_sheet.' . $k, '0')) ?>" class="w-24 border border-gray-300 rounded px-1 py-0.5 text-sm text-right receive-input" />
</td>
<td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<td class="text-center font-mono"><?= esc((string) ($row['lot_no'] ?? '')) ?></td>
<td class="text-center"><?= esc((string) ($row['order_no'] ?? '')) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows ?? [])): ?>
<tr><td colspan="8" class="text-center text-gray-400 py-4"><?php
if ((int) ($companyIdx ?? 0) <= 0) {
echo '제작업체를 선택하고 조회해 주세요.';
} else {
echo '해당 제작업체의 미입고 발주 내역이 없습니다.';
}
?></td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="flex items-center gap-2">
<a href="<?= base_url('bag/purchase-inbound') ?>" class="bg-gray-200 text-gray-700 px-6 py-1.5 rounded-sm text-sm">취소</a>
</div>
</form>
<script>
(() => {
const parseIntSafe = (v) => {
const n = Number(String(v ?? '').replace(/,/g, ''));
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
};
const refreshPending = (row) => {
const pendingCell = row.querySelector('.pending-cell');
const input = row.querySelector('.receive-input');
if (!pendingCell || !input) return;
const original = parseIntSafe(row.getAttribute('data-pending-original'));
const current = parseIntSafe(input.value);
const remain = Math.max(0, original - current);
pendingCell.textContent = Number(remain || 0).toLocaleString('ko-KR');
};
document.querySelectorAll('.receive-input').forEach((input) => {
input.addEventListener('input', (e) => {
const row = e.target.closest('tr');
if (!row) return;
const original = parseIntSafe(row.getAttribute('data-pending-original'));
const current = Math.min(parseIntSafe(input.value), original);
input.value = String(current);
refreshPending(row);
});
const row = input.closest('tr');
if (row) refreshPending(row);
});
})();
</script>

View File

@@ -0,0 +1,138 @@
<?= view('components/print_header', ['printTitle' => '봉투 입고 현황', 'printShowApproval' => false]) ?>
<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-700">입고 현황</span>
<div class="flex items-center gap-2">
<a href="<?= base_url('bag/receiving/status/export') . '?' . http_build_query(array_filter(['start_date' => $startDate ?? '', 'end_date' => $endDate ?? '', 'company_idx' => $companyIdx ?? 0, 'bag_code' => $bagCode ?? '', 'receive_type' => $receiveType ?? ''])) ?>" class="border border-btn-excel-border text-btn-excel-text px-3 py-1 rounded-sm text-sm hover:bg-green-50">엑셀저장</a>
<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">인쇄</button>
<a href="<?= base_url('bag/receiving/scanner') ?>" class="border border-gray-300 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">입고 처리</a>
</div>
</div>
</section>
<section class="p-2 bg-white border-b border-gray-200 no-print">
<form method="get" action="<?= base_url('bag/receiving/status') ?>" class="flex flex-wrap items-center gap-2">
<label class="text-sm text-gray-600">입고 기간</label>
<input type="date" name="start_date" value="<?= esc((string) ($startDate ?? '')) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm" />
<span class="text-sm text-gray-500">~</span>
<input type="date" name="end_date" value="<?= esc((string) ($endDate ?? '')) ?>" class="border border-gray-300 rounded px-2 py-1 text-sm" />
<label class="text-sm text-gray-600 ml-2">제작 업체</label>
<select name="company_idx" class="border border-gray-300 rounded px-2 py-1 text-sm w-52">
<option value="0">전 체</option>
<?php foreach (($companies ?? []) as $company): ?>
<option value="<?= (int) $company->cp_idx ?>" <?= (int) ($companyIdx ?? 0) === (int) $company->cp_idx ? 'selected' : '' ?>>
<?= esc((string) ($company->cp_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">품명</label>
<select name="bag_code" class="border border-gray-300 rounded px-2 py-1 text-sm w-44">
<option value="">전 체</option>
<?php foreach (($bagCodeOptions ?? []) as $bag): ?>
<option value="<?= esc((string) ($bag->cd_code ?? '')) ?>" <?= (string) ($bagCode ?? '') === (string) ($bag->cd_code ?? '') ? 'selected' : '' ?>>
<?= esc((string) ($bag->cd_name ?? '')) ?>
</option>
<?php endforeach; ?>
</select>
<label class="text-sm text-gray-600">입고 구분</label>
<select name="receive_type" class="border border-gray-300 rounded px-2 py-1 text-sm w-36">
<option value="all" <?= ($receiveType ?? 'all') === 'all' ? 'selected' : '' ?>>전 체</option>
<option value="completed" <?= ($receiveType ?? 'all') === 'completed' ? 'selected' : '' ?>>완료</option>
<option value="pending" <?= ($receiveType ?? 'all') === 'pending' ? 'selected' : '' ?>>미완료</option>
</select>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm text-sm">조회</button>
<a href="<?= base_url('bag/receiving/status') ?>" class="text-sm text-gray-500 hover:underline">초기화</a>
</form>
</section>
<div class="border border-gray-300 overflow-auto mt-2 receiving-status-print-wrap">
<table class="w-full data-table text-sm receiving-status-print-table">
<thead>
<tr>
<th>입고일자</th>
<th>품명</th>
<th>입고수량</th>
<th>발주일자</th>
<th>발주수량</th>
<th>발주번호</th>
<th>제작업체</th>
<th>입고여부</th>
<th>입고처</th>
<th>비고</th>
</tr>
</thead>
<tbody>
<?php
$prevDate = null;
$runningSum = 0;
?>
<?php foreach (($rows ?? []) as $i => $row): ?>
<?php
$d = (string) ($row['display_date'] ?? '');
if ($prevDate !== null && $d !== $prevDate):
?>
<tr class="bg-gray-50 font-semibold">
<td colspan="2" class="text-center">소 계</td>
<td class="text-right"><?= number_format($runningSum) ?></td>
<td colspan="7"></td>
</tr>
<?php
$runningSum = 0;
endif;
$runningSum += (int) ($row['received_qty_sheet'] ?? 0);
$prevDate = $d;
?>
<tr>
<td class="text-center"><?= esc($d) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['bag_name'] ?? '')) ?></td>
<td class="text-right"><?= number_format((int) ($row['received_qty_sheet'] ?? 0)) ?></td>
<td class="text-center"><?= esc((string) ($row['order_date'] ?? '')) ?></td>
<td class="text-right"><?= number_format((int) ($row['order_qty_sheet'] ?? 0)) ?></td>
<td class="text-center"><?= esc((string) ($row['order_no'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['company_name'] ?? '')) ?></td>
<td class="text-center <?= ($row['receive_status_label'] ?? '') === '완료' ? 'text-red-600 font-bold' : 'text-blue-600 font-bold' ?>"><?= esc((string) ($row['receive_status_label'] ?? '')) ?></td>
<td class="text-left pl-2"><?= esc((string) ($row['agency_name'] ?? '')) ?></td>
<td></td>
</tr>
<?php endforeach; ?>
<?php if (! empty($rows ?? [])): ?>
<tr class="bg-gray-50 font-semibold">
<td colspan="2" class="text-center">소 계</td>
<td class="text-right"><?= number_format($runningSum) ?></td>
<td colspan="7"></td>
</tr>
<?php endif; ?>
<?php if (empty($rows ?? [])): ?>
<tr><td colspan="10" class="text-center text-gray-400 py-4">조회 결과가 없습니다.</td></tr>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="bg-gray-100 font-bold">
<td colspan="2" class="text-center">합 계</td>
<td class="text-right"><?= number_format((int) ($grandTotalReceive ?? 0)) ?></td>
<td colspan="7"></td>
</tr>
</tfoot>
</table>
</div>
<style>
@media print {
.receiving-status-print-wrap {
overflow: visible !important;
border: none !important;
}
.receiving-status-print-table th,
.receiving-status-print-table td {
white-space: nowrap !important;
font-size: 10px !important;
padding: 2px 3px !important;
}
}
</style>

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;