사용자 매뉴얼·번호알기·gov-portal 대시보드와 메뉴 동선·수불 리포트를 보강한다.

- 사용자 매뉴얼: league/commonmark 기반 bag/manual(로그인 전용),
  ManualRenderer + Config\Manual manifest, 콘텐츠 8종, E2E
- 번호알기(봉투번호확인): bag/number-lookup, BagNumberLookup, E2E
- gov-portal 대시보드 시안(기본/strip)·기본코드관리 화면
- 메뉴 관리: 등록·수정 후 메뉴 화면 유지, 수정 버튼 클릭 시 상단 스크롤
- 수불/분석 리포트(LOT 수불·반품/파기·수급계획·추이) 표시 보강
- .gitignore: docs/ → /docs/ 앵커링(최상위 개발문서만 제외, app/Docs는 추적)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-06-08 00:46:51 +09:00
parent 0f1d414f37
commit 8763876f19
77 changed files with 6139 additions and 182 deletions

3
.gitignore vendored
View File

@@ -174,4 +174,5 @@ blob-report/
/results/ /results/
/phpunit*.xml /phpunit*.xml
docs/ # 최상위 개발 문서/스크린샷 폴더만 제외 (app/Docs 등 하위 docs 경로는 추적).
/docs/

View File

@@ -271,6 +271,8 @@ assets/ # 기획 문서 (엑셀)
## 바코드 생성/사용 시점 (현재 코드 기준) ## 바코드 생성/사용 시점 (현재 코드 기준)
상세 코드 체계(LOT·팩·낱장·품목코드·판매소번호): [`doc/봉투-LOT-바코드-코드체계.md`](doc/봉투-LOT-바코드-코드체계.md)
- **현재 코드 구현** - **현재 코드 구현**
- 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다. - 박스/팩/낱장 바코드는 `bag_receiving_pack_code`에 저장됩니다.
- 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다. - 생성 트리거는 입고 처리(`receiving/scanner/store`, `receiving/batch/store`) 시점의 `createReceivingPackCodes()` 호출입니다.

34
app/Config/Manual.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* 사용자 매뉴얼(설명서) 목차 정의.
*
* - 배열 순서가 곧 목차(사이드바) 노출 순서입니다.
* - slug 는 URL 세그먼트이자 화이트리스트입니다. 여기에 없는 slug 는 404 입니다.
* - file 은 $dir 하위의 실제 마크다운 파일명입니다(사용자 입력으로 조합하지 않음).
*/
class Manual extends BaseConfig
{
/** 마크다운 콘텐츠 디렉터리 (웹 루트 밖). */
public string $dir = APPPATH . 'Docs/manual/';
/**
* @var array<string, array{title: string, file: string}>
*/
public array $pages = [
'overview' => ['title' => '시작하기·시스템 개요', 'file' => '00_overview.md'],
'flow' => ['title' => '핵심 업무 흐름', 'file' => '10_workflow.md'],
'order' => ['title' => '발주·입고', 'file' => '20_order_receiving.md'],
'inventory' => ['title' => '재고·실사', 'file' => '30_inventory.md'],
'sales' => ['title' => '판매·불출', 'file' => '40_sales_issue.md'],
'reports' => ['title' => '판매현황·수불·통계', 'file' => '50_reports.md'],
'codes' => ['title' => '봉투·LOT·바코드 코드체계', 'file' => '90_code_system.md'],
'faq' => ['title' => '자주 묻는 질문·문의', 'file' => '99_faq.md'],
];
}

View File

@@ -15,6 +15,10 @@ $routes->get('dashboard/dense', 'Home::dashboardDense');
$routes->get('dashboard/charts', 'Home::dashboardCharts'); $routes->get('dashboard/charts', 'Home::dashboardCharts');
$routes->get('dashboard/blend', 'Home::dashboardBlend'); $routes->get('dashboard/blend', 'Home::dashboardBlend');
$routes->get('dashboard/lite', 'Home::dashboardLite'); $routes->get('dashboard/lite', 'Home::dashboardLite');
$routes->get('dashboard/gov-portal', 'Home::dashboardGovPortal');
$routes->get('dashboard/gov-portal/code-kinds', 'Home::dashboardGovPortalCodeKinds');
$routes->get('dashboard/gov-portal-strip', 'Home::dashboardGovPortalStrip');
$routes->get('dashboard/gov-portal-strip/code-kinds', 'Home::dashboardGovPortalStripCodeKinds');
$routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry'); $routes->get('bag/inventory-inquiry', 'Home::inventoryInquiry');
$routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise'); $routes->get('bag/waste-suibal-enterprise', 'Home::wasteSuibalEnterprise');
@@ -52,6 +56,15 @@ $routes->get('bag/analytics/seasonal-trend', 'Bag::analyticsSeasonalTrend');
$routes->get('bag/window', 'Bag::window'); $routes->get('bag/window', 'Bag::window');
$routes->get('bag/help', 'Bag::help'); $routes->get('bag/help', 'Bag::help');
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
$routes->get('manual', 'Bag::manual');
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
});
$routes->get('bag/number-lookup', 'Bag::numberLookup');
$routes->post('bag/number-lookup/resolve', 'Bag::numberLookupResolve');
// 사이트 메뉴 CRUD (사이트 레이아웃) // 사이트 메뉴 CRUD (사이트 레이아웃)
$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');

View File

@@ -18,6 +18,21 @@ class Menu extends BaseController
$this->typeModel = model(MenuTypeModel::class); $this->typeModel = model(MenuTypeModel::class);
} }
/**
* 메뉴 등록·수정·삭제·순서변경 후 항상 같은 메뉴 관리 화면(mt_idx 유지)으로 돌아간다.
* redirect()->back() 은 목록의 새 탭(target="_blank") 링크 클릭으로 세션 직전 URL(_ci_previous_url)이
* 메뉴 대상 페이지로 덮어써지면 그 페이지로 이탈하므로, 명시적으로 메뉴 화면 URL 을 사용한다.
*/
private function menusRedirect(int $mtIdx): \CodeIgniter\HTTP\RedirectResponse
{
$url = base_url('admin/menus');
if ($mtIdx > 0) {
$url .= '?mt_idx=' . $mtIdx;
}
return redirect()->to($url);
}
/** /**
* 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리. * 메뉴 관리 화면 (목록 + 등록/수정 폼). 지자체별 메뉴만 조회·관리.
*/ */
@@ -140,10 +155,10 @@ class Menu extends BaseController
$mmDep = (int) $this->request->getPost('mm_dep'); $mmDep = (int) $this->request->getPost('mm_dep');
$mmName = trim((string) $this->request->getPost('mm_name')); $mmName = trim((string) $this->request->getPost('mm_name'));
if ($mtIdx <= 0) { if ($mtIdx <= 0) {
return redirect()->back()->with('error', '메뉴 종류를 선택하세요.'); return $this->menusRedirect($mtIdx)->with('error', '메뉴 종류를 선택하세요.');
} }
if ($mmName === '') { if ($mmName === '') {
return redirect()->back()->with('error', '메뉴명을 입력하세요.'); return $this->menusRedirect($mtIdx)->with('error', '메뉴명을 입력하세요.');
} }
$mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep); $mmNum = $this->menuModel->getNextNum($mtIdx, $lgIdx, $mmPidx, $mmDep);
$data = [ $data = [
@@ -164,7 +179,7 @@ class Menu extends BaseController
} }
$this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx); $this->menuModel->pruneInventoryManagementMenus($mtIdx, $lgIdx);
$this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx); $this->menuModel->syncTypeToAllLgs($mtIdx, $lgIdx);
return redirect()->back()->with('success', '메뉴가 등록되었습니다.'); return $this->menusRedirect($mtIdx)->with('success', '메뉴가 등록되었습니다.');
} }
/** /**
@@ -182,10 +197,12 @@ class Menu extends BaseController
} }
$row = $this->menuModel->find($id); $row = $this->menuModel->find($id);
if (! $row) { if (! $row) {
return redirect()->back()->with('error', '메뉴를 찾을 수 없습니다.'); return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '메뉴를 찾을 수 없습니다.');
} }
if ((int) $row->lg_idx !== $lgIdx) { if ((int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.'); return $this->menusRedirect((int) $row->mt_idx)
->with('error', '해당 지자체의 메뉴만 수정할 수 있습니다.');
} }
$data = [ $data = [
'mm_name' => (string) $this->request->getPost('mm_name'), 'mm_name' => (string) $this->request->getPost('mm_name'),
@@ -196,7 +213,7 @@ class Menu extends BaseController
$this->menuModel->update($id, $data); $this->menuModel->update($id, $data);
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return redirect()->back()->with('success', '메뉴가 수정되었습니다.'); return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.');
} }
/** /**
@@ -214,15 +231,16 @@ class Menu extends BaseController
} }
$row = $this->menuModel->find($id); $row = $this->menuModel->find($id);
if (! $row || (int) $row->lg_idx !== $lgIdx) { if (! $row || (int) $row->lg_idx !== $lgIdx) {
return redirect()->back()->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.'); return $this->menusRedirect((int) $this->request->getPost('mt_idx'))
->with('error', '해당 지자체의 메뉴만 삭제할 수 있습니다.');
} }
$result = $this->menuModel->deleteSafe($id); $result = $this->menuModel->deleteSafe($id);
if ($result['ok']) { if ($result['ok']) {
$this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx);
return redirect()->back()->with('success', '메뉴가 삭제되었습니다.'); return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 삭제되었습니다.');
} }
return redirect()->back()->with('error', $result['msg']); return $this->menusRedirect((int) $row->mt_idx)->with('error', $result['msg']);
} }
/** /**
@@ -239,8 +257,9 @@ class Menu extends BaseController
->with('error', '지자체를 선택하세요.'); ->with('error', '지자체를 선택하세요.');
} }
$ids = $this->request->getPost('mm_idx'); $ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_idx');
if (! is_array($ids) || empty($ids)) { if (! is_array($ids) || empty($ids)) {
return redirect()->back()->with('error', '순서를 적용할 메뉴가 없습니다.'); return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
} }
$firstId = (int) ($ids[0] ?? 0); $firstId = (int) ($ids[0] ?? 0);
$firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null; $firstRow = $firstId > 0 ? $this->menuModel->find($firstId) : null;
@@ -249,7 +268,8 @@ class Menu extends BaseController
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx); $this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
} }
return redirect()->back()->with('success', '순서가 적용되었습니다.'); $mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
} }
/** /**

View File

@@ -2582,7 +2582,6 @@ class SalesReport extends BaseController
'endDate' => $endDate, 'endDate' => $endDate,
'ioType' => $ioType, 'ioType' => $ioType,
'queried' => $queried, 'queried' => $queried,
'exportQuery' => $this->returnsExportQueryString(),
]); ]);
} }
@@ -2594,12 +2593,24 @@ class SalesReport extends BaseController
return redirect()->to(mgmt_url('reports/returns'))->with('error', '지자체를 선택해 주세요.'); return redirect()->to(mgmt_url('reports/returns'))->with('error', '지자체를 선택해 주세요.');
} }
if ($this->request->getGet('search') !== '1') { $startDate = trim((string) ($this->request->getGet('start_date') ?? ''));
$endDate = trim((string) ($this->request->getGet('end_date') ?? ''));
$hasQuery = $this->request->getGet('search') === '1'
|| ($startDate !== '' && $endDate !== '');
if (! $hasQuery) {
return redirect()->to(mgmt_url('reports/returns'))->with('error', '조회 후 엑셀 저장을 이용해 주세요.'); return redirect()->to(mgmt_url('reports/returns'))->with('error', '조회 후 엑셀 저장을 이용해 주세요.');
} }
$startDate = (string) ($this->request->getGet('start_date') ?? date('Y-m-01')); if ($startDate === '') {
$endDate = (string) ($this->request->getGet('end_date') ?? date('Y-m-d')); $startDate = date('Y-m-01');
}
if ($endDate === '') {
$endDate = date('Y-m-d');
}
if ($startDate > $endDate) {
[$startDate, $endDate] = [$endDate, $startDate];
}
$ioType = (string) ($this->request->getGet('io_type') ?? 'out'); $ioType = (string) ($this->request->getGet('io_type') ?? 'out');
if (! in_array($ioType, ['in', 'out'], true)) { if (! in_array($ioType, ['in', 'out'], true)) {
$ioType = 'out'; $ioType = 'out';
@@ -2628,22 +2639,77 @@ class SalesReport extends BaseController
} }
/** /**
* 출고 = 지정판매소 반품(designated-return · bag_return_scan_code)
* 입고 = 물류 입고분 파기(bag_dispose)
*
* @return list<object> * @return list<object>
*/ */
private function fetchReturnDisposeRows(int $lgIdx, string $startDate, string $endDate, string $ioType): array private function fetchReturnDisposeRows(int $lgIdx, string $startDate, string $endDate, string $ioType): array
{ {
$bsTypes = $ioType === 'in' ? ['return'] : ['cancel'];
$typePlaceholders = implode(',', array_fill(0, count($bsTypes), '?'));
$db = \Config\Database::connect(); $db = \Config\Database::connect();
if ($ioType === 'out') {
return $this->fetchDesignatedReturnRows($db, $lgIdx, $startDate, $endDate);
}
return $this->fetchBagDisposeRows($db, $lgIdx, $startDate, $endDate);
}
/**
* @return list<object>
*/
private function fetchDesignatedReturnRows($db, int $lgIdx, string $startDate, string $endDate): array
{
if ($db->tableExists('bag_return_scan_code')) {
return $db->query("
SELECT r.brsc_return_date AS bs_sale_date,
COALESCE(ds.ds_name, '') AS bs_ds_name,
r.brsc_bag_code AS bs_bag_code,
r.brsc_bag_name AS bs_bag_name,
'return' AS bs_type,
SUM(r.brsc_qty) AS qty
FROM bag_return_scan_code r
LEFT JOIN designated_shop ds
ON ds.ds_idx = r.brsc_ds_idx AND ds.ds_lg_idx = r.brsc_lg_idx
WHERE r.brsc_lg_idx = ?
AND r.brsc_return_date BETWEEN ? AND ?
AND r.brsc_state = 'returned'
GROUP BY r.brsc_return_date, r.brsc_ds_idx, ds.ds_name,
r.brsc_bag_code, r.brsc_bag_name
ORDER BY r.brsc_return_date ASC, bs_ds_name ASC, r.brsc_bag_code ASC
", [$lgIdx, $startDate, $endDate])->getResult();
}
return $db->query(" return $db->query("
SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type, SELECT bs_sale_date, bs_ds_name, bs_bag_code, bs_bag_name, bs_type,
ABS(bs_qty) AS qty ABS(bs_qty) AS qty
FROM bag_sale FROM bag_sale
WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ? WHERE bs_lg_idx = ? AND bs_sale_date BETWEEN ? AND ?
AND bs_type IN ({$typePlaceholders}) AND bs_type = 'return'
ORDER BY bs_sale_date ASC, bs_ds_name ASC, bs_bag_code ASC ORDER BY bs_sale_date ASC, bs_ds_name ASC, bs_bag_code ASC
", array_merge([$lgIdx, $startDate, $endDate], $bsTypes))->getResult(); ", [$lgIdx, $startDate, $endDate])->getResult();
}
/**
* @return list<object>
*/
private function fetchBagDisposeRows($db, int $lgIdx, string $startDate, string $endDate): array
{
if (! $db->tableExists('bag_dispose')) {
return [];
}
return $db->query("
SELECT bd_dispose_date AS bs_sale_date,
bd_location AS bs_ds_name,
bd_bag_code AS bs_bag_code,
bd_bag_name AS bs_bag_name,
'dispose' AS bs_type,
bd_qty AS qty
FROM bag_dispose
WHERE bd_lg_idx = ? AND bd_dispose_date BETWEEN ? AND ?
ORDER BY bd_dispose_date ASC, bd_location ASC, bd_bag_code ASC
", [$lgIdx, $startDate, $endDate])->getResult();
} }
private function returnDisposeKindLabel(object $row): string private function returnDisposeKindLabel(object $row): string
@@ -2661,23 +2727,12 @@ class SalesReport extends BaseController
{ {
return match ($bsType) { return match ($bsType) {
'return' => '반품', 'return' => '반품',
'dispose' => '파기',
'cancel' => '파기', 'cancel' => '파기',
default => $bsType, default => $bsType,
}; };
} }
private function returnsExportQueryString(): string
{
$params = array_filter([
'search' => '1',
'start_date' => $this->request->getGet('start_date'),
'end_date' => $this->request->getGet('end_date'),
'io_type' => $this->request->getGet('io_type'),
], static fn ($v) => $v !== null && $v !== '');
return http_build_query($params);
}
/** /**
* P5-10: LOT 수불 조회 (레거시 w_gd033a — 바코드/봉투번호) * P5-10: LOT 수불 조회 (레거시 w_gd033a — 바코드/봉투번호)
*/ */

View File

@@ -3571,6 +3571,71 @@ SQL);
return $this->render('도움말', 'bag/help', []); return $this->render('도움말', 'bag/help', []);
} }
/**
* 사용자 매뉴얼(설명서) — 목차 첫 페이지로 이동.
*/
public function manual(): \CodeIgniter\HTTP\RedirectResponse
{
$first = (new \App\Libraries\ManualRenderer())->firstSlug();
return redirect()->to(site_url('bag/manual/' . $first));
}
/**
* 사용자 매뉴얼 개별 페이지 (slug = 화이트리스트). 미등록 slug 는 404.
*/
public function manualPage(string $slug): string
{
$renderer = new \App\Libraries\ManualRenderer();
$page = $renderer->find($slug);
$body = $page !== null ? $renderer->render($slug) : null;
if ($page === null || $body === null) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound('매뉴얼 문서를 찾을 수 없습니다.');
}
return $this->render('사용자 매뉴얼 · ' . $page['title'], 'bag/manual', [
'pages' => $renderer->pages(),
'current' => $slug,
'title' => $page['title'],
'body' => $body,
]);
}
/**
* 도움말 — 번호알기(봉투번호확인). 코드 → 바코드·인쇄숫자·인식번호.
*/
public function numberLookup(): string
{
$code = trim((string) ($this->request->getGet('code') ?? ''));
$result = null;
$error = '';
if ($code !== '') {
$resolved = (new \App\Libraries\BagNumberLookup())->resolve($code, $this->lgIdx());
$result = $resolved;
if (! $resolved['ok']) {
$error = (string) $resolved['message'];
}
}
return $this->render('번호알기', 'bag/number_lookup', [
'code' => $code,
'result' => $result,
'error' => $error,
]);
}
/**
* 번호알기 AJAX 조회.
*/
public function numberLookupResolve()
{
$code = trim((string) ($this->request->getPost('code') ?? $this->request->getGet('code') ?? ''));
$data = (new \App\Libraries\BagNumberLookup())->resolve($code, $this->lgIdx());
return $this->response->setJSON($data);
}
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// CRUD — 사이트 레이아웃으로 등록/처리 폼 제공 // CRUD — 사이트 레이아웃으로 등록/처리 폼 제공
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════

View File

@@ -2,6 +2,7 @@
namespace App\Controllers; namespace App\Controllers;
use App\Libraries\GovPortalCodeKindsPage;
use App\Models\LocalGovernmentModel; use App\Models\LocalGovernmentModel;
class Home extends BaseController class Home extends BaseController
@@ -118,6 +119,107 @@ class Home extends BaseController
]); ]);
} }
/**
* 공공 포털형(국가재난관리정보시스템 스타일) 메인 시안.
* URL: /dashboard/gov-portal — 기능은 요약 대시보드와 동일, UI만 별도 레이아웃.
*/
public function dashboardGovPortal()
{
if (! session()->get('logged_in')) {
return redirect()->to(base_url('login'));
}
helper('admin');
return view('home/dashboard_gov_portal', gov_portal_dashboard_view_data($this->resolveLgLabel(), 'base'));
}
/**
* 공공 포털형 변형 — 가로 MY MENU·와이드 맵·KPI 띠. URL: /dashboard/gov-portal-strip
*/
public function dashboardGovPortalStrip()
{
if (! session()->get('logged_in')) {
return redirect()->to(base_url('login'));
}
helper('admin');
return view('home/_dashboard_gov_portal_strip_layout', array_merge(
gov_portal_dashboard_view_data($this->resolveLgLabel(), 'strip'),
['stripInnerView' => 'home/_dashboard_gov_portal_strip_home_inner']
));
}
/**
* 공공 포털형(기본) — 기본 코드관리 UI 시안. URL: /dashboard/gov-portal/code-kinds
*/
public function dashboardGovPortalCodeKinds()
{
return $this->renderGovPortalCodeKinds('base');
}
/**
* 공공 포털형(변형 strip) — 기본 코드관리 UI 시안. URL: /dashboard/gov-portal-strip/code-kinds
*/
public function dashboardGovPortalStripCodeKinds()
{
return $this->renderGovPortalCodeKinds('strip');
}
/**
* @return \CodeIgniter\HTTP\RedirectResponse|string
*/
private function renderGovPortalCodeKinds(string $variant)
{
if (! session()->get('logged_in')) {
return redirect()->to(base_url('login'));
}
helper('admin');
$portalPath = gov_portal_code_kinds_portal_path($variant);
$lgIdx = admin_effective_lg_idx();
$level = (int) session()->get('mb_level');
$filters = [
'q_code' => $this->request->getGet('q_code'),
'q_name' => $this->request->getGet('q_name'),
'q_state' => $this->request->getGet('q_state'),
];
$builder = new GovPortalCodeKindsPage();
$ckIdx = (int) ($this->request->getGet('ck_idx') ?? 0);
$pageData = $builder->buildPageData($lgIdx, $level, $lgIdx, $ckIdx, $filters);
if ($ckIdx === 0 && $pageData['codeKinds'] !== []) {
$pageData = $builder->buildPageData(
$lgIdx,
$level,
$lgIdx,
(int) $pageData['codeKinds'][0]->ck_idx,
$filters
);
}
$viewData = array_merge(
gov_portal_dashboard_view_data($this->resolveLgLabel(), $variant),
$pageData,
[
'govActiveChildHref' => $portalPath,
'pageBaseUrl' => site_url($portalPath),
]
);
if ($variant === 'strip') {
return view('home/_dashboard_gov_portal_strip_layout', array_merge($viewData, [
'stripInnerView' => 'home/_gov_portal_code_kinds_body',
'stripIncludeWorkCss' => true,
'stripShowProfileLinks' => true,
]));
}
return view('home/dashboard_gov_portal_code_kinds', $viewData);
}
/** /**
* 재고 조회(수불) 화면 (목업) * 재고 조회(수불) 화면 (목업)
*/ */

View File

@@ -0,0 +1,49 @@
# 시작하기 · 시스템 개요
종량제 쓰레기봉투 물류 시스템 사용자 매뉴얼입니다. 이 문서는 실제 업무를 처리하는 담당자(지자체 관리자·지정판매소)를 위한 **빠른 시작 안내서**입니다.
## 1. 시스템은 무엇을 하나요?
지자체 종량제 쓰레기봉투의 **발주 → 입고 → 재고 → 판매/불출 → 정산·통계** 전 과정을 관리합니다.
- 봉투를 제작업체에 **발주**하고, 들어온 물량을 **입고** 처리합니다.
- 현재 **재고**를 확인하고, 실사로 실제 수량과 맞춥니다.
- 지정판매소에 **판매**하거나 무료 대상자에게 **불출**합니다.
- 일계표·기간별·연간 등 **판매현황**과 **수불·통계**를 조회합니다.
## 2. 로그인과 화면 구성
1. 발급받은 아이디·비밀번호로 로그인합니다.
2. 관리자(지자체/슈퍼)는 보안을 위해 **2차 인증(OTP)** 이 적용될 수 있습니다.
3. 로그인하면 상단에 **대메뉴**가 보이고, 각 대메뉴에 마우스를 올리면 **소메뉴**가 펼쳐집니다.
4. 화면 상단의 제목 바에 현재 보고 있는 화면 이름이 표시됩니다.
> 슈퍼 관리자는 작업할 **지자체를 먼저 선택**해야 업무 화면이 열립니다.
## 3. 사용자 역할(권한)
시스템은 4단계 역할로 접근 권한을 구분합니다.
| 레벨 | 역할 | 할 수 있는 일 |
|---|---|---|
| 1 | 일반 사용자 | 기본 조회 |
| 2 | 지정판매소 | 봉투 판매·반품, 자기 판매 현황 조회 |
| 3 | 지자체 관리자 | 소속 지자체의 발주·입고·재고·불출·판매·리포트 전반 |
| 4 | 슈퍼 관리자 | 전체 시스템 관리 + 기본코드 등 마스터 관리 (지자체 선택 후 작업) |
### 역할별 접근 한눈에 보기
| 기능군 | 일반 | 판매소 | 지자체관리자 | 슈퍼 |
|---|:--:|:--:|:--:|:--:|
| 기본정보 조회(코드·단가·포장) | △ | ○ | ○ | ○ |
| 기본코드·마스터 편집 | ✕ | ✕ | △(지자체분) | ○ |
| 발주·입고·재고·불출 | ✕ | ✕ | ○ | ○ |
| 판매·반품 등록 | ✕ | ○ | ○ | ○ |
| 판매현황·수불·통계 | ✕ | △(자기분) | ○ | ○ |
(○ 사용 가능 · △ 제한적 · ✕ 불가)
## 4. 다음 단계
- 전체 업무가 어떻게 이어지는지 먼저 보려면 **[핵심 업무 흐름]** 으로 이동하세요.
- 특정 작업 방법은 좌측 목차에서 해당 항목(발주·입고 / 재고·실사 / 판매·불출 / 판매현황·수불·통계)을 선택하세요.

View File

@@ -0,0 +1,36 @@
# 핵심 업무 흐름
봉투 한 묶음이 시스템에서 거치는 전체 흐름입니다. 처음 사용하신다면 이 순서대로 익히는 것을 권장합니다.
## 전체 흐름
```
발주 ─→ 입고 ─→ 재고(실사) ─→ 판매 / 불출 ─→ 판매현황 · 수불 · 통계
```
| 단계 | 무엇을 하나 | 주요 메뉴 |
|---|---|---|
| ① 발주 | 봉투 종류·수량을 제작업체에 주문 | 발주 입고 관리 발주 등록 |
| ② 입고 | 도착한 물량을 시스템에 등록(스캐너/일괄) | 발주 입고 관리 입고 |
| ③ 재고 | 현재 보유 수량 확인, 실사로 실수량 보정 | 재고 관리 |
| ④ 판매 | 지정판매소에 판매·반품 처리 | 판매 관리 |
| ④ 불출 | 무료 대상자에게 무상 지급 | 불출 관리 |
| ⑤ 현황 | 일·기간·연간 판매 및 수불·통계 조회 | 판매 현황 / 봉투 수불 / 통계 분석 |
## 각 단계 한 줄 요약
1. **발주** — 봉투 품목·수량·납기를 입력해 발주서를 만들면 추적용 **LOT 번호**가 부여됩니다.
2. **입고** — 발주분이 도착하면 입고로 등록합니다. 이때 박스·팩·낱장 단위의 **바코드**가 생성됩니다.
3. **재고** — 품목별 현재 재고를 조회하고, 정기적으로 **실사**(선별 → 등록 → 적용)로 실제 수량과 맞춥니다.
4. **판매/불출** — 지정판매소 판매·반품, 또는 무료 대상자 불출로 재고가 감소합니다.
5. **현황·통계** — 일계표·기간별·연간 판매와 봉투 수불, 전년대비/월별/계절 추이를 확인합니다.
## 봉투 추적 단위
봉투는 다음 계층으로 추적됩니다. 자세한 코드 규칙은 **[봉투·LOT·바코드 코드체계]** 문서를 참고하세요.
```
LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장)
```
> 코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 번호알기** 화면을 사용하세요.

View File

@@ -0,0 +1,41 @@
# 발주 · 입고
제작업체에 봉투를 주문(발주)하고, 도착한 물량을 시스템에 등록(입고)하는 단계입니다. **지자체 관리자** 이상이 사용합니다.
## 발주
### 발주 등록
**발주 입고 관리 발주 등록**
1. 봉투 **품목**(종류·용량)과 **수량**, 납품 관련 정보를 입력합니다.
2. 박스/낱장 수량과 금액·총계가 자동으로 계산됩니다.
3. 저장하면 발주 건이 생성되고, 추적용 **LOT 번호**가 자동 부여됩니다.
> 발주 내용은 무결성 보호를 위해 버전·해시로 관리됩니다. 수정(재발주) 시 기존 LOT는 유지됩니다.
### 발주 변경 · 현황
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 발주 변경 | 발주 변경 | 기존 발주 수정·재발주 |
| 발주 현황 | 발주 현황 | 발주 목록을 기간·상태로 조회, 엑셀 내보내기 |
| 발주 상세 | (현황에서 행 선택) | 개별 발주 상세 확인, 취소 처리 |
## 입고
발주분이 실제 도착하면 입고로 등록합니다. 입고 시 **박스·팩·낱장 바코드**가 생성되어 재고에 반영됩니다.
| 방식 | 메뉴 | 언제 사용 |
|---|---|---|
| 스캐너 입고 | 발주 입고[스캐너] | 바코드를 스캔하며 입고 |
| 일괄 입고 | 일괄입고 | 다량을 한 번에 입고 |
| 입고 현황 | 입고 현황 | 입고 기록 조회, 엑셀 내보내기 |
### 입고 처리 순서
1. 입고할 발주 건(LOT)을 선택합니다.
2. 도착 수량(박스/낱장)을 확인·입력합니다.
3. 저장하면 재고가 증가하고, 단위별 바코드가 부여됩니다.
> 입고가 끝나면 **재고 관리**에서 수량이 정상 반영됐는지 확인하세요.

View File

@@ -0,0 +1,39 @@
# 재고 · 실사
현재 보유 봉투 수량을 확인하고, 정기적으로 실사를 통해 실제 수량과 맞추는 단계입니다.
## 재고 현황
**재고 관리 재고 현황**
- 품목별·상태별 현재 재고를 조회합니다.
- 지자체·봉투 종류 등으로 필터링할 수 있습니다.
- **엑셀 내보내기**로 목록을 저장할 수 있습니다.
| 항목 | 설명 |
|---|---|
| 품목 | 봉투 종류·용량 |
| 재고 수량 | 입고 (판매 + 불출 + 파기) |
| 상태 | 재고/판매 등 단위별 상태 |
## 실사 (재고 조사)
장부상 재고와 실제 창고 수량을 맞추는 작업입니다. 다음 순서로 진행합니다.
```
실사 선별 ─→ 실사 등록(작업) ─→ 적용
```
| 단계 | 메뉴 | 하는 일 |
|---|---|---|
| ① 선별 | 실사 선별 조회 | 실사 대상 범위를 골라 선별 |
| ② 작업 | 실사 선별 관리 | 실제 수량을 입력·기록 |
| ③ 상세·적용 | 실사 상세 | 실사 결과를 확인하고 재고에 **적용** |
### 실사 진행 순서
1. **선별**: 실사할 품목·구간을 선택해 실사 건을 만듭니다.
2. **등록**: 실제 센 수량을 입력합니다. 장부 수량과의 차이가 표시됩니다.
3. **적용**: 검토 후 적용하면 차이가 재고에 반영됩니다.
> 적용 전까지는 재고에 영향을 주지 않으므로, 입력 도중 중단해도 안전합니다.

View File

@@ -0,0 +1,46 @@
# 판매 · 불출
재고를 외부로 내보내는 두 가지 경로입니다. **판매**는 지정판매소 대상 유상 거래, **불출**은 무료 대상자에 대한 무상 지급입니다.
## 판매 (지정판매소)
**판매 관리** 메뉴에서 처리합니다.
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 판매 등록 | 지정 판매소 판매 | 판매소를 선택해 판매 거래 기록 |
| 스캔 판매 | 지정 판매소 판매[스캔] | 바코드를 스캔하며 판매(판매소 현장용) |
| 판매 취소 | 지정 판매소 판매 취소 | 기존 판매 거래 취소 |
| 반품 | 지정 판매소 반품 | 판매분 반품 등록 |
| 반품 취소 | 지정 판매소 반품 취소 | 반품 취소 처리 |
### 판매 등록 순서
1. 판매할 **지정판매소**를 선택합니다.
2. 봉투 **품목·수량**을 입력(또는 바코드 스캔)합니다.
3. 저장하면 재고가 감소하고 판매 내역이 기록됩니다.
### 전화 접수(주문)
| 작업 | 메뉴 |
|---|---|
| 전화 접수(신규) | 전화 접수 |
| 전화 접수 관리 | 전화 접수 관리(수정·취소) |
## 불출 (무료 대상자)
**불출 관리** 메뉴에서 무료 대상자에게 봉투를 무상 지급합니다.
| 작업 | 메뉴 | 설명 |
|---|---|---|
| 불출 처리 | 무료용 불출 처리 | 무료 배분 등록(재고 감소) |
| 불출 취소 | 무료용 불출 취소 | 불출 취소(재고 복원) |
| 불출 현황 | 무료 불출 현황 | 기간별 불출 기록 조회 |
### 불출 처리 순서
1. 무료 **대상처**와 봉투 **품목·수량**을 선택합니다.
2. 저장하면 재고가 감소하고 불출 내역이 기록됩니다.
3. 잘못 처리한 경우 **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
> 판매·불출 모두 재고를 감소시키므로, 처리 후 **재고 현황**에서 반영 결과를 확인하세요.

View File

@@ -0,0 +1,42 @@
# 판매현황 · 수불 · 통계
판매·재고 흐름을 집계해 보여주는 조회·리포트 화면 모음입니다. 대부분 **기간을 지정해 조회**하고 인쇄·엑셀로 내보낼 수 있습니다.
## 판매 현황
| 메뉴 | 내용 |
|---|---|
| 지정 판매소 일/기간 판매대장 | 판매소별 일자·기간 거래 장부 |
| 일계표 | 하루 전체 판매 요약(일계 + 월 누계) |
| 기간별 판매현황 | 지정 기간의 판매 집계(일집계/기간집계) |
| 년 판매 현황 | 연간 판매 통계(월별/분기별) |
| 지정 판매소별 판매현황 | 판매소별 수량·금액 비교 |
| 홈텍스 처리 | 세금계산서용 데이터(엑셀) 생성 |
## 봉투 수불 관리
입고·판매·불출·반품·파기를 한데 모아 **수불(수입·불출)** 흐름을 봅니다.
| 메뉴 | 내용 |
|---|---|
| 기간별 봉투 수불 현황 | 기간 내 재고/입고/판매/불출 종합(엑셀 가능) |
| 기타 입출고 | 손상·기증·폐기 등 기타 입출고 등록·조회 |
| 반품/파기 현황 | 반품 및 파기 내역 |
| LOT 수불 조회 | 봉투번호(바코드)·LOT 단위 이력 추적 |
| 쓰레기 봉투 수급 계획 | 공급·수요 계획 |
### LOT 수불 조회 사용법
1. **봉투번호(바코드)** 또는 LOT 번호를 입력합니다.
2. 조회하면 해당 단위의 입고·판매·반품 이력이 표시됩니다.
3. 입력할 코드 형식이 헷갈리면 **도움말 번호알기**로 먼저 확인하세요.
## 통계 분석
| 메뉴 | 내용 |
|---|---|
| 전년 대비 판매 분석 | 작년 동기 대비 비교(차트) |
| 월별 판매 추이 분석 | 월별 추이 시각화 |
| 계절별 판매 추이 분석 | 계절 패턴 분석 |
> 리포트 화면은 조회 조건(기간·품목·판매소)을 바꿔가며 반복 조회할 수 있습니다. 결과는 인쇄 버튼 또는 엑셀 내보내기로 저장하세요.

View File

@@ -0,0 +1,57 @@
# 봉투 · LOT · 바코드 코드체계
봉투번호(바코드)·LOT 번호·품목코드가 무엇을 뜻하는지 정리한 안내입니다. LOT 수불 조회나 번호알기 화면에서 코드를 입력할 때 참고하세요.
## 추적 단위 계층
```
LOT(발주 묶음) ─→ 박스(B) ─→ 팩(P) ─→ 낱장(S, 봉투 한 장)
```
| 단위 | 예시 | 용도 |
|---|---|---|
| LOT | `OQXCKH` | 발주 단위, LOT 수불·시드 |
| 박스 | `OQXCKH-000008-B001` | 박스 단위 입출고 |
| 팩 | `OQXCKH-000008-P299` | 팩 단위(스캔 가능) |
| 낱장 | `OQXCKH-000008-P299-S00125` | 봉투 한 장 단위 판매·반품 |
> `000008`은 "8번째 봉투"가 아니라 **입고 건 번호를 6자리로 채운 값**입니다.
## 바코드 형식 읽는 법
`OQXCKH-000008-P299-S00125` 를 예로 들면:
| 구간 | 값 | 의미 |
|---|---|---|
| 접두 | `OQXCKH` | 발주 LOT 번호 |
| 입고번호 | `000008` | 입고 건 번호(6자리) |
| 팩 | `P299` | 그 입고 건의 299번째 팩 |
| 낱장 | `S00125` | 그 팩의 125번째 낱장(봉투 한 장) |
- 박스는 `B001`, 팩은 `P299` 처럼 접두 문자로 구분합니다.
- LOT 번호는 발주 시 자동 부여되는 영문·숫자 6자리입니다.
## 품목코드 (봉투 종류)
품목코드는 봉투의 **종류·용량**을 식별하는 마스터 코드로, 바코드와는 별개입니다.
| 자리 | 의미 | 예 |
|---|---|---|
| 앞 2자리 | 봉투 구분(10 일반, 20 공공, 30 무료 …) | `10` |
| 다음 2자리 | 용량 | `15` |
| 마지막 1자리 | 재질 등 | `2` |
**예:** `10152` = 일반용 20L. 같은 품목이라도 발주·입고 건마다 바코드(LOT) 접두는 달라집니다.
## 번호알기로 확인하기
코드 한 건이 어떤 바코드·인쇄숫자·인식번호인지 확인하려면 **도움말 번호알기(봉투번호확인)** 화면에 코드를 입력하세요.
입력 가능한 형식:
| 단위 | 입력 형식 | 예시 |
|---|---|---|
| 낱장 | `{LOT}-{입고번호}-P{팩}-S{낱장}` | `OQXCKH-000008-P299-S00125` |
| 팩 | `{LOT}-{입고번호}-P{팩}` | `OQXCKH-000008-P299` |
| 박스 | `{LOT}-{입고번호}-B{박스}` | `OQXCKH-000008-B001` |
| LOT | 영문·숫자 4~8자리 | `OQXCKH` |

29
app/Docs/manual/99_faq.md Normal file
View File

@@ -0,0 +1,29 @@
# 자주 묻는 질문 · 문의
## 자주 묻는 질문
### Q. 로그인 후 업무 화면이 안 열려요.
슈퍼 관리자는 **작업할 지자체를 먼저 선택**해야 합니다. 상단 안내에 따라 지자체를 선택하세요. 일반/판매소 계정은 권한 범위 내 메뉴만 보입니다.
### Q. 메뉴가 안 보여요.
역할(권한)에 따라 노출 메뉴가 다릅니다. **시작하기 역할별 접근 한눈에 보기** 표를 확인하세요. 그래도 필요한 메뉴가 없으면 관리자에게 문의하세요.
### Q. 입고했는데 재고에 안 보여요.
입고가 정상 저장됐는지 **발주 입고 관리 입고 현황**에서 확인하고, **재고 관리 재고 현황**에서 품목·지자체 필터를 점검하세요.
### Q. 판매/불출을 잘못 처리했어요.
- 판매: **지정 판매소 판매 취소** 또는 **반품**으로 되돌립니다.
- 불출: **무료용 불출 취소**로 되돌리면 재고가 복원됩니다.
### Q. 봉투 코드(바코드)가 무슨 뜻인지 모르겠어요.
**도움말 번호알기(봉투번호확인)** 에 코드를 입력하면 바코드·인쇄숫자·인식번호로 분해해 보여줍니다. 형식은 **봉투·LOT·바코드 코드체계** 문서를 참고하세요.
### Q. 비밀번호를 바꾸고 싶어요.
**기본정보관리 PASSWORD 변경** 에서 변경할 수 있습니다.
### Q. 리포트를 엑셀/인쇄로 저장할 수 있나요?
대부분의 현황·리포트 화면에 **엑셀 내보내기**와 **인쇄** 기능이 있습니다. 이 매뉴얼 화면도 우측 상단 **인쇄** 버튼으로 출력할 수 있습니다.
## 문의
시스템 사용 중 문제가 있으면 시스템 운영 담당자 또는 소속 지자체 관리자에게 문의하세요.

View File

@@ -594,3 +594,345 @@ if (! function_exists('session_user_nav_display')) {
]; ];
} }
} }
if (! function_exists('gov_portal_active_variant')) {
/**
* 공공 포털 시안 변형: base(좌측 MY MENU) | strip(호버 상단 메뉴).
*/
function gov_portal_active_variant(?string $fromPath = null): string
{
$path = strtolower(ltrim($fromPath ?? current_nav_request_path(), '/'));
return str_starts_with($path, 'dashboard/gov-portal-strip') ? 'strip' : 'base';
}
}
if (! function_exists('gov_portal_code_kinds_portal_path')) {
/**
* 포털 UI 기본 코드관리 시안 경로 (변형별).
*/
function gov_portal_code_kinds_portal_path(?string $variant = null): string
{
return gov_portal_active_variant($variant) === 'strip'
? 'dashboard/gov-portal-strip/code-kinds'
: 'dashboard/gov-portal/code-kinds';
}
}
if (! function_exists('gov_portal_menu_href_remap')) {
/**
* gov-portal 상·좌측·드롭다운 메뉴 전용: 기본 코드관리 → 변형별 포털 시안 URL.
*/
function gov_portal_menu_href_remap(string $href, ?string $variant = null): string
{
if (strtolower(ltrim($href, '/')) !== 'bag/code-kinds') {
return $href;
}
return gov_portal_code_kinds_portal_path($variant);
}
}
if (! function_exists('gov_portal_nav_match_path')) {
/**
* 포털 시안 URL 접속 시 사이트 메뉴(mm_link) 활성 판별용.
*/
function gov_portal_nav_match_path(string $currentPath): string
{
$key = strtolower(ltrim($currentPath, '/'));
return match ($key) {
'dashboard/gov-portal/code-kinds',
'dashboard/gov-portal-strip/code-kinds' => 'bag/code-kinds',
default => $currentPath,
};
}
}
if (! function_exists('gov_portal_dashboard_aliases')) {
/**
* 포털 대시보드 현재 경로·메뉴 활성 판별용 별칭.
*
* @return list<string>
*/
function gov_portal_dashboard_aliases(): array
{
return [
'dashboard',
'dashboard/blend',
'dashboard/simple',
'dashboard/lite',
'dashboard/compact',
'dashboard/dense',
'dashboard/charts',
'dashboard/modern',
'dashboard/gov-portal',
'dashboard/gov-portal-strip',
'dashboard/gov-portal/code-kinds',
'dashboard/gov-portal-strip/code-kinds',
];
}
}
if (! function_exists('gov_portal_nav_context')) {
/**
* 공공 포털형 대시보드(gov-portal)용 사이트 메뉴 트리·JSON·활성 대메뉴 인덱스.
*
* @return array{
* siteNavTree: array<int, object>,
* navItems: list<array{idx:int,name:string,href:string,url:string,children:list<array>,hasChildren:bool}>,
* navJson: string,
* activeParentIdx: int,
* currentPath: string,
* dashboardAliases: list<string>
* }
*/
function gov_portal_nav_context(): array
{
helper('url');
$tree = get_site_nav_tree();
$rawPath = current_nav_request_path();
$variant = gov_portal_active_variant($rawPath);
$currentPath = gov_portal_nav_match_path($rawPath);
$aliases = gov_portal_dashboard_aliases();
$items = [];
$activeParentIdx = 0;
foreach ($tree as $pIdx => $parent) {
$children = [];
foreach ($parent->children ?? [] as $child) {
$href = gov_portal_menu_href_remap(
menu_link_preferred_href_path($child->mm_link ?? null, $currentPath),
$variant
);
$children[] = [
'idx' => (int) ($child->mm_idx ?? 0),
'name' => (string) ($child->mm_name ?? ''),
'href' => $href,
'url' => $href !== '' ? base_url($href) : '',
];
}
$parentHref = menu_link_preferred_href_path($parent->mm_link ?? null, $currentPath);
$isParentActive = site_nav_link_matches_current($parent->mm_link ?? null, $currentPath, $aliases);
$activeChild = menu_active_child_for_parent($parent, $currentPath, $aliases);
if ($isParentActive || $activeChild !== null) {
$activeParentIdx = (int) $pIdx;
}
$items[] = [
'idx' => (int) ($parent->mm_idx ?? 0),
'name' => (string) ($parent->mm_name ?? ''),
'href' => $parentHref,
'url' => $parentHref !== '' ? base_url($parentHref) : '',
'children' => $children,
'hasChildren' => $children !== [],
];
}
return [
'siteNavTree' => $tree,
'navItems' => $items,
'navJson' => json_encode($items, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS),
'activeParentIdx' => $activeParentIdx,
'currentPath' => $rawPath,
'dashboardAliases'=> $aliases,
];
}
}
if (! function_exists('gov_portal_menu_search_options')) {
/**
* 메뉴검색 입력 아래 표시할 바로가기(사이트 메뉴에서 추출, 최대 N개).
*
* @param list<array> $navItems gov_portal_nav_context()['navItems']
* @return list<array{label:string,url:string,keyword:string}>
*/
function gov_portal_menu_search_options(array $navItems, int $max = 8): array
{
$picked = [];
$seen = [];
$prefer = ['재고', '발주', '수불', '판매', '통계', '실사', '발급', '주문', '코드', '지정판매', '구매'];
$push = static function (array $child) use (&$picked, &$seen, $max): bool {
$href = (string) ($child['href'] ?? '');
if ($href === '' || isset($seen[$href])) {
return false;
}
$seen[$href] = true;
$picked[] = [
'label' => (string) ($child['name'] ?? ''),
'url' => (string) ($child['url'] ?? ''),
'keyword' => (string) ($child['name'] ?? ''),
];
return count($picked) >= $max;
};
foreach ($prefer as $needle) {
if (count($picked) >= $max) {
break;
}
foreach ($navItems as $parent) {
foreach ($parent['children'] ?? [] as $child) {
$name = (string) ($child['name'] ?? '');
if ($name === '' || ! str_contains($name, $needle)) {
continue;
}
if ($push($child)) {
break 3;
}
break;
}
if (count($picked) >= $max) {
break;
}
}
}
foreach ($navItems as $parent) {
if (count($picked) >= $max) {
break;
}
foreach ($parent['children'] ?? [] as $child) {
if ($push($child)) {
break 2;
}
}
}
return $picked;
}
}
if (! function_exists('gov_portal_dashboard_view_data')) {
/**
* 공공 포털형 대시보드(gov-portal / gov-portal-strip) 뷰 데이터.
* 컨트롤러에서 view() 두 번째 인자로 전달한다.
*
* @return array<string, mixed>
*/
function gov_portal_dashboard_view_data(string $lgLabel, string $activeVariant): array
{
helper('url');
$govNav = gov_portal_nav_context();
return [
'lgLabel' => $lgLabel,
'activeVariant' => $activeVariant,
'mbName' => (string) (session()->get('mb_name') ?? '담당자'),
'mbId' => (string) (session()->get('mb_id') ?? ''),
'levelName' => config('Roles')->getLevelName((int) session()->get('mb_level')),
'weeklyRequests' => [7, 12, 9, 14, 8, 11, 10],
'stockMix' => [
['name' => '일반용', 'value' => 52, 'color' => '#3b82f6'],
['name' => '음식물', 'value' => 28, 'color' => '#10b981'],
['name' => '특수', 'value' => 20, 'color' => '#f59e0b'],
],
'lowStock' => [
['name' => '일반 20L', 'percent' => 34],
['name' => '특수규격 A', 'percent' => 22],
['name' => '재사용봉투', 'percent' => 58],
],
'stockAlerts' => [
[
'count' => 18,
'label' => '정상',
'class' => 'al-blue',
'bags' => ['일반 10L', '일반 20L', '음식물 2L', '음식물 5L'],
],
[
'count' => 3,
'label' => '주의',
'class' => 'al-yellow',
'bags' => ['일반 50L', '특수 소형', '음식물 10L'],
],
[
'count' => 2,
'label' => '경계',
'class' => 'al-orange',
'bags' => ['특수규격 A', '재사용봉투'],
],
[
'count' => 1,
'label' => '부족',
'class' => 'al-red',
'bags' => ['일반 20L'],
],
],
'quickLinks' => [
['label' => '기본 코드관리', 'desc' => '포털 UI · 코드 종류·세부코드', 'url' => base_url(gov_portal_code_kinds_portal_path($activeVariant)), 'icon' => 'fa-code'],
['label' => '창고 재고 조회', 'desc' => '품목별 현재 재고', 'url' => base_url('bag/inventory'), 'icon' => 'fa-boxes-stacked'],
['label' => '발주(구매신청) 등록', 'desc' => '지정판매소 발주 입력', 'url' => base_url('bag/order/create'), 'icon' => 'fa-cart-shopping'],
['label' => '수불 흐름 보기', 'desc' => '입고·출고 내역', 'url' => base_url('bag/flow'), 'icon' => 'fa-arrow-right-arrow-left'],
['label' => '판매 내역 조회', 'desc' => '기간별 판매 현황', 'url' => base_url('bag/sales'), 'icon' => 'fa-receipt'],
['label' => '전년 대비 통계', 'desc' => '통계분석 · YoY', 'url' => base_url('bag/analytics/year-over-year'), 'icon' => 'fa-chart-line'],
['label' => '도움말 / 매뉴얼', 'desc' => '업무별 사용 안내', 'url' => base_url('bag/help'), 'icon' => 'fa-circle-question'],
],
'notices' => [
['title' => '봉투 단가 조정 예고 — 3/1 적용 예정', 'date' => '2026.05.12'],
['title' => '실사·재고 점검 일정 안내', 'date' => '2026.05.08'],
['title' => '지정판매소 바코드 연동 점검 완료', 'date' => '2026.04.29'],
],
'timeline' => [
['time' => '14:32', 'text' => 'GS25 검단점 — 일반 20L 판매 3건'],
['time' => '13:10', 'text' => '북구 창고 — 입고 확인 완료'],
['time' => '11:45', 'text' => '구매신청 #1042 승인 대기'],
['time' => '09:20', 'text' => '회원 가입 승인 1건 처리'],
],
'govMapPanel' => [
'centerLat' => 35.8714,
'centerLng' => 128.6014,
'zoom' => 11,
'markers' => [
['lat' => 35.8852, 'lng' => 128.5821, 'kind' => 'warehouse', 'title' => '북구 종량제 창고'],
['lat' => 35.8684, 'lng' => 128.6243, 'kind' => 'shop', 'title' => 'GS25 검단점'],
['lat' => 35.8621, 'lng' => 128.5948, 'kind' => 'shop', 'title' => 'CU 칠곡중앙점'],
['lat' => 35.8776, 'lng' => 128.6479, 'kind' => 'flow', 'title' => '동구 입고·출고 거점'],
['lat' => 35.8512, 'lng' => 128.6112, 'kind' => 'shop', 'title' => '이마트 월배점'],
],
],
'portalVariants' => [
['label' => '기본', 'url' => base_url('dashboard/gov-portal')],
['label' => '변형', 'url' => base_url('dashboard/gov-portal-strip')],
],
'siteNavTree' => $govNav['siteNavTree'],
'govNavItems' => $govNav['navItems'],
'menuSearchOptions' => gov_portal_menu_search_options($govNav['navItems']),
'govNavJson' => $govNav['navJson'],
'govActiveParentIdx' => $govNav['activeParentIdx'],
'govCurrentPath' => gov_portal_nav_match_path($govNav['currentPath']),
'govDashboardAliases' => $govNav['dashboardAliases'],
];
}
}
if (! function_exists('gov_portal_nav_partial_vars')) {
/**
* CI4 view() partial에 넘길 사이트 메뉴·네비 변수만 추출.
*
* @param array<string, mixed> $viewData
* @return array<string, mixed>
*/
function gov_portal_nav_partial_vars(array $viewData): array
{
$keys = [
'siteNavTree',
'govNavItems',
'govNavJson',
'govActiveParentIdx',
'govCurrentPath',
'govDashboardAliases',
'govActiveChildHref',
];
$out = [];
foreach ($keys as $key) {
if (array_key_exists($key, $viewData)) {
$out[$key] = $viewData[$key];
}
}
return $out;
}
}

View File

@@ -6,6 +6,8 @@ namespace App\Libraries;
/** /**
* 통계 분석 관리 (전년대비 / 월별·계절별 추이) * 통계 분석 관리 (전년대비 / 월별·계절별 추이)
*
* 월별·계절별 추이·전년대비: bs_type = sale 판매량·판매금액만 집계 (반품·취소 제외)
*/ */
class BagAnalyticsReportBuilder class BagAnalyticsReportBuilder
{ {
@@ -14,6 +16,12 @@ class BagAnalyticsReportBuilder
/** @var array<string, string> */ /** @var array<string, string> */
private array $bagNames = []; private array $bagNames = [];
/** 판매(bs_type=sale) 낱장 수량만 합산 */
private function saleQtySql(): string
{
return "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) ELSE 0 END";
}
/** /**
* @return array<string, array{label: string, months_label: string, months: list<int>, cross_year: bool}> * @return array<string, array{label: string, months_label: string, months: list<int>, cross_year: bool}>
*/ */
@@ -336,14 +344,11 @@ class BagAnalyticsReportBuilder
string $gugunCode, string $gugunCode,
int $dsIdx int $dsIdx
): array { ): array {
$saleQty = $this->saleQtySql();
$sql = " $sql = "
SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m, SELECT bs.bs_bag_code AS bag_code, YEAR(bs.bs_sale_date) AS y, MONTH(bs.bs_sale_date) AS m,
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) SUM({$saleQty}) AS sale_qty,
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty) SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount ELSE 0 END) AS sale_amt
ELSE 0 END) AS net_qty,
SUM(CASE WHEN bs.bs_type = 'sale' THEN bs.bs_amount
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_amount)
ELSE 0 END) AS net_amt
FROM bag_sale bs FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? WHERE bs.bs_lg_idx = ?
@@ -369,8 +374,8 @@ class BagAnalyticsReportBuilder
continue; continue;
} }
$agg[$code][$y][$m] = [ $agg[$code][$y][$m] = [
'qty' => (float) ($row['net_qty'] ?? 0), 'qty' => (float) ($row['sale_qty'] ?? 0),
'amt' => (float) ($row['net_amt'] ?? 0), 'amt' => (float) ($row['sale_amt'] ?? 0),
]; ];
} }
@@ -515,11 +520,10 @@ class BagAnalyticsReportBuilder
private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array private function monthlyNetByShop(int $lgIdx, int $year, int $month, string $gugunCode, int $saIdx = 0): array
{ {
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$saleQty = $this->saleQtySql();
$sql = " $sql = "
SELECT bs.bs_ds_idx AS ds_idx, SELECT bs.bs_ds_idx AS ds_idx,
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) SUM({$saleQty}) AS sale_qty
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END) AS net_qty
FROM bag_sale bs FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ? WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? AND MONTH(bs.bs_sale_date) = ?
@@ -538,7 +542,7 @@ class BagAnalyticsReportBuilder
$map = []; $map = [];
foreach ($this->db->query($sql, $params)->getResultArray() as $row) { foreach ($this->db->query($sql, $params)->getResultArray() as $row) {
$map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['net_qty'] ?? 0); $map[(int) ($row['ds_idx'] ?? 0)] = (float) ($row['sale_qty'] ?? 0);
} }
return $map; return $map;
@@ -560,11 +564,10 @@ class BagAnalyticsReportBuilder
} }
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
$saleQty = $this->saleQtySql();
$sql = " $sql = "
SELECT bs.bs_ds_idx AS ds_idx, SELECT bs.bs_ds_idx AS ds_idx,
SUM(CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) SUM({$saleQty}) / 12 AS avg_qty
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END) / 12 AS avg_qty
FROM bag_sale bs FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?
@@ -606,15 +609,13 @@ class BagAnalyticsReportBuilder
} }
$divisor = count($months); $divisor = count($months);
$qtyExpr = "CASE WHEN bs.bs_type = 'sale' THEN ABS(bs.bs_qty) $saleQty = $this->saleQtySql();
WHEN bs.bs_type IN ('return','cancel') THEN -ABS(bs.bs_qty)
ELSE 0 END";
$hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop'); $hasDsSa = $this->db->fieldExists('ds_sa_idx', 'designated_shop');
if ($crossYearWinter) { if ($crossYearWinter) {
$sql = " $sql = "
SELECT bs.bs_ds_idx AS ds_idx, SELECT bs.bs_ds_idx AS ds_idx,
SUM({$qtyExpr}) / ? AS avg_qty SUM({$saleQty}) / ? AS avg_qty
FROM bag_sale bs FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? WHERE bs.bs_lg_idx = ?
@@ -629,7 +630,7 @@ class BagAnalyticsReportBuilder
$placeholders = implode(',', array_fill(0, count($months), '?')); $placeholders = implode(',', array_fill(0, count($months), '?'));
$sql = " $sql = "
SELECT bs.bs_ds_idx AS ds_idx, SELECT bs.bs_ds_idx AS ds_idx,
SUM({$qtyExpr}) / ? AS avg_qty SUM({$saleQty}) / ? AS avg_qty
FROM bag_sale bs FROM bag_sale bs
INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx INNER JOIN designated_shop ds ON ds.ds_idx = bs.bs_ds_idx
WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ? WHERE bs.bs_lg_idx = ? AND YEAR(bs.bs_sale_date) = ?

View File

@@ -202,11 +202,21 @@ class BagLotFlowBuilder
return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty); return $this->resolvedFromPackRow($barcode, '박스', $first, 1, count($boxRows), $sheetQty);
} }
$exactSheet = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code', $barcode)
->limit(1)
->get()
->getRowArray();
if (is_array($exactSheet) && $exactSheet !== []) {
return $this->resolvedFromPackRow($barcode, '낱장', $exactSheet, 0, 0, 1);
}
$sheetRows = $this->db->table('bag_receiving_pack_code') $sheetRows = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx) ->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code <=', $barcode) ->where('brpc_sheet_start_code !=', '')
->where('brpc_sheet_end_code >=', $barcode) ->where('brpc_sheet_end_code !=', '')
->limit(50) ->limit(200)
->get() ->get()
->getResultArray(); ->getResultArray();
foreach ($sheetRows as $row) { foreach ($sheetRows as $row) {
@@ -217,9 +227,109 @@ class BagLotFlowBuilder
} }
} }
$fromScan = $this->resolveBarcodeFromScanTables($lgIdx, $barcode);
if ($fromScan !== null) {
return $fromScan;
}
return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.']; return ['ok' => false, 'message' => '등록되지 않은 바코드입니다.'];
} }
/**
* 판매·반품 스캔에만 있는 낱장 코드(입고 팩 테이블 미등록) 조회
*
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}|null
*/
private function resolveBarcodeFromScanTables(int $lgIdx, string $barcode): ?array
{
$bagCode = '';
$bagName = '';
if ($this->db->tableExists('bag_sale_scan_code')) {
$sale = $this->db->table('bag_sale_scan_code')
->select('bssc_bag_code, bssc_bag_name')
->where('bssc_lg_idx', $lgIdx)
->where('bssc_code', $barcode)
->orderBy('bssc_regdate', 'DESC')
->limit(1)
->get()
->getRowArray();
if (is_array($sale) && $sale !== []) {
$bagCode = (string) ($sale['bssc_bag_code'] ?? '');
$bagName = (string) ($sale['bssc_bag_name'] ?? '');
}
}
if ($bagCode === '' && $this->db->tableExists('bag_return_scan_code')) {
$ret = $this->db->table('bag_return_scan_code')
->select('brsc_bag_code, brsc_bag_name')
->where('brsc_lg_idx', $lgIdx)
->where('brsc_code', $barcode)
->orderBy('brsc_regdate', 'DESC')
->limit(1)
->get()
->getRowArray();
if (is_array($ret) && $ret !== []) {
$bagCode = (string) ($ret['brsc_bag_code'] ?? '');
$bagName = (string) ($ret['brsc_bag_name'] ?? '');
}
}
if ($bagCode === '' && $bagName === '') {
return null;
}
$packRow = $this->findPackRowContainingSheet($lgIdx, $barcode);
return [
'ok' => true,
'barcode' => $barcode,
'unit' => '낱장',
'bag_code' => $bagCode,
'bag_name' => $bagName,
'lot_no' => (string) ($packRow['brpc_lot_no'] ?? ''),
'box_code' => (string) ($packRow['brpc_box_code'] ?? ''),
'pack_code' => (string) ($packRow['brpc_pack_code'] ?? ''),
'pack_ids' => isset($packRow['brpc_idx']) ? [(int) $packRow['brpc_idx']] : [],
'qty_box' => 0,
'qty_pack' => 0,
'qty_sheet' => 1,
];
}
/**
* @return array<string, mixed>
*/
private function findPackRowContainingSheet(int $lgIdx, string $barcode): array
{
if (! $this->db->tableExists('bag_receiving_pack_code')) {
return [];
}
$exact = $this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code', $barcode)
->limit(1)
->get()
->getRowArray();
if (is_array($exact) && $exact !== []) {
return $exact;
}
foreach ($this->db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code !=', '')
->where('brpc_sheet_end_code !=', '')
->limit(200)
->get()
->getResultArray() as $row) {
$start = (string) ($row['brpc_sheet_start_code'] ?? '');
$end = (string) ($row['brpc_sheet_end_code'] ?? '');
if ($this->barcodeInRange($barcode, $start, $end)) {
return $row;
}
}
return [];
}
/** /**
* @param array<string, mixed> $pack * @param array<string, mixed> $pack
* @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int} * @return array{ok: true, barcode: string, unit: string, bag_code: string, bag_name: string, lot_no: string, box_code: string, pack_code: string, pack_ids: list<int>, qty_box: int, qty_pack: int, qty_sheet: int}
@@ -247,30 +357,62 @@ class BagLotFlowBuilder
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}> * @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/ */
private function collectFlowRows(int $lgIdx, array $resolved): array private function collectFlowRows(int $lgIdx, array $resolved): array
{
$unit = (string) ($resolved['unit'] ?? '');
return match ($unit) {
'낱장' => $this->collectFlowRowsForSheet($lgIdx, $resolved),
'박스' => $this->collectFlowRowsForBox($lgIdx, $resolved),
default => $this->collectFlowRowsForPack($lgIdx, $resolved),
};
}
/**
* 낱장: 해당 바코드 판매·반품만 + 소속 팩 입고 1건(발주·동일 LOT 전체 이력 제외)
*
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRowsForSheet(int $lgIdx, array $resolved): array
{ {
$rows = []; $rows = [];
$codes = [$resolved['barcode']]; $barcode = trim((string) ($resolved['barcode'] ?? ''));
if (($resolved['pack_code'] ?? '') !== '' && ! in_array($resolved['pack_code'], $codes, true)) { if ($barcode === '') {
$codes[] = $resolved['pack_code']; return [];
}
if (($resolved['box_code'] ?? '') !== '' && ! in_array($resolved['box_code'], $codes, true)) {
$codes[] = $resolved['box_code'];
} }
$brIdx = 0; $brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
if ($this->db->tableExists('bag_receiving_pack_code')) { if ($brIdx > 0) {
$packCode = (string) ($resolved['pack_code'] ?? ''); foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
if ($packCode !== '') { $rows[] = $ev;
$p = $this->db->table('bag_receiving_pack_code')
->select('brpc_br_idx')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_pack_code', $packCode)
->get()
->getRowArray();
$brIdx = (int) ($p['brpc_br_idx'] ?? 0);
} }
} }
foreach ($this->loadScanEventsForCodes($lgIdx, [$barcode]) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadReturnEventsForCodes($lgIdx, [$barcode]) as $ev) {
$rows[] = $ev;
}
return $rows;
}
/**
* 팩: 팩·낱장 바코드 스캔 + 입고 + LOT 발주
*
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRowsForPack(int $lgIdx, array $resolved): array
{
$rows = [];
$codes = array_values(array_unique(array_filter([
(string) ($resolved['barcode'] ?? ''),
(string) ($resolved['pack_code'] ?? ''),
], static fn (string $c): bool => $c !== '')));
$brIdx = $this->receivingBrIdxForPackCode($lgIdx, (string) ($resolved['pack_code'] ?? ''));
if ($brIdx > 0) { if ($brIdx > 0) {
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) { foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
$rows[] = $ev; $rows[] = $ev;
@@ -294,6 +436,91 @@ class BagLotFlowBuilder
return $rows; return $rows;
} }
/**
* 박스: 박스·소속 팩 코드 스캔 + 입고(박스 내 팩) + LOT 발주
*
* @param array<string, mixed> $resolved
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/
private function collectFlowRowsForBox(int $lgIdx, array $resolved): array
{
$rows = [];
$boxCode = (string) ($resolved['box_code'] ?? '');
$codes = array_values(array_unique(array_filter([
(string) ($resolved['barcode'] ?? ''),
$boxCode,
(string) ($resolved['pack_code'] ?? ''),
], static fn (string $c): bool => $c !== '')));
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
$packCodes = $this->db->table('bag_receiving_pack_code')
->select('brpc_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $boxCode)
->get()
->getResultArray();
foreach ($packCodes as $p) {
$pc = (string) ($p['brpc_pack_code'] ?? '');
if ($pc !== '' && ! in_array($pc, $codes, true)) {
$codes[] = $pc;
}
}
}
$seenBr = [];
if ($boxCode !== '' && $this->db->tableExists('bag_receiving_pack_code')) {
$brRows = $this->db->table('bag_receiving_pack_code')
->select('brpc_br_idx')
->distinct()
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $boxCode)
->get()
->getResultArray();
foreach ($brRows as $brRow) {
$brIdx = (int) ($brRow['brpc_br_idx'] ?? 0);
if ($brIdx <= 0 || isset($seenBr[$brIdx])) {
continue;
}
$seenBr[$brIdx] = true;
foreach ($this->loadReceivingEventsByBrIdx($lgIdx, $brIdx) as $ev) {
$rows[] = $ev;
}
}
}
foreach ($this->loadScanEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
foreach ($this->loadReturnEventsForCodes($lgIdx, $codes) as $ev) {
$rows[] = $ev;
}
$lotNo = (string) ($resolved['lot_no'] ?? '');
if ($lotNo !== '') {
foreach ($this->loadOrderEventsForLot($lgIdx, $lotNo) as $ev) {
$rows[] = $ev;
}
}
return $rows;
}
private function receivingBrIdxForPackCode(int $lgIdx, string $packCode): int
{
if ($packCode === '' || ! $this->db->tableExists('bag_receiving_pack_code')) {
return 0;
}
$p = $this->db->table('bag_receiving_pack_code')
->select('brpc_br_idx')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_pack_code', $packCode)
->get()
->getRowArray();
return (int) ($p['brpc_br_idx'] ?? 0);
}
/** /**
* @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}> * @return list<array{flow_date: string, counterparty: string, flow_type: string, sort_ts: int}>
*/ */

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
/**
* 봉투번호확인(번호알기) — 코드 입력 → 바코드·인쇄숫자·인식번호 분해.
* LOT-입고PK-팩/박스-낱장 형식 및 DB 등록 바코드 지원.
*/
class BagNumberLookup
{
/**
* @return array{
* ok: bool,
* message: string,
* input: string,
* barcode_text: string,
* print_text: string,
* recognition_text: string,
* unit: string,
* bag_code: string,
* bag_name: string
* }
*/
public function resolve(string $raw, ?int $lgIdx = null): array
{
$input = trim($raw);
if ($input === '') {
return $this->fail('코드를 입력해 주세요.', $input);
}
$normalized = strtoupper(preg_replace('/\s+/', '', $input) ?? $input);
$parsed = $this->parseStructuredCode($normalized);
if ($parsed === null && $lgIdx !== null) {
$parsed = $this->resolveFromDatabase($lgIdx, $normalized);
}
if ($parsed === null) {
return $this->fail('인식할 수 없는 코드입니다. 봉투 바코드(LOT-입고번호-팩/박스-낱장) 형식을 확인해 주세요.', $input);
}
return [
'ok' => true,
'message' => '',
'input' => $input,
'barcode_text' => $this->formatRow($parsed['barcode'], 4),
'print_text' => $this->formatRow($parsed['print'], 3),
'recognition_text' => $this->formatRow($parsed['recognition'], 2),
'unit' => (string) ($parsed['unit'] ?? ''),
'bag_code' => (string) ($parsed['bag_code'] ?? ''),
'bag_name' => (string) ($parsed['bag_name'] ?? ''),
];
}
/**
* @param list<string> $parts
*/
private function formatRow(array $parts, int $slots): string
{
$cells = array_pad($parts, $slots, '-');
foreach ($cells as $i => $cell) {
if ($cell === '' || $cell === null) {
$cells[$i] = '-';
}
}
return implode(' ', $cells);
}
/**
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
*/
private function parseStructuredCode(string $code): ?array
{
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})-S(\d+)$/i', $code, $m) === 1) {
$sheet = str_pad((string) $m[4], 5, '0', STR_PAD_LEFT);
return [
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], 'S' . $sheet],
'print' => [(string) (int) $m[2], (string) (int) $m[3], (string) (int) $sheet],
'recognition' => [(string) $m[2], 'P' . $m[3]],
'unit' => '낱장',
];
}
if (preg_match('/^([A-Z0-9]+)-(\d{6})-P(\d{3})$/i', $code, $m) === 1) {
return [
'barcode' => [(string) $m[1], (string) $m[2], 'P' . $m[3], '-'],
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
'recognition' => [(string) $m[2], 'P' . $m[3]],
'unit' => '팩',
];
}
if (preg_match('/^([A-Z0-9]+)-(\d{6})-B(\d{3})$/i', $code, $m) === 1) {
return [
'barcode' => [(string) $m[1], (string) $m[2], 'B' . $m[3], '-'],
'print' => [(string) (int) $m[2], (string) (int) $m[3], '-'],
'recognition' => [(string) $m[2], 'B' . $m[3]],
'unit' => '박스',
];
}
if (preg_match('/^([A-Z0-9]{4,8})$/i', $code) === 1) {
return [
'barcode' => [$code, '-', '-', '-'],
'print' => ['-', '-', '-'],
'recognition' => [$code, '-'],
'unit' => 'LOT',
];
}
return null;
}
/**
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code?:string,bag_name?:string}|null
*/
private function resolveFromDatabase(int $lgIdx, string $code): ?array
{
$db = \Config\Database::connect();
if (! $db->tableExists('bag_receiving_pack_code')) {
return null;
}
$row = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_pack_code', $code)
->get()
->getRowArray();
if (is_array($row) && $row !== []) {
return $this->parsedFromPackRow($code, $row, '팩');
}
$boxRows = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_box_code', $code)
->limit(1)
->get()
->getRowArray();
if (is_array($boxRows) && $boxRows !== []) {
return $this->parsedFromPackRow($code, $boxRows, '박스');
}
$sheetRow = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->where('brpc_sheet_start_code <=', $code)
->where('brpc_sheet_end_code >=', $code)
->limit(1)
->get()
->getRowArray();
if (is_array($sheetRow) && $sheetRow !== []) {
$parsed = $this->parseStructuredCode($code);
if ($parsed !== null) {
$parsed['bag_code'] = (string) ($sheetRow['brpc_bag_code'] ?? '');
$parsed['bag_name'] = (string) ($sheetRow['brpc_bag_name'] ?? '');
return $parsed;
}
}
$exactSheet = $db->table('bag_receiving_pack_code')
->where('brpc_lg_idx', $lgIdx)
->groupStart()
->where('brpc_sheet_start_code', $code)
->orWhere('brpc_sheet_end_code', $code)
->groupEnd()
->limit(1)
->get()
->getRowArray();
if (is_array($exactSheet) && $exactSheet !== []) {
$parsed = $this->parseStructuredCode($code);
if ($parsed !== null) {
$parsed['bag_code'] = (string) ($exactSheet['brpc_bag_code'] ?? '');
$parsed['bag_name'] = (string) ($exactSheet['brpc_bag_name'] ?? '');
return $parsed;
}
}
return null;
}
/**
* @param array<string, mixed> $row
* @return array{barcode:list<string>,print:list<string>,recognition:list<string>,unit:string,bag_code:string,bag_name:string}
*/
private function parsedFromPackRow(string $code, array $row, string $unit): array
{
$parsed = $this->parseStructuredCode($code);
if ($parsed === null) {
$parsed = [
'barcode' => [$code, '-', '-', '-'],
'print' => ['-', '-', '-'],
'recognition' => ['-', '-'],
'unit' => $unit,
];
}
$parsed['unit'] = $unit;
$parsed['bag_code'] = (string) ($row['brpc_bag_code'] ?? '');
$parsed['bag_name'] = (string) ($row['brpc_bag_name'] ?? '');
return $parsed;
}
/**
* @return array{ok:bool,message:string,input:string,barcode_text:string,print_text:string,recognition_text:string,unit:string,bag_code:string,bag_name:string}
*/
private function fail(string $message, string $input): array
{
return [
'ok' => false,
'message' => $message,
'input' => $input,
'barcode_text' => $this->formatRow([], 4),
'print_text' => $this->formatRow([], 3),
'recognition_text' => $this->formatRow([], 2),
'unit' => '',
'bag_code' => '',
'bag_name' => '',
];
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use App\Models\CodeDetailModel;
use App\Models\CodeKindModel;
use Config\Roles;
/**
* 공공 포털형 기본 코드관리 UI 시안 전용 데이터 (/bag/code-kinds 와 별도 URL, 동일 집계)
*/
class GovPortalCodeKindsPage
{
/**
* @return array<string, mixed>
*/
public function buildPageData(?int $lgIdx, int $level, ?int $adminLgIdx, int $selectedCkIdx, array $filters): array
{
$kindModel = model(CodeKindModel::class);
$detailModel = model(CodeDetailModel::class);
$kinds = [];
$countMap = [];
$selectedKind = null;
$detailList = [];
$rowCanEdit = [];
$qCode = trim((string) ($filters['q_code'] ?? ''));
$qName = trim((string) ($filters['q_name'] ?? ''));
$qState = (string) ($filters['q_state'] ?? '');
try {
$builder = $kindModel->orderBy('ck_code', 'ASC');
if ($qCode !== '') {
$builder->like('ck_code', $qCode);
}
if ($qName !== '') {
$builder->like('ck_name', $qName);
}
if ($qState === '1' || $qState === '0') {
$builder->where('ck_state', (int) $qState);
}
$kinds = $builder->findAll();
foreach ($kinds as $row) {
$countMap[$row->ck_idx] = (int) $detailModel->where('cd_ck_idx', $row->ck_idx)
->filterByTenantScope($lgIdx)
->countAllResults();
}
} catch (\Throwable $e) {
log_message('error', '[GovPortalCodeKinds] {type} {message}', [
'type' => $e::class,
'message' => $e->getMessage(),
]);
}
$canManageKinds = Roles::canManageCodeKindMaster($level);
$canManageDetails = Roles::canManageCodeMaster($level);
if ($kinds !== []) {
foreach ($kinds as $row) {
if ((int) $row->ck_idx === $selectedCkIdx) {
$selectedKind = $row;
break;
}
}
if ($selectedKind === null) {
$selectedKind = $kinds[0];
}
}
if ($selectedKind !== null) {
$detailList = $detailModel->where('cd_ck_idx', (int) $selectedKind->ck_idx)
->filterByTenantScope($lgIdx)
->orderBy('cd_sort', 'ASC')
->orderBy('cd_code', 'ASC')
->orderBy('cd_idx', 'ASC')
->findAll();
foreach ($detailList as $row) {
$rowCanEdit[$row->cd_idx] = Roles::canEditCodeDetailRow($level, $row, $adminLgIdx);
}
}
return [
'codeKinds' => $kinds,
'countMap' => $countMap,
'canManageKinds' => $canManageKinds,
'canManageDetails' => $canManageDetails,
'selectedKind' => $selectedKind,
'detailList' => $detailList,
'rowCanEdit' => $rowCanEdit,
'totalCount' => count($kinds),
'filters' => [
'q_code' => $qCode,
'q_name' => $qName,
'q_state' => $qState,
],
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Libraries;
use Config\Manual as ManualConfig;
use League\CommonMark\Exception\CommonMarkException;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* 사용자 매뉴얼(설명서) 렌더러.
*
* - 목차(manifest)는 Config\Manual 에서 가져온다.
* - slug → 파일 매핑은 화이트리스트(manifest)로만 결정한다(사용자 입력으로 파일명 조합 금지).
* - 마크다운은 GFM(표·코드블록)로 변환하며, md 내 raw HTML 은 이스케이프한다.
*/
class ManualRenderer
{
private ManualConfig $config;
private GithubFlavoredMarkdownConverter $converter;
public function __construct(?ManualConfig $config = null)
{
$this->config = $config ?? config(ManualConfig::class);
$this->converter = new GithubFlavoredMarkdownConverter([
// 콘텐츠 저자는 신뢰되지만, 사고 방지를 위해 정책을 명시 고정한다.
'html_input' => 'escape',
'allow_unsafe_links' => false,
'max_nesting_level' => 50,
]);
}
/**
* 목차(slug → title/file). 배열 순서가 노출 순서.
*
* @return array<string, array{title: string, file: string}>
*/
public function pages(): array
{
return $this->config->pages;
}
/** 목차의 첫 번째 slug (기본 진입 페이지). */
public function firstSlug(): string
{
return (string) (array_key_first($this->config->pages) ?? '');
}
/**
* slug 메타 조회. 화이트리스트에 없으면 null.
*
* @return array{title: string, file: string}|null
*/
public function find(string $slug): ?array
{
return $this->config->pages[$slug] ?? null;
}
/**
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
*/
public function render(string $slug): ?string
{
$page = $this->find($slug);
if ($page === null) {
return null;
}
$path = $this->resolvePath($page['file']);
if ($path === null) {
return null;
}
$markdown = @file_get_contents($path);
if ($markdown === false) {
return null;
}
try {
return (string) $this->converter->convert($markdown);
} catch (CommonMarkException) {
return null;
}
}
/**
* 파일명을 디렉터리 경계 안으로 안전하게 해석한다.
* manifest 의 고정 파일명만 받으며, realpath 가 $dir 하위인지 재검증한다.
*/
private function resolvePath(string $file): ?string
{
$baseReal = realpath(rtrim($this->config->dir, '/\\'));
if ($baseReal === false) {
return null;
}
$candidate = realpath($baseReal . DIRECTORY_SEPARATOR . $file);
if ($candidate === false || ! is_file($candidate)) {
return null;
}
$prefix = $baseReal . DIRECTORY_SEPARATOR;
if (! str_starts_with($candidate, $prefix)) {
return null;
}
return $candidate;
}
}

View File

@@ -157,6 +157,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<?php endif; ?> <?php endif; ?>
<form action="<?= base_url('admin/menus/delete/' . (int) $row->mm_idx) ?>" method="post" class="inline ml-1" onsubmit="return confirm('이 메뉴를 삭제하시겠습니까?');"> <form action="<?= base_url('admin/menus/delete/' . (int) $row->mm_idx) ?>" method="post" class="inline ml-1" onsubmit="return confirm('이 메뉴를 삭제하시겠습니까?');">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button> <button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
</form> </form>
</td> </td>
@@ -317,6 +318,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
formTitle.textContent = '메뉴 수정'; formTitle.textContent = '메뉴 수정';
btnSubmit.textContent = '수정'; btnSubmit.textContent = '수정';
btnCancel.style.display = 'inline-block'; btnCancel.style.display = 'inline-block';
// 수정 폼이 보이도록 스크롤 최상단으로 이동
window.scrollTo({ top: 0, behavior: 'smooth' });
const sc = document.querySelector('.main-content-area');
if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
}); });
}); });

View File

@@ -52,7 +52,7 @@ if ($bagName !== '' || $bagCode !== '') {
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
팩·박스·낱장 바코드 또는 LOT 번호(보조: <code class="text-xs">lot_no</code> 파라미터)로 조회합니다. 낱장 번호 조회 시 <strong>해당 장(바코드)의 판매·반품</strong>만 표시합니다. 팩·박스·LOT 조회는 해당 단위 이력입니다.
</p> </p>
</section> </section>
@@ -128,6 +128,8 @@ if ($bagName !== '' || $bagCode !== '') {
<div><dt class="text-gray-500 inline">LOT</dt> <div><dt class="text-gray-500 inline">LOT</dt>
<dd class="font-mono text-xs break-all"><?= esc($lotLabel) ?></dd></div> <dd class="font-mono text-xs break-all"><?= esc($lotLabel) ?></dd></div>
<?php endif; ?> <?php endif; ?>
<div><dt class="text-gray-500 inline">봉투번호</dt>
<dd class="font-mono text-xs break-all"><?= esc($barcode !== '' ? $barcode : '-') ?></dd></div>
<?php if ($unit !== ''): ?> <?php if ($unit !== ''): ?>
<div><dt class="text-gray-500 inline">조회단위</dt> <div><dt class="text-gray-500 inline">조회단위</dt>
<dd><?= esc($unit) ?></dd></div> <dd><?= esc($unit) ?></dd></div>

View File

@@ -4,7 +4,15 @@ $endDate = (string) ($endDate ?? date('Y-m-d'));
$ioType = (string) ($ioType ?? 'out'); $ioType = (string) ($ioType ?? 'out');
$result = is_array($result ?? null) ? $result : (array) ($result ?? []); $result = is_array($result ?? null) ? $result : (array) ($result ?? []);
$queried = (bool) ($queried ?? false); $queried = (bool) ($queried ?? false);
$exportQuery = (string) ($exportQuery ?? 'search=1'); $exportParams = $queried ? array_filter([
'search' => '1',
'start_date' => $startDate,
'end_date' => $endDate,
'io_type' => $ioType,
], static fn ($v) => $v !== null && $v !== '') : [];
$excelUrl = $exportParams !== []
? mgmt_url('reports/returns/export') . '?' . http_build_query($exportParams)
: '';
$fmtKrDate = static function (string $ymd): string { $fmtKrDate = static function (string $ymd): string {
$ts = strtotime($ymd); $ts = strtotime($ymd);
@@ -23,6 +31,7 @@ $printExtraLines = [
$typeLabel = static function (string $bsType): string { $typeLabel = static function (string $bsType): string {
return match ($bsType) { return match ($bsType) {
'return' => '반품', 'return' => '반품',
'dispose' => '파기',
'cancel' => '파기', 'cancel' => '파기',
default => $bsType, default => $bsType,
}; };
@@ -42,6 +51,11 @@ $totalQty = 0;
foreach ($result as $row) { foreach ($result as $row) {
$totalQty += (int) ($row->qty ?? 0); $totalQty += (int) ($row->qty ?? 0);
} }
$tipPage = "지정판매소 반품·물류 입고분 파기 내역을 기간·입출고 구분으로 조회합니다.\n"
. "· 출고: 지정판매소 반품 등록 화면에서 처리된 반품\n"
. "· 입고: 물류 창고 입고분 파기 처리 내역\n"
. "조회 후 표·엑셀·인쇄에 반영됩니다.";
?> ?>
<?= view('components/print_header', [ <?= view('components/print_header', [
'printTitle' => '반품 / 파기 현황', 'printTitle' => '반품 / 파기 현황',
@@ -50,10 +64,13 @@ foreach ($result as $row) {
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print"> <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 inline-flex items-center gap-1">
반품/파기 현황
<?= view('components/field_tooltip', ['text' => $tipPage, 'placement' => 'below']) ?>
</span>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<?php if ($queried && $exportQuery !== ''): ?> <?php if ($excelUrl !== ''): ?>
<a href="<?= mgmt_url('reports/returns/export?' . esc($exportQuery, 'attr')) ?>" <a href="<?= esc($excelUrl, 'attr') ?>" target="_blank" rel="noopener noreferrer"
class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a> class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a>
<?php else: ?> <?php else: ?>
<span class="bg-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm cursor-not-allowed" title="조회 후 이용">엑셀저장</span> <span class="bg-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm cursor-not-allowed" title="조회 후 이용">엑셀저장</span>
@@ -89,9 +106,6 @@ foreach ($result as $row) {
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
<p class="text-xs text-gray-500 mt-1">
<strong>입고</strong> = 지정판매소 반품(재고 복귀), <strong>출고</strong> = 판매 취소·파기 처리. 조회 후 표·엑셀·인쇄에 반영됩니다.
</p>
</section> </section>
<?php if (! $queried): ?> <?php if (! $queried): ?>
@@ -138,6 +152,26 @@ foreach ($result as $row) {
</div> </div>
<style> <style>
.field-tip { position: relative; display: inline-flex; vertical-align: middle; }
.field-tip-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 14px; height: 14px; font-size: 10px; font-weight: 700; line-height: 1;
color: #6b7280; background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 50%;
cursor: help; user-select: none;
}
.field-tip-btn:hover, .field-tip-btn:focus { color: #1d4ed8; border-color: #93c5fd; background: #eff6ff; outline: none; }
.field-tip-panel {
position: absolute; z-index: 60; left: 50%; transform: translateX(-50%);
bottom: calc(100% + 6px); width: max-content; max-width: 280px;
padding: 0.35rem 0.5rem; border-radius: 4px;
background: #1f2937; color: #f9fafb; font-size: 11px; font-weight: 500; line-height: 1.35;
text-align: left; white-space: pre-line; box-shadow: 0 2px 8px rgba(0,0,0,.15);
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .12s, visibility .12s;
}
.field-tip--below .field-tip-panel { bottom: auto; top: calc(100% + 6px); }
.field-tip:hover .field-tip-panel,
.field-tip:focus-within .field-tip-panel { opacity: 1; visibility: visible; }
@media print { @media print {
.no-print { display: none !important; } .no-print { display: none !important; }
} }

View File

@@ -14,6 +14,17 @@ $fmtKrRef = static function (string $ymd): string {
return $ts ? date('Y.m.d', $ts) . ' 현재' : $ymd; return $ts ? date('Y.m.d', $ts) . ' 현재' : $ymd;
}; };
/** 툴팁: 의미 + 계산(간단) */
$tipPage = "봉투 품목별로 재고가 며칠 버티는지, 언제·얼마나 발주할지 보는 수급·발주 계획표입니다.";
$tipLead = "의미: 발주 후 입고까지 걸리는 제작기일(일). 재고 소진 전에 발주하려는 여유.\n계산: 발주예정일 = 기준일 + 소진일수 보유일수";
$tipStock = "의미: 표에 넣을 현재고·총재고 범위.\n기존=바코드 미등록(수기), 바코드=등록 품목.";
$tipSales = "의미: 소진일수에 쓸 판매 속도 범위.\n최근 12개월 순판매(또는 바코드 판매) 월평균.";
$tipTotal = "의미: 지금·곧 쓸 수 있는 재고 합계.\n계산: 현재고 + 입고예정량";
$tipMonth = "의미: 요즘 한 달 판매 규모(평균).\n최근 12개월 월평균 판매량.";
$tipDepl = "의미: 이 판매 속도면 재고가 며칠 남는지.\n계산: (총재고 ÷ 월판매량) × 30";
$tipSched = "의미: 발주를 넣기 좋은 날(제작기일 반영).\n계산: 기준일 + 소진일수 보유일수. 기한 지남=빨간색·긴급";
$tipOrder = "의미: 그 시점에 맞춰 제안하는 추가 발주 장수.\n촉박하거나 발주예정일이 지난 품목만 표시.";
$printExtraLines = [ $printExtraLines = [
$fmtKrRef($refDate), $fmtKrRef($refDate),
'적정재고 보유일수(제작기일): ' . $leadDays . '일', '적정재고 보유일수(제작기일): ' . $leadDays . '일',
@@ -28,7 +39,10 @@ $printExtraLines = [
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print"> <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 inline-flex items-center gap-1">
쓰레기봉투 수급 계획
<?= view('components/field_tooltip', ['text' => $tipPage, 'placement' => 'below']) ?>
</span>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<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">인쇄</button> <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">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a> <a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
@@ -47,14 +61,19 @@ $printExtraLines = [
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<label class="font-bold text-gray-700 whitespace-nowrap">적정재고 보유일수</label> <label class="font-bold text-gray-700 whitespace-nowrap inline-flex items-center gap-0.5">
적정재고 보유일수
<?= view('components/field_tooltip', ['text' => $tipLead]) ?>
</label>
<input type="number" name="lead_days" value="<?= (int) $leadDays ?>" min="1" max="365" <input type="number" name="lead_days" value="<?= (int) $leadDays ?>" min="1" max="365"
class="border border-gray-300 rounded px-2 py-1 w-20 text-right" title="제작기일(발주예정일 산정)"/> class="border border-gray-300 rounded px-2 py-1 w-20 text-right"/>
<span class="text-blue-700 text-xs">※ 제작기일 <?= (int) $leadDays ?>일 기준으로 발주예정일 산정</span>
</div> </div>
<fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0"> <fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0">
<legend class="font-bold text-gray-700 whitespace-nowrap mr-1">현재고 선택 옵션</legend> <legend class="font-bold text-gray-700 whitespace-nowrap mr-1 inline-flex items-center gap-0.5">
현재고 선택 옵션
<?= view('components/field_tooltip', ['text' => $tipStock]) ?>
</legend>
<?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?> <?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?>
<label class="inline-flex items-center gap-1"> <label class="inline-flex items-center gap-1">
<input type="radio" name="stock_scope" value="<?= esc($val) ?>" <?= $stockScope === $val ? 'checked' : '' ?>/> <input type="radio" name="stock_scope" value="<?= esc($val) ?>" <?= $stockScope === $val ? 'checked' : '' ?>/>
@@ -64,7 +83,10 @@ $printExtraLines = [
</fieldset> </fieldset>
<fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0"> <fieldset class="flex flex-wrap items-center gap-2 border-0 p-0 m-0">
<legend class="font-bold text-gray-700 whitespace-nowrap mr-1">월 평균판매량 선택 옵션</legend> <legend class="font-bold text-gray-700 whitespace-nowrap mr-1 inline-flex items-center gap-0.5">
월 평균판매량 선택 옵션
<?= view('components/field_tooltip', ['text' => $tipSales]) ?>
</legend>
<?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?> <?php foreach (['all' => 'ALL', 'legacy' => '기존 봉투', 'barcode' => '바코드 봉투'] as $val => $lab): ?>
<label class="inline-flex items-center gap-1"> <label class="inline-flex items-center gap-1">
<input type="radio" name="sales_scope" value="<?= esc($val) ?>" <?= $salesScope === $val ? 'checked' : '' ?>/> <input type="radio" name="sales_scope" value="<?= esc($val) ?>" <?= $salesScope === $val ? 'checked' : '' ?>/>
@@ -75,12 +97,6 @@ $printExtraLines = [
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
<p class="text-xs text-gray-500 mt-2 max-w-4xl">
<strong>기존 봉투</strong> = 입고 팩 바코드 미등록 품목(수기 재고),
<strong>바코드 봉투</strong> = <code class="text-xs">bag_receiving_pack_code</code> 등록 품목.
월판매량은 최근 12개월 순판매(또는 바코드 판매 스캔)의 월평균입니다.
소진일수 = (총재고÷월판매량)×30, 발주예정일 = 기준일+소진일수−보유일수, 과거일은 빨간색.
</p>
</section> </section>
<?php if (! $queried): ?> <?php if (! $queried): ?>
@@ -105,11 +121,21 @@ $printExtraLines = [
<th class="sp-col-num text-right">발주시재고</th> <th class="sp-col-num text-right">발주시재고</th>
<th class="sp-col-num text-right border-l">현재고</th> <th class="sp-col-num text-right border-l">현재고</th>
<th class="sp-col-num text-right">입고예정량</th> <th class="sp-col-num text-right">입고예정량</th>
<th class="sp-col-num text-right">총재고</th> <th class="sp-col-num text-right">
<th class="sp-col-num text-right">월판매량</th> <span class="inline-flex items-center justify-end gap-0.5 w-full">총재고<?= view('components/field_tooltip', ['text' => $tipTotal, 'placement' => 'below']) ?></span>
<th class="sp-col-num text-right">소진일수(일)</th> </th>
<th class="sp-col-date text-center border-l">발주예정일</th> <th class="sp-col-num text-right">
<th class="sp-col-num text-right">발주수량</th> <span class="inline-flex items-center justify-end gap-0.5 w-full">월판매량<?= view('components/field_tooltip', ['text' => $tipMonth, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-num text-right">
<span class="inline-flex items-center justify-end gap-0.5 w-full">소진일수(일)<?= view('components/field_tooltip', ['text' => $tipDepl, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-date text-center border-l">
<span class="inline-flex items-center justify-center gap-0.5">발주예정일<?= view('components/field_tooltip', ['text' => $tipSched, 'placement' => 'below']) ?></span>
</th>
<th class="sp-col-num text-right">
<span class="inline-flex items-center justify-end gap-0.5 w-full">발주수량<?= view('components/field_tooltip', ['text' => $tipOrder, 'placement' => 'below']) ?></span>
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -154,7 +180,28 @@ $printExtraLines = [
</div> </div>
<style> <style>
.supply-plan-table thead th { font-size: 0.75rem; line-height: 1.2; padding: 0.35rem 0.25rem; } .field-tip { position: relative; display: inline-flex; vertical-align: middle; }
.field-tip-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 14px; height: 14px; font-size: 10px; font-weight: 700; line-height: 1;
color: #6b7280; background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 50%;
cursor: help; user-select: none;
}
.field-tip-btn:hover, .field-tip-btn:focus { color: #1d4ed8; border-color: #93c5fd; background: #eff6ff; outline: none; }
.field-tip-panel {
position: absolute; z-index: 60; left: 50%; transform: translateX(-50%);
bottom: calc(100% + 6px); width: max-content; max-width: 280px;
padding: 0.35rem 0.5rem; border-radius: 4px;
background: #1f2937; color: #f9fafb; font-size: 11px; font-weight: 500; line-height: 1.35;
text-align: left; white-space: pre-line; box-shadow: 0 2px 8px rgba(0,0,0,.15);
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .12s, visibility .12s;
}
.field-tip--below .field-tip-panel { bottom: auto; top: calc(100% + 6px); }
.field-tip:hover .field-tip-panel,
.field-tip:focus-within .field-tip-panel { opacity: 1; visibility: visible; }
.supply-plan-table thead th { font-size: 0.75rem; line-height: 1.2; padding: 0.35rem 0.25rem; overflow: visible; }
.supply-plan-table thead th .field-tip-panel { max-width: 260px; }
.supply-plan-table tbody td { padding: 0.25rem 0.35rem; } .supply-plan-table tbody td { padding: 0.25rem 0.35rem; }
@media screen { @media screen {

View File

@@ -0,0 +1,168 @@
<?php
/**
* [개발용 임시] 판매관리·전화접수 화면 하단에 끼우는 "전체 처리 흐름" 표.
*
* - 통합 데이터 출처:
* · shop_order + shop_order_item (단계: order) — 전화/일반 주문 접수
* · bag_sale_scan_code state=sold (단계: sale) — 지정판매소 판매 처리
* · bag_sale_scan_code state=in_stock (단계: returned) — 반품으로 재고 복귀
* - 호출 API: GET /bag/sale/dev-all-sales-history
* - 운영 배포 시 본 파일과 라우트/컨트롤러(`Bag::devAllSalesHistory`) 함께 제거.
*
* 다수 페이지에 동일하게 include 되므로 ID 충돌 회피를 위해 dev-all-sales- 접두어 사용.
*/
?>
<section id="dev-all-sales-panel" class="border border-amber-400 bg-amber-50/50 p-3 mt-3 rounded-sm">
<div class="flex flex-wrap items-center justify-between gap-2 mb-2">
<p class="text-xs text-amber-900 leading-relaxed m-0">
<strong class="text-amber-950">[개발용 임시 — 전체 처리 흐름]</strong>
현재 지자체의 <strong>주문 접수(order) · 판매 처리(sale) · 반품 복귀(returned)</strong>를 모두 모아
<strong>일시 역순 최근 500건</strong>으로 표시합니다. (일시는 DB UTC 기준을 <strong>한국 표준시(KST)</strong>로 변환)
운영 배포 시 이 블록을 제거해 주세요.
</p>
<div class="flex items-center gap-2">
<span id="dev-all-sales-count" class="text-[13px] text-amber-900"></span>
<button type="button" id="dev-all-sales-refresh" class="text-[13px] border border-amber-500 text-amber-800 bg-white rounded px-2 py-0.5 hover:bg-amber-100">새로고침</button>
</div>
</div>
<div class="max-h-80 overflow-auto border border-amber-300 bg-white">
<table class="w-full data-table text-xs">
<thead class="sticky top-0 bg-amber-100 z-10">
<tr>
<th class="w-40">일시</th>
<th class="w-24">단계</th>
<th>지정판매소</th>
<th class="w-14">주문</th>
<th>봉투 종류</th>
<th class="w-44">봉투 바코드</th>
<th class="w-14">포장</th>
<th class="w-14">수량</th>
</tr>
</thead>
<tbody id="dev-all-sales-tbody">
<tr><td colspan="8" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>
</tbody>
</table>
</div>
</section>
<script>
(function () {
// base_url() 은 .env 의 app.baseURL 기준 절대 URL을 만든다. 사용자가 다른 host(예: localhost)로
// 접속한 경우 cross-origin 으로 가 세션 쿠키가 빠진다. 페이지 origin과 동일하도록 path 부분만 사용.
const apiPath = '<?= parse_url(base_url('bag/sale/dev-all-sales-history'), PHP_URL_PATH) ?>';
const api = apiPath || '/bag/sale/dev-all-sales-history';
const tbody = document.getElementById('dev-all-sales-tbody');
const countEl = document.getElementById('dev-all-sales-count');
const refreshBtn = document.getElementById('dev-all-sales-refresh');
if (!tbody) return;
const nf = new Intl.NumberFormat('ko-KR');
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
function stageBadge(type) {
const t = String(type || '').toLowerCase();
if (t === 'order') {
return '<span class="text-[12px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-800">주문 접수</span>';
}
if (t === 'sale') {
return '<span class="text-[12px] px-1.5 py-0.5 rounded bg-rose-100 text-rose-800">판매</span>';
}
if (t === 'returned') {
return '<span class="text-[12px] px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-800">반품 복귀</span>';
}
return `<span class="text-[12px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-700">${escapeHtml(type)}</span>`;
}
function rowClass(type) {
const t = String(type || '').toLowerCase();
if (t === 'returned') return 'text-gray-500';
return '';
}
function dash(s) {
return (s == null || String(s) === '') ? '<span class="text-gray-300">-</span>' : escapeHtml(s);
}
function renderRows(rows) {
if (!Array.isArray(rows) || rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">처리 내역이 없습니다.</td></tr>';
return;
}
const html = rows.map((r) => {
const shopText = (r.ds_name && String(r.ds_name).trim() !== '')
? `${escapeHtml(r.ds_shop_no || '')} ${escapeHtml(r.ds_name)}`.trim()
: `판매소#${escapeHtml(r.ds_idx || '0')}`;
const bagText = (r.bag_code || r.bag_name)
? `${escapeHtml(r.bag_code || '')} ${escapeHtml(r.bag_name || '')}`.trim()
: '<span class="text-gray-300">-</span>';
return `
<tr class="${rowClass(r.event_type)}">
<td class="text-center whitespace-nowrap">${escapeHtml(r.event_time || '')}</td>
<td class="text-center">${stageBadge(r.event_type)}</td>
<td class="text-left pl-1">${shopText}</td>
<td class="text-center">${escapeHtml(r.so_idx || '')}</td>
<td class="text-left pl-1">${bagText}</td>
<td class="text-center font-mono">${dash(r.code)}</td>
<td class="text-center">${dash(r.unit)}</td>
<td class="text-right pr-1 tabular-nums">${Number(r.qty || 0) ? nf.format(Number(r.qty)) : '<span class="text-gray-300">-</span>'}</td>
</tr>`;
}).join('');
tbody.innerHTML = html;
}
async function load() {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-gray-400 py-4">불러오는 중…</td></tr>';
countEl.textContent = '';
try {
const url = api + (api.indexOf('?') >= 0 ? '&' : '?') + '_=' + Date.now();
const res = await fetch(url, {
credentials: 'same-origin',
cache: 'no-store',
headers: { 'Accept': 'application/json', 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' },
});
const data = await res.json();
if (!data || data.ok === false) {
const msg = (data && data.message) ? data.message : '내역을 불러오지 못했습니다.';
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-red-500 py-4">${escapeHtml(msg)}</td></tr>`;
return;
}
// ok=false (예: 지자체 미선택)일 때는 진단을 표 자리에 띄운다.
if (data.ok === false) {
const sess = data.session || {};
const lines = [
(data.message || '지자체를 선택해 주세요.'),
`세션: mb_idx=${sess.mb_idx ?? '-'} · mb_level=${sess.mb_level ?? '-'} · admin_selected_lg_idx=${sess.admin_selected_lg_idx ?? '-'} · mb_lg_idx=${sess.mb_lg_idx ?? '-'}`,
];
tbody.innerHTML = `<tr><td colspan="8" class="text-left px-2 py-4 text-amber-900"><div class="mb-1">${escapeHtml(lines[0])}</div><div class="text-[12px] text-gray-500 font-mono">${escapeHtml(lines[1])}</div></td></tr>`;
countEl.textContent = `lg_idx=- · 주문 0 / 판매 0 / 반품복귀 0 · 표시 0건`;
return;
}
renderRows(data.rows || []);
const orders = Number(data.orders ?? 0);
const sold = Number(data.sold ?? 0);
const ret = Number(data.returned ?? 0);
const shown = Array.isArray(data.rows) ? data.rows.length : 0;
const lg = data.lg_idx == null ? '-' : data.lg_idx;
const sess = data.session || {};
const sessText = `[mb_level=${sess.mb_level ?? '-'}, admin=${sess.admin_selected_lg_idx ?? '-'}, mb_lg=${sess.mb_lg_idx ?? '-'}]`;
countEl.textContent = `lg_idx=${lg} ${sessText} · 주문 ${nf.format(orders)} / 판매 ${nf.format(sold)} / 반품복귀 ${nf.format(ret)} · 표시 ${nf.format(shown)}건`;
} catch (e) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-red-500 py-4">통신 오류로 불러오지 못했습니다.</td></tr>';
}
}
refreshBtn?.addEventListener('click', () => load());
// 폼 제출(주문 저장·판매 저장 등) 직후 redirect 되어 새로 로드된 경우에도
// 항상 최신 상태를 보장한다. pageshow(BFCache 복원 포함)에서 한 번 더 호출.
window.addEventListener('pageshow', (ev) => {
if (ev.persisted) load();
});
load();
})();
</script>

View File

@@ -61,7 +61,7 @@ $prevAvgLabel = ($trendBasis ?? '') === 'month' ? '전년 동월' : '전년 평
</div> </div>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</form> </form>
<p class="text-xs text-gray-500 mt-1">(단위: 매) · <?= esc($prevAvgLabel) ?> 대비 기준월 판매량 편차를 표시합니다.</p> <p class="text-xs text-gray-500 mt-1">(단위: 매) · <strong>판매(sale)</strong> 수량만 집계합니다(반품·취소 제외). <?= esc($prevAvgLabel) ?> 대비 기준월 편차를 표시합니다.</p>
</section> </section>
<?php if ($error !== ''): ?> <?php if ($error !== ''): ?>

View File

@@ -54,7 +54,7 @@ $seasonScope = $seasonMonthsLabel !== ''
<p class="text-xs text-gray-500 mt-1"> <p class="text-xs text-gray-500 mt-1">
(단위: 매) · 계절을 바꾸면 자동 조회됩니다. (단위: 매) · 계절을 바꾸면 자동 조회됩니다.
<?php if ($queried && $seasonMonthsLabel !== ''): ?> <?php if ($queried && $seasonMonthsLabel !== ''): ?>
· 현재: <strong><?= esc($seasonScope) ?></strong> 판매 월평균(3개월 합÷3) vs 전년 동일 계절 · 현재: <strong><?= esc($seasonScope) ?></strong> <strong>판매(sale)</strong> 월평균(계절 3개월 합÷3, 반품·취소 제외) vs 전년 동일 계절
<?php endif; ?> <?php endif; ?>
</p> </p>
</section> </section>

View File

@@ -38,6 +38,7 @@ $printExtra = [
<div class="m-2 border border-gray-300 overflow-auto"> <div class="m-2 border border-gray-300 overflow-auto">
<p class="text-center text-sm font-bold py-2 bg-gray-50 border-b">전년대비 판매 통계분석</p> <p class="text-center text-sm font-bold py-2 bg-gray-50 border-b">전년대비 판매 통계분석</p>
<p class="text-xs text-gray-500 px-2 py-1 border-b">(단위: 매, 원) · <strong>판매(sale)</strong> 수량·금액만 집계합니다(반품·취소 제외). 증감은 전년·당해 판매량(또는 금액) 차이입니다.</p>
<table class="w-full data-table text-xs"> <table class="w-full data-table text-xs">
<thead> <thead>
<tr> <tr>

View File

@@ -9,8 +9,19 @@ $rows = is_array($rows ?? null) ? $rows : [];
$bagProducts = is_array($bagProducts ?? null) ? $bagProducts : []; $bagProducts = is_array($bagProducts ?? null) ? $bagProducts : [];
$bagKindOptions = is_array($bagKindOptions ?? null) ? $bagKindOptions : []; $bagKindOptions = is_array($bagKindOptions ?? null) ? $bagKindOptions : [];
$agencies = is_array($agencies ?? null) ? $agencies : []; $agencies = is_array($agencies ?? null) ? $agencies : [];
$exportQuery = (string) ($exportQuery ?? 'search=1');
$queried = (bool) ($queried ?? false); $queried = (bool) ($queried ?? false);
$exportParams = array_filter([
'search' => '1',
'start_date' => $startDate,
'end_date' => $endDate,
'agg_mode' => $aggMode,
'bag_code' => $bagCode,
'bag_kind' => $bagKind,
'sa_idx' => $saIdx > 0 ? (string) $saIdx : '',
], static fn ($v) => $v !== null && $v !== '');
$excelUrl = $queried
? base_url('bag/flow/export') . '?' . http_build_query($exportParams)
: '';
$fmt = static fn ($n): string => number_format((int) $n); $fmt = static fn ($n): string => number_format((int) $n);
$printExtraLines = []; $printExtraLines = [];
@@ -18,6 +29,13 @@ if ($queried) {
$aggLabel = $aggMode === 'daily' ? '일자별' : '기간별'; $aggLabel = $aggMode === 'daily' ? '일자별' : '기간별';
$printExtraLines[] = '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')'; $printExtraLines[] = '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')';
} }
$tipPage = "조회 기간 동안 봉투 품목별 입고·출고·잔량을 집계하는 수불표입니다.\n"
. "· 집계방식: 일자별(날짜마다) / 기간별(기간 합계)\n"
. "· 전일재고: 조회 시작일 전날 기준 재고(입고·반품·기타 출고 누적)\n"
. "· 입고: 입고·반품·기타 / 출고: 판매·일반·무료불출·반품·기타\n"
. "· 대행소 선택 시 판매 열만 해당 대행소 소속 판매소 기준\n"
. "조회 후 표·엑셀·인쇄에 반영됩니다.";
?> ?>
<div class="flow-print-sheet"> <div class="flow-print-sheet">
<?= view('components/print_header', [ <?= view('components/print_header', [
@@ -27,9 +45,17 @@ if ($queried) {
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel no-print"> <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 inline-flex items-center gap-1">
기간별 봉투 수불 현황
<?= view('components/field_tooltip', ['text' => $tipPage, 'placement' => 'below']) ?>
</span>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<a href="<?= base_url('bag/flow/export?' . esc($exportQuery, 'attr')) ?>" class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a> <?php if ($excelUrl !== ''): ?>
<a href="<?= esc($excelUrl, 'attr') ?>" target="_blank" rel="noopener noreferrer"
class="bg-green-700 text-white px-3 py-1 rounded-sm text-sm hover:bg-green-800">엑셀저장</a>
<?php else: ?>
<span class="bg-gray-300 text-gray-600 px-3 py-1 rounded-sm text-sm cursor-not-allowed" title="조회 후 이용">엑셀저장</span>
<?php endif; ?>
<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">인쇄</button> <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">인쇄</button>
<a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a> <a href="<?= base_url('dashboard') ?>" class="border border-gray-400 text-gray-700 px-3 py-1 rounded-sm text-sm hover:bg-gray-50">종료</a>
</div> </div>
@@ -103,7 +129,6 @@ if ($queried) {
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button> <button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
<a href="<?= base_url('bag/flow') ?>" class="text-gray-500 hover:text-gray-800 px-2">초기화</a> <a href="<?= base_url('bag/flow') ?>" class="text-gray-500 hover:text-gray-800 px-2">초기화</a>
</form> </form>
<p class="text-xs text-gray-500 mt-1">전일재고 = 조회 시작일 전날 기준 품목별 재고(입고·반품·기타 출고 누적). 대행소 선택 시 <strong>판매</strong>만 해당 대행소 소속 판매소 기준입니다.</p>
</section> </section>
<?php if (! $queried): ?> <?php if (! $queried): ?>
@@ -186,6 +211,26 @@ if ($queried) {
</div> </div>
<style> <style>
.field-tip { position: relative; display: inline-flex; vertical-align: middle; }
.field-tip-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 14px; height: 14px; font-size: 10px; font-weight: 700; line-height: 1;
color: #6b7280; background: #f3f4f6; border: 1px solid #d1d5db; border-radius: 50%;
cursor: help; user-select: none;
}
.field-tip-btn:hover, .field-tip-btn:focus { color: #1d4ed8; border-color: #93c5fd; background: #eff6ff; outline: none; }
.field-tip-panel {
position: absolute; z-index: 60; left: 50%; transform: translateX(-50%);
bottom: calc(100% + 6px); width: max-content; max-width: 300px;
padding: 0.35rem 0.5rem; border-radius: 4px;
background: #1f2937; color: #f9fafb; font-size: 11px; font-weight: 500; line-height: 1.35;
text-align: left; white-space: pre-line; box-shadow: 0 2px 8px rgba(0,0,0,.15);
opacity: 0; visibility: hidden; pointer-events: none; transition: opacity .12s, visibility .12s;
}
.field-tip--below .field-tip-panel { bottom: auto; top: calc(100% + 6px); }
.field-tip:hover .field-tip-panel,
.field-tip:focus-within .field-tip-panel { opacity: 1; visibility: visible; }
.flow-lbl-print { display: none; } .flow-lbl-print { display: none; }
@media screen { @media screen {

View File

@@ -2,6 +2,12 @@
<h2 class="text-lg font-bold text-gray-700 mb-4">도움말</h2> <h2 class="text-lg font-bold text-gray-700 mb-4">도움말</h2>
<div class="space-y-4 text-sm text-gray-600"> <div class="space-y-4 text-sm text-gray-600">
<section class="rounded-lg border border-blue-200 bg-blue-50 p-3">
<h3 class="font-bold text-blue-800 mb-1">📖 사용자 매뉴얼</h3>
<p>업무별 사용 방법을 단계별로 정리한 사용자 설명서입니다.</p>
<p class="mt-2"><a href="<?= base_url('bag/manual') ?>" class="text-blue-700 hover:underline font-semibold">사용자 매뉴얼 열기 &rarr;</a></p>
</section>
<section> <section>
<h3 class="font-bold text-gray-700 mb-1">시스템 개요</h3> <h3 class="font-bold text-gray-700 mb-1">시스템 개요</h3>
<p>종량제 시스템은 지자체 종량제 쓰레기봉투의 발주, 입고, 재고, 판매, 불출 전체 물류 프로세스를 관리합니다.</p> <p>종량제 시스템은 지자체 종량제 쓰레기봉투의 발주, 입고, 재고, 판매, 불출 전체 물류 프로세스를 관리합니다.</p>
@@ -23,6 +29,12 @@
</table> </table>
</section> </section>
<section>
<h3 class="font-bold text-gray-700 mb-1">번호알기</h3>
<p>봉투 바코드 코드를 입력하면 <strong>바코드</strong>·<strong>인쇄숫자</strong>·<strong>인식번호</strong> 확인할 있습니다.</p>
<p class="mt-2"><a href="<?= base_url('bag/number-lookup') ?>" class="text-blue-700 hover:underline font-semibold">봉투번호확인(번호알기) 열기</a></p>
</section>
<section> <section>
<h3 class="font-bold text-gray-700 mb-1">문의</h3> <h3 class="font-bold text-gray-700 mb-1">문의</h3>
<p>시스템 사용 문의사항은 소속 지자체 담당자에게 연락하시기 바랍니다.</p> <p>시스템 사용 문의사항은 소속 지자체 담당자에게 연락하시기 바랍니다.</p>

View File

@@ -173,6 +173,15 @@ $lowStock = [
<p class="text-[11px] text-gray-500 truncate">업무별 사용 방법 안내</p> <p class="text-[11px] text-gray-500 truncate">업무별 사용 방법 안내</p>
</div> </div>
</a> </a>
<a href="<?= base_url('dashboard/gov-portal') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-gray-200 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-slate-800 text-white flex items-center justify-center">
<i class="fa-solid fa-building-columns"></i>
</div>
<div class="min-w-0">
<p class="text-xs font-semibold text-gray-900 group-hover:text-blue-800">종량제 시스템 · 포털</p>
<p class="text-[11px] text-gray-500 truncate">기본 · 변형(strip) 시안</p>
</div>
</a>
<a href="<?= base_url('dashboard') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-dashed border-gray-300 hover:border-blue-500 hover:bg-blue-50/40 transition"> <a href="<?= base_url('dashboard') ?>" class="group flex items-center gap-3 px-3 py-2 rounded border border-dashed border-gray-300 hover:border-blue-500 hover:bg-blue-50/40 transition">
<div class="h-8 w-8 rounded-full bg-white text-gray-500 flex items-center justify-center"> <div class="h-8 w-8 rounded-full bg-white text-gray-500 flex items-center justify-center">
<i class="fa-solid fa-table-columns"></i> <i class="fa-solid fa-table-columns"></i>

92
app/Views/bag/manual.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* 사용자 매뉴얼 뷰.
*
* @var array<string, array{title:string,file:string}> $pages 목차
* @var string $current 현재 slug
* @var string $title 현재 페이지 제목
* @var string $body commonmark 로 변환된 HTML (신뢰된 콘텐츠)
*/
$pages = $pages ?? [];
$current = (string) ($current ?? '');
$title = (string) ($title ?? '사용자 매뉴얼');
$body = (string) ($body ?? '');
$slugs = array_keys($pages);
$pos = array_search($current, $slugs, true);
$prevSlug = ($pos !== false && $pos > 0) ? $slugs[$pos - 1] : null;
$nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : null;
?>
<style>
/* 매뉴얼 본문 스코프 스타일 (사이트 레이아웃 Tailwind 에 typography 플러그인 없음) */
.manual-prose { color: #1f2937; font-size: 0.95rem; line-height: 1.75; max-width: 52rem; }
.manual-prose h1 { font-size: 1.6rem; font-weight: 800; margin: 0 0 1rem; color: #111827; }
.manual-prose h2 { font-size: 1.25rem; font-weight: 700; margin: 1.8rem 0 0.7rem; padding-bottom: 0.35rem; border-bottom: 2px solid #e5e7eb; color: #1d4ed8; scroll-margin-top: 1rem; }
.manual-prose h3 { font-size: 1.05rem; font-weight: 700; margin: 1.3rem 0 0.5rem; color: #374151; }
.manual-prose p { margin: 0.6rem 0; }
.manual-prose ul, .manual-prose ol { margin: 0.6rem 0 0.6rem 1.4rem; }
.manual-prose ul { list-style: disc; }
.manual-prose ol { list-style: decimal; }
.manual-prose li { margin: 0.25rem 0; }
.manual-prose li > ul, .manual-prose li > ol { margin-top: 0.25rem; }
.manual-prose a { color: #1c4e80; text-decoration: underline; }
.manual-prose a:hover { color: #2563eb; }
.manual-prose strong { font-weight: 700; color: #111827; }
.manual-prose blockquote { margin: 0.9rem 0; padding: 0.6rem 1rem; border-left: 4px solid #60a5fa; background: #eff6ff; color: #1e3a8a; border-radius: 0 6px 6px 0; }
.manual-prose code { background: #f3f4f6; color: #b91c1c; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.manual-prose pre { background: #1e293b; color: #e2e8f0; padding: 0.9rem 1rem; border-radius: 8px; overflow-x: auto; margin: 0.9rem 0; font-size: 0.85rem; line-height: 1.6; }
.manual-prose pre code { background: transparent; color: inherit; padding: 0; }
.manual-prose table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: 0.875rem; }
.manual-prose th, .manual-prose td { border: 1px solid #d1d5db; padding: 0.45rem 0.7rem; text-align: left; vertical-align: top; }
.manual-prose th { background: #e9ecef; font-weight: 700; color: #333; }
.manual-prose tbody tr:nth-child(even) td { background: #f9fafb; }
.manual-prose hr { margin: 1.6rem 0; border: 0; border-top: 1px solid #e5e7eb; }
.manual-toc a.active { background: #1c4e80; color: #fff; font-weight: 700; }
@media print {
.manual-toc, .manual-actions, .manual-nav { display: none !important; }
.manual-layout { display: block !important; }
.manual-prose { max-width: none; }
}
</style>
<div class="manual-layout flex gap-6 items-start max-w-6xl mx-auto">
<!-- 좌측 목차 -->
<nav class="manual-toc no-print w-56 shrink-0 sticky top-0 self-start">
<div class="bg-title-bar text-white text-sm font-bold px-3 py-2 rounded-t">사용자 매뉴얼</div>
<ul class="border border-t-0 border-gray-300 rounded-b divide-y divide-gray-100 bg-white text-sm">
<?php foreach ($pages as $slug => $p): ?>
<li>
<a href="<?= base_url('bag/manual/' . $slug) ?>"
class="block px-3 py-2 text-gray-700 hover:bg-blue-50 <?= $slug === $current ? 'active' : '' ?>">
<?= esc($p['title']) ?>
</a>
</li>
<?php endforeach; ?>
</ul>
</nav>
<!-- 본문 -->
<div class="flex-grow min-w-0">
<div class="manual-actions no-print flex justify-end mb-3">
<button type="button" onclick="window.print()"
class="text-sm border border-gray-300 rounded px-3 py-1.5 text-gray-600 hover:bg-gray-50">
인쇄
</button>
</div>
<article class="manual-prose"><?= $body ?></article>
<!-- 이전/다음 -->
<div class="manual-nav no-print flex justify-between mt-8 pt-4 border-t border-gray-200 text-sm">
<?php if ($prevSlug !== null): ?>
<a href="<?= base_url('bag/manual/' . $prevSlug) ?>" class="text-blue-700 hover:underline">&larr; <?= esc($pages[$prevSlug]['title']) ?></a>
<?php else: ?>
<span></span>
<?php endif; ?>
<?php if ($nextSlug !== null): ?>
<a href="<?= base_url('bag/manual/' . $nextSlug) ?>" class="text-blue-700 hover:underline"><?= esc($pages[$nextSlug]['title']) ?> &rarr;</a>
<?php endif; ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
$code = (string) ($code ?? '');
$result = is_array($result ?? null) ? $result : null;
$barcodeText = (string) ($result['barcode_text'] ?? '- - - -');
$printText = (string) ($result['print_text'] ?? '- - -');
$recognition = (string) ($result['recognition_text'] ?? '- -');
$error = (string) ($error ?? ($result['ok'] ?? true ? '' : ($result['message'] ?? '')));
$unit = (string) ($result['unit'] ?? '');
$bagName = (string) ($result['bag_name'] ?? '');
$bagCode = (string) ($result['bag_code'] ?? '');
$hasResult = $result !== null && ($result['ok'] ?? false);
?>
<style>
.num-lookup-wrap {
max-width: 28rem;
margin: 1.5rem auto 2rem;
border: 1px solid #9ca3af;
background: #ece9d8;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.08);
font-family: 'Malgun Gothic', 'Noto Sans KR', sans-serif;
}
.num-lookup-title {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
background: linear-gradient(180deg, #3a6ea5 0%, #2c5282 100%);
color: #fff;
font-size: 0.8125rem;
font-weight: 700;
}
.num-lookup-body { padding: 0.85rem 0.9rem 1rem; }
.num-lookup-label {
display: block;
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.35rem;
}
.num-lookup-input {
width: 100%;
border: 1px solid #9ca3af;
background: #fff;
padding: 0.35rem 0.45rem;
font-size: 0.875rem;
font-family: inherit;
}
.num-lookup-input:focus {
outline: 1px solid #2563eb;
border-color: #2563eb;
}
.num-lookup-sep {
border: none;
border-top: 1px solid #b8b4a8;
margin: 0.85rem 0;
}
.num-lookup-row {
display: grid;
grid-template-columns: 5.5rem 1fr;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.55rem;
}
.num-lookup-row label {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
text-align: right;
}
.num-lookup-out {
min-height: 1.75rem;
border: 1px solid #9ca3af;
background: #fffef0;
padding: 0.35rem 0.5rem;
font-size: 0.875rem;
font-family: 'Consolas', 'Courier New', monospace;
letter-spacing: 0.04em;
color: #111827;
}
.num-lookup-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
margin-top: 0.75rem;
}
.num-lookup-btn {
border: 1px solid #6b7280;
background: linear-gradient(180deg, #f9fafb 0%, #e5e7eb 100%);
padding: 0.3rem 0.85rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
}
.num-lookup-btn-primary {
border-color: #1c4e80;
background: linear-gradient(180deg, #2b6cb0 0%, #1c4e80 100%);
color: #fff;
}
.num-lookup-meta {
margin-top: 0.65rem;
font-size: 0.75rem;
color: #4b5563;
line-height: 1.5;
}
.num-lookup-error {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #b91c1c;
}
.num-lookup-hint {
margin: 0.75rem auto 0;
max-width: 28rem;
font-size: 0.75rem;
color: #6b7280;
line-height: 1.5;
}
</style>
<div class="num-lookup-wrap" role="dialog" aria-labelledby="numLookupTitle">
<div class="num-lookup-title" id="numLookupTitle">
<i class="fa-solid fa-trash-can" aria-hidden="true"></i>
봉투번호확인
</div>
<form class="num-lookup-body" method="get" action="<?= site_url('bag/number-lookup') ?>" id="numLookupForm">
<label class="num-lookup-label" for="codeInput">코드 입력</label>
<input
type="text"
id="codeInput"
name="code"
class="num-lookup-input"
value="<?= esc($code) ?>"
autocomplete="off"
spellcheck="false"
placeholder="예: OQXCKH-000008-P299-S00125"
/>
<hr class="num-lookup-sep"/>
<div class="num-lookup-row">
<label for="barcodeOut">바코드</label>
<div id="barcodeOut" class="num-lookup-out" aria-live="polite"><?= esc($barcodeText) ?></div>
</div>
<div class="num-lookup-row">
<label for="printOut">인쇄숫자</label>
<div id="printOut" class="num-lookup-out" aria-live="polite"><?= esc($printText) ?></div>
</div>
<div class="num-lookup-row">
<label for="recognitionOut">인식번호</label>
<div id="recognitionOut" class="num-lookup-out" aria-live="polite"><?= esc($recognition) ?></div>
</div>
<?php if ($error !== ''): ?>
<p class="num-lookup-error" id="numLookupError"><?= esc($error) ?></p>
<?php else: ?>
<p class="num-lookup-error" id="numLookupError" hidden></p>
<?php endif; ?>
<?php if ($hasResult && ($unit !== '' || $bagName !== '')): ?>
<p class="num-lookup-meta" id="numLookupMeta">
<?php if ($unit !== ''): ?>단위: <?= esc($unit) ?><?php endif; ?>
<?php if ($bagName !== ''): ?> · <?= esc($bagName) ?><?= $bagCode !== '' ? ' (' . esc($bagCode) . ')' : '' ?><?php endif; ?>
</p>
<?php else: ?>
<p class="num-lookup-meta" id="numLookupMeta" hidden></p>
<?php endif; ?>
<div class="num-lookup-actions">
<button type="button" class="num-lookup-btn" id="numLookupReset">초기화</button>
<button type="submit" class="num-lookup-btn num-lookup-btn-primary">확인</button>
</div>
</form>
</div>
<p class="num-lookup-hint">
봉투 바코드·LOT·팩·낱장 코드를 입력하면 <strong>바코드</strong>(4칸), <strong>인쇄숫자</strong>(3칸), <strong>인식번호</strong>(2칸)로 나누어 표시합니다.
등록된 입고 바코드는 DB에서 품목명을 함께 조회합니다.
</p>
<div class="mx-auto mt-4 max-w-xl text-xs text-gray-700">
<table class="w-full border border-gray-300 border-collapse">
<thead>
<tr class="bg-gray-100">
<th class="border border-gray-300 px-2 py-1 text-left">단위</th>
<th class="border border-gray-300 px-2 py-1 text-left">입력 예시</th>
<th class="border border-gray-300 px-2 py-1 text-left">설명</th>
</tr>
</thead>
<tbody>
<tr>
<td class="border border-gray-300 px-2 py-1 align-top">LOT</td>
<td class="border border-gray-300 px-2 py-1 align-top"><code>OQXCKH</code></td>
<td class="border border-gray-300 px-2 py-1 align-top">
발주 LOT 번호만 입력합니다.<br>
바코드: <code>OQXCKH - - -</code><br>
인쇄숫자/인식번호는 LOT 기준으로만 표시됩니다.
</td>
</tr>
<tr>
<td class="border border-gray-300 px-2 py-1 align-top">박스</td>
<td class="border border-gray-300 px-2 py-1 align-top"><code>OQXCKH-000008-B001</code></td>
<td class="border border-gray-300 px-2 py-1 align-top">
박스 바코드(LOT-입고번호-B박스번호)를 그대로 입력합니다.<br>
바코드: <code>LOT 입고번호 B박스 -</code><br>
인쇄숫자: <code>입고번호 박스번호 -</code><br>
인식번호: <code>입고번호 B박스</code>
</td>
</tr>
<tr>
<td class="border border-gray-300 px-2 py-1 align-top">팩</td>
<td class="border border-gray-300 px-2 py-1 align-top"><code>OQXCKH-000008-P299</code></td>
<td class="border border-gray-300 px-2 py-1 align-top">
팩 바코드(LOT-입고번호-P팩번호)를 그대로 입력합니다.<br>
바코드: <code>LOT 입고번호 P팩 -</code><br>
인쇄숫자: <code>입고번호 팩번호 -</code><br>
인식번호: <code>입고번호 P팩</code>
</td>
</tr>
<tr>
<td class="border border-gray-300 px-2 py-1 align-top">낱장</td>
<td class="border border-gray-300 px-2 py-1 align-top"><code>OQXCKH-000008-P299-S00125</code></td>
<td class="border border-gray-300 px-2 py-1 align-top">
낱장 바코드(LOT-입고번호-P팩-S장번호)를 그대로 입력합니다.<br>
바코드: <code>LOT 입고번호 P팩 S장번호</code><br>
인쇄숫자: <code>입고번호 팩번호 장번호</code><br>
인식번호: <code>입고번호 P팩</code>
</td>
</tr>
</tbody>
</table>
</div>
<script>
(function () {
var form = document.getElementById('numLookupForm');
var input = document.getElementById('codeInput');
var resetBtn = document.getElementById('numLookupReset');
if (!form || !input) return;
var emptyBarcode = '- - - -';
var emptyPrint = '- - -';
var emptyRec = '- -';
function setOutputs(data) {
document.getElementById('barcodeOut').textContent = data.barcode_text || emptyBarcode;
document.getElementById('printOut').textContent = data.print_text || emptyPrint;
document.getElementById('recognitionOut').textContent = data.recognition_text || emptyRec;
var err = document.getElementById('numLookupError');
var meta = document.getElementById('numLookupMeta');
if (err) {
if (data.message) {
err.textContent = data.message;
err.hidden = false;
} else {
err.textContent = '';
err.hidden = true;
}
}
if (meta) {
var parts = [];
if (data.unit) parts.push('단위: ' + data.unit);
if (data.bag_name) {
var line = data.bag_name;
if (data.bag_code) line += ' (' + data.bag_code + ')';
parts.push(line);
}
if (parts.length) {
meta.textContent = parts.join(' · ');
meta.hidden = false;
} else {
meta.hidden = true;
}
}
}
function lookupAjax() {
var code = (input.value || '').trim();
if (!code) {
setOutputs({ message: '코드를 입력해 주세요.', barcode_text: emptyBarcode, print_text: emptyPrint, recognition_text: emptyRec });
return;
}
fetch('<?= site_url('bag/number-lookup/resolve') ?>', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
body: 'code=' + encodeURIComponent(code) + '&<?= csrf_token() ?>=' + encodeURIComponent('<?= csrf_hash() ?>')
})
.then(function (r) { return r.json(); })
.then(function (data) { setOutputs(data || {}); })
.catch(function () { setOutputs({ message: '조회 중 오류가 발생했습니다.' }); });
}
input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
lookupAjax();
}
});
if (resetBtn) {
resetBtn.addEventListener('click', function () {
input.value = '';
setOutputs({ barcode_text: emptyBarcode, print_text: emptyPrint, recognition_text: emptyRec });
input.focus();
});
}
})();
</script>

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
/** @var string $text */
$placement = (string) ($placement ?? 'above');
$wrapClass = 'field-tip' . ($placement === 'below' ? ' field-tip--below' : '');
?>
<span class="<?= esc($wrapClass, 'attr') ?>">
<span class="field-tip-btn" tabindex="0" aria-label="설명">ⓘ</span>
<span class="field-tip-panel" role="tooltip"><?= esc($text) ?></span>
</span>

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
/**
* 포털 헤더 브랜드 — components/header_brand 와 동일 SVG·문구
*
* @var string $brandHref
*/
$brandHref = $brandHref ?? base_url('dashboard/gov-portal');
?>
<a href="<?= esc($brandHref) ?>" class="gov-portal-brand">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M9 3a1 1 0 00-1 1v1H5.75a.75.75 0 000 1.5h12.5a.75.75 0 000-1.5H16V4a1 1 0 00-1-1H9zm9 4H6v11a2 2 0 002 2h8a2 2 0 002-2V7zM10 9a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0110 9zm4 0a.75.75 0 01.75.75v6a.75.75 0 01-1.5 0v-6A.75.75 0 0114 9z"/>
</svg>
<span>종량제 시스템</span>
</a>

View File

@@ -0,0 +1,13 @@
.gov-portal-brand {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.03em;
color: #fff;
text-decoration: none;
white-space: nowrap;
}
.gov-portal-brand:hover { color: #fff; opacity: 0.92; }
.gov-portal-brand svg { width: 24px; height: 24px; flex-shrink: 0; opacity: 0.95; }

View File

@@ -0,0 +1,15 @@
<script>
(function () {
var scale = 1;
var label = document.getElementById('fontZoomLabel');
var root = document.documentElement;
function applyScale() {
root.style.setProperty('--font-scale', String(scale));
if (label) label.textContent = Math.round(scale * 100) + '%';
}
var btnSm = document.getElementById('fontSmaller');
var btnLg = document.getElementById('fontLarger');
if (btnSm) btnSm.addEventListener('click', function () { scale = Math.max(0.85, scale - 0.05); applyScale(); });
if (btnLg) btnLg.addEventListener('click', function () { scale = Math.min(1.2, scale + 0.05); applyScale(); });
})();
</script>

View File

@@ -0,0 +1,3 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>종량제 시스템</title>

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/** @var string $activeVariant */
/** @var array $portalVariants */
/** @var string $mbName */
/** @var string $levelName */
/** @var string $lgLabel */
/** @var bool $showFontZoom */
$showFontZoom = $showFontZoom ?? true;
?>
<div class="portal-header-utils">
<?= view('home/_dashboard_gov_portal_variant_nav', ['portalVariants' => $portalVariants, 'activeVariant' => $activeVariant]) ?>
<span class="user-line"><?= esc($mbName) ?> · <?= esc($levelName) ?> · <?= esc($lgLabel) ?></span>
<?php if ($showFontZoom): ?>
<button type="button" class="extend-btn" onclick="alert('세션 연장(목업)')">시간연장</button>
<div class="font-zoom" title="글자 크기">
<button type="button" id="fontSmaller" aria-label="글자 작게"></button>
<span id="fontZoomLabel">100%</span>
<button type="button" id="fontLarger" aria-label="글자 크게">+</button>
</div>
<?php endif; ?>
<a href="<?= base_url('dashboard/simple') ?>" class="util-ico" title="기존 요약"><i class="fa-solid fa-table-columns"></i></a>
<a href="<?= base_url('logout') ?>" class="util-ico" title="로그아웃"><i class="fa-solid fa-right-from-bracket"></i></a>
</div>

View File

@@ -0,0 +1,71 @@
.portal-map-wrap {
position: relative;
width: 100%;
min-height: 200px;
background: #e8eef4;
}
.portal-map-wrap.is-fill { height: 100%; min-height: 220px; }
.portal-map-leaflet {
width: 100%;
height: 100%;
min-height: inherit;
z-index: 1;
}
.portal-map-wrap .leaflet-control-attribution {
font-size: 0.5625rem;
line-height: 1.2;
background: rgba(255, 255, 255, 0.85);
}
.portal-map-legend {
position: absolute;
left: 0.5rem;
bottom: 0.5rem;
z-index: 500;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.45rem;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
font-size: 0.5625rem;
font-weight: 600;
color: #444;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
pointer-events: none;
}
.portal-map-legend span::before {
content: '';
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-right: 0.2rem;
vertical-align: middle;
}
.portal-map-legend .lg-warehouse::before { background: #2563eb; }
.portal-map-legend .lg-shop::before { background: #10b981; }
.portal-map-legend .lg-flow::before { background: #f59e0b; }
.portal-map-lg-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 500;
background: rgba(255, 255, 255, 0.94);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 0.35rem 0.55rem;
font-size: 0.6875rem;
font-weight: 700;
color: #1a2b4b;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
pointer-events: none;
max-width: calc(100% - 1rem);
}
.portal-map-lg-badge small {
display: block;
font-size: 0.5625rem;
font-weight: 500;
color: #666;
margin-top: 0.1rem;
}

View File

@@ -0,0 +1,2 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="anonymous" referrerpolicy="no-referrer"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
/**
* OpenStreetMap(Leaflet) 목업 지도 — 지정판매소·창고·수불 지점 대략 표시
*
* @var string $lgLabel
* @var string $mapId
* @var string $mapHeight
* @var bool $mapFill
* @var array{centerLat:float,centerLng:float,zoom:int,markers:list<array>} $govMapPanel
*/
$mapId = $mapId ?? 'govPortalMap';
$mapHeight = $mapHeight ?? '200px';
$mapFill = $mapFill ?? false;
$govMapPanel = $govMapPanel ?? [
'centerLat' => 35.8714,
'centerLng' => 128.6014,
'zoom' => 11,
'markers' => [],
];
$wrapClass = 'portal-map-wrap' . ($mapFill ? ' is-fill' : '');
$wrapStyle = $mapFill ? '' : 'height:' . esc($mapHeight, 'attr') . ';';
?>
<div class="<?= esc($wrapClass, 'attr') ?>" id="<?= esc($mapId, 'attr') ?>-wrap"<?= $wrapStyle !== '' ? ' style="' . $wrapStyle . '"' : '' ?>>
<div class="portal-map-lg-badge">
<?= esc($lgLabel) ?>
<small>지정판매소·창고 (목업)</small>
</div>
<div id="<?= esc($mapId, 'attr') ?>" class="portal-map-leaflet" role="application" aria-label="<?= esc($lgLabel) ?> 판매·수불 지도"></div>
<div class="portal-map-legend" aria-hidden="true">
<span class="lg-warehouse">창고</span>
<span class="lg-shop">판매소</span>
<span class="lg-flow">수불</span>
</div>
</div>
<script>
(function () {
var mapId = <?= json_encode($mapId, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var cfg = <?= json_encode($govMapPanel, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
var lgLabel = <?= json_encode($lgLabel, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
function markerStyle(kind) {
if (kind === 'warehouse') return { color: '#2563eb', fill: '#3b82f6' };
if (kind === 'shop') return { color: '#059669', fill: '#10b981' };
return { color: '#d97706', fill: '#f59e0b' };
}
function initMap() {
var el = document.getElementById(mapId);
if (!el || typeof L === 'undefined') return;
if (el._govPortalMap) {
el._govPortalMap.remove();
el._govPortalMap = null;
}
var map = L.map(el, {
zoomControl: true,
scrollWheelZoom: false,
attributionControl: true,
}).setView([cfg.centerLat, cfg.centerLng], cfg.zoom || 11);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
}).addTo(map);
(cfg.markers || []).forEach(function (m) {
var st = markerStyle(m.kind || 'flow');
var circle = L.circleMarker([m.lat, m.lng], {
radius: 7,
color: st.color,
fillColor: st.fill,
fillOpacity: 0.85,
weight: 2,
}).addTo(map);
if (m.title) {
circle.bindPopup('<strong>' + m.title + '</strong>');
}
});
L.circleMarker([cfg.centerLat, cfg.centerLng], {
radius: 9,
color: '#1a2b4b',
fillColor: '#4a69bd',
fillOpacity: 0.9,
weight: 2,
}).addTo(map).bindPopup('<strong>' + lgLabel + '</strong><br>담당 지자체');
var bounds = [];
(cfg.markers || []).forEach(function (m) { bounds.push([m.lat, m.lng]); });
if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [28, 28], maxZoom: 13 });
}
el._govPortalMap = map;
setTimeout(function () { map.invalidateSize(); }, 120);
}
function boot() {
if (typeof L !== 'undefined') {
initMap();
return;
}
var el = document.getElementById(mapId);
if (el) {
el.innerHTML = '<div style="padding:1rem;text-align:center;font-size:12px;color:#666;">지도를 불러오지 못했습니다. 네트워크 연결을 확인해 주세요.</div>';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
</script>

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/**
* 메뉴검색 + 하단 바로가기 칩
*
* @var string $variant teal|inline
* @var string $inputId
* @var list<array{label:string,url:string,keyword:string}> $menuSearchOptions
*/
$variant = $variant ?? 'teal';
$inputId = $inputId ?? 'menuSearch';
$menuSearchOptions = $menuSearchOptions ?? [];
?>
<?php if ($variant === 'inline'): ?>
<div class="search-inline portal-menu-search-block">
<div class="search-inline-row">
<label for="<?= esc($inputId, 'attr') ?>"><i class="fa-solid fa-magnifying-glass"></i> 메뉴검색</label>
<input type="search" id="<?= esc($inputId, 'attr') ?>" placeholder="메뉴명 (예: 재고, 발주)" autocomplete="off"/>
</div>
<?php if ($menuSearchOptions !== []): ?>
<div class="portal-menu-search-options" aria-label="자주 찾는 메뉴">
<?php foreach ($menuSearchOptions as $opt): ?>
<a href="#" class="menu-search-chip" data-url="<?= esc($opt['url'], 'attr') ?>" data-keyword="<?= esc($opt['keyword'], 'attr') ?>" title="<?= esc($opt['label']) ?>"><?= esc($opt['label']) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="search-teal portal-menu-search-block">
<strong><i class="fa-solid fa-magnifying-glass"></i> 메뉴검색</strong>
<div class="search-wrap">
<input type="search" id="<?= esc($inputId, 'attr') ?>" placeholder="메뉴명 입력 (예: 재고, 발주, 통계)" autocomplete="off"/>
<i class="fa-solid fa-magnifying-glass"></i>
</div>
<?php if ($menuSearchOptions !== []): ?>
<div class="portal-menu-search-options" aria-label="자주 찾는 메뉴">
<?php foreach ($menuSearchOptions as $opt): ?>
<a href="#" class="menu-search-chip" data-url="<?= esc($opt['url'], 'attr') ?>" data-keyword="<?= esc($opt['keyword'], 'attr') ?>" title="<?= esc($opt['label']) ?>"><?= esc($opt['label']) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>

View File

@@ -0,0 +1,48 @@
.portal-menu-search-options {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
}
.menu-search-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
padding: 0.22rem 0.5rem;
border-radius: 999px;
font-size: 0.625rem;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.02em;
text-decoration: none;
cursor: pointer;
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search-teal .menu-search-chip,
.search-inline .menu-search-chip {
background: rgba(255, 255, 255, 0.22);
color: #fff;
border-color: rgba(255, 255, 255, 0.35);
}
.search-teal .menu-search-chip:hover,
.search-inline .menu-search-chip:hover {
background: #fff;
color: #00796b;
border-color: #fff;
}
.search-inline.portal-menu-search-block {
flex-direction: column;
align-items: stretch;
}
.search-inline.portal-menu-search-block .search-inline-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.search-inline.portal-menu-search-block input {
margin-top: 0;
}

View File

@@ -0,0 +1,118 @@
<script>
(function () {
var navData = <?= $govNavJson ?? '[]' ?>;
var activeIdx = <?= (int) ($govActiveParentIdx ?? 0) ?>;
var activeChildHref = <?= json_encode(strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/')), JSON_UNESCAPED_UNICODE) ?>;
var listEl = document.getElementById('portalSidebarList');
var titleEl = document.getElementById('portalSidebarTitle');
if (listEl && navData.length) {
function renderSidebar(idx) {
var parent = navData[idx];
if (!parent) return;
if (titleEl) titleEl.textContent = parent.name || 'MY MENU';
listEl.innerHTML = '';
var items = parent.children && parent.children.length ? parent.children : null;
if (!items && parent.href) {
items = [{ name: parent.name, url: parent.url, href: parent.href }];
}
if (!items || !items.length) {
var empty = document.createElement('li');
empty.innerHTML = '<span class="menu-sub" style="opacity:.65;">하위 메뉴 없음</span>';
listEl.appendChild(empty);
return;
}
items.forEach(function (child, ci) {
var li = document.createElement('li');
var chHref = (child.href || '').toLowerCase().replace(/^\//, '');
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
if (child.href) {
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' +
'<span class="menu-ico">' + (on ? '×' : '+') + '</span>' + child.name + '</a>';
} else {
li.innerHTML = '<span class="menu-sub" style="opacity:.65;"><span class="menu-ico">+</span>' + child.name + '</span>';
}
listEl.appendChild(li);
});
}
function setActiveTrigger(idx) {
document.querySelectorAll('.portal-nav-trigger, .portal-nav-link[data-parent-idx]').forEach(function (el) {
var n = parseInt(el.getAttribute('data-parent-idx'), 10);
var on = n === idx;
el.classList.toggle('is-active', on);
if (el.classList.contains('portal-nav-trigger')) {
el.setAttribute('aria-expanded', on ? 'true' : 'false');
}
});
}
document.querySelectorAll('.portal-nav-trigger[data-parent-idx]').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.preventDefault();
var idx = parseInt(btn.getAttribute('data-parent-idx'), 10);
setActiveTrigger(idx);
renderSidebar(idx);
});
});
setActiveTrigger(activeIdx);
renderSidebar(activeIdx);
}
var searchInput = document.getElementById('menuSearch') || document.getElementById('menuSearchStrip');
function runMenuSearch(q) {
if (!q || !navData.length) return false;
q = q.toLowerCase();
for (var p = 0; p < navData.length; p++) {
var par = navData[p];
if ((par.name || '').toLowerCase().indexOf(q) !== -1) {
if (listEl) {
document.querySelectorAll('.portal-nav-trigger[data-parent-idx]').forEach(function (btn) {
if (parseInt(btn.getAttribute('data-parent-idx'), 10) === p) btn.click();
});
}
return true;
}
var ch = par.children || [];
for (var i = 0; i < ch.length; i++) {
if ((ch[i].name || '').toLowerCase().indexOf(q) !== -1 && ch[i].url) {
window.location.href = ch[i].url;
return true;
}
}
}
return false;
}
if (searchInput && navData.length) {
searchInput.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
var q = (searchInput.value || '').trim();
if (!q) return;
if (!runMenuSearch(q)) {
alert('일치하는 메뉴가 없습니다.');
}
});
}
document.querySelectorAll('.menu-search-chip').forEach(function (chip) {
chip.addEventListener('click', function (e) {
e.preventDefault();
var url = chip.getAttribute('data-url') || '';
var kw = chip.getAttribute('data-keyword') || '';
if (url) {
window.location.href = url;
return;
}
if (searchInput && kw) {
searchInput.value = kw;
if (!runMenuSearch(kw)) {
alert('일치하는 메뉴가 없습니다.');
}
}
});
});
})();
</script>

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
/**
* @deprecated 데이터는 Home 컨트롤러의 gov_portal_dashboard_view_data()로 전달합니다.
* 하위 호환용: include 시 컨트롤러와 동일한 변수를 주입합니다.
*/
helper('admin');
foreach (gov_portal_dashboard_view_data($lgLabel ?? '북구', $activeVariant ?? 'base') as $k => $v) {
$$k = $v;
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/** @var list<array> $govNavItems */
/** @var int $govActiveParentIdx */
/** @var string $govActiveChildHref */
$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
$sidebarTitle = $activeParent['name'] ?? 'MY MENU';
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
?>
<aside class="sidebar">
<div class="my-menu-hd" id="portalSidebarTitle"><?= esc($sidebarTitle) ?></div>
<ul class="my-menu-list" id="portalSidebarList">
<?php if ($activeParent !== null): ?>
<?php if (! empty($activeParent['hasChildren'])): ?>
<?php foreach ($activeParent['children'] as $ci => $child): ?>
<?php
$childHref = strtolower(ltrim((string) ($child['href'] ?? ''), '/'));
$isChildActive = $activeChildHref !== ''
? ($childHref === $activeChildHref)
: ($ci === 0);
?>
<li>
<?php if ($child['href'] !== ''): ?>
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
<span class="menu-ico"><?= $isChildActive ? '×' : '+' ?></span>
<?= esc($child['name']) ?>
</a>
<?php else: ?>
<span class="menu-sub" style="opacity:.65;cursor:default;">
<span class="menu-ico">+</span><?= esc($child['name']) ?>
</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
<?php elseif ($activeParent['href'] !== ''): ?>
<li>
<a href="<?= esc($activeParent['url']) ?>" class="active">
<span class="menu-ico">×</span><?= esc($activeParent['name']) ?>
</a>
</li>
<?php endif; ?>
<?php endif; ?>
</ul>
<div class="sidebar-blocks">
<div class="sb-teal">
<i class="fa-solid fa-mobile-screen"></i>
모바일 앱(예정)<br/>지정판매소 판매·스캔 연동
</div>
<div class="sb-gray">
<i class="fa-solid fa-repeat"></i> 통합 전환<br/>
<a href="<?= base_url('admin/select-local-government') ?>" style="color:#fff;text-decoration:underline;">지자체 선택</a>
</div>
<div class="sb-links">
<a href="<?= base_url('bag/help') ?>">나의 할일</a>
<a href="<?= base_url('dashboard') ?>">종합·그래프</a>
<a href="<?= base_url('bag/help') ?>">FAQ</a>
</div>
</div>
</aside>

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* 재고 경보 4단계 + 단계별 봉투 종류(대략)
*
* @var list<array{count:int,label:string,class:string,bags:list<string>}> $stockAlerts
*/
?>
<div class="alert-levels">
<?php foreach ($stockAlerts as $alert): ?>
<div class="alert-box <?= esc($alert['class'], 'attr') ?>">
<div class="n"><?= (int) $alert['count'] ?></div>
<div class="t"><?= esc($alert['label']) ?></div>
<?php if (! empty($alert['bags'])): ?>
<ul class="alert-bags" aria-label="<?= esc($alert['label']) ?> 봉투 종류">
<?php foreach ($alert['bags'] as $bagName): ?>
<li><?= esc($bagName) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>

View File

@@ -0,0 +1,157 @@
.alert-levels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.alert-box {
text-align: center;
padding: 0.5rem 0.2rem 0.4375rem;
border-radius: 4px;
color: #fff;
font-weight: 700;
}
.alert-box .n { font-size: 1.25rem; line-height: 1.1; letter-spacing: -0.03em; }
.alert-box .t {
font-size: 0.6875rem;
font-weight: 500;
margin-top: 0.125rem;
letter-spacing: -0.02em;
}
.alert-box .alert-bags {
list-style: none;
margin: 0.35rem 0 0;
padding: 0;
width: 100%;
font-size: 0.5625rem;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.02em;
opacity: 0.92;
text-align: center;
}
.alert-box .alert-bags li {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0.05rem 0;
}
.al-yellow .alert-bags { color: #333; opacity: 1; }
.al-blue { background: #3498db; }
.al-yellow { background: #f1c40f; color: #333; }
.al-yellow .n, .al-yellow .t { color: #333; }
.al-orange { background: #f39c12; }
.al-red { background: #e74c3c; }
.bar-row { margin-bottom: 0.4375rem; }
.bar-row .meta {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--muted);
margin-bottom: 0.15rem;
letter-spacing: -0.02em;
}
.bar-track {
height: 7px;
background: #f1f5f9;
border-radius: 4px;
overflow: hidden;
}
.bar-fill { height: 100%; background: #f59e0b; border-radius: 4px; }
.card-low-stock {
display: flex;
flex-direction: column;
min-height: 100%;
}
.card-low-stock .card-bd {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.card-low-stock .low-stock-grid {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-evenly;
gap: 0.75rem;
min-height: 9.5rem;
}
.grid .card-low-stock.stock-tall .low-stock-grid {
min-height: 100%;
gap: 1rem;
}
.card-low-stock .bar-row {
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: 0;
min-height: 2.75rem;
}
.grid .card-low-stock.stock-tall .bar-row {
min-height: 3.25rem;
flex: 1 1 auto;
}
.card-low-stock .bar-row .meta { margin-bottom: 0.3rem; }
.grid .card-low-stock.stock-tall .bar-row .meta {
margin-bottom: 0.45rem;
font-size: 0.75rem;
}
.card-low-stock .bar-track {
flex: 1 1 auto;
height: auto;
min-height: 12px;
max-height: 2.25rem;
border-radius: 6px;
}
.grid .card-low-stock.stock-tall .bar-track {
min-height: 16px;
max-height: 3.25rem;
}
.card-low-stock .bar-fill { border-radius: 6px; }
.stock-pair.grid2 {
align-items: stretch;
}
.stock-pair.grid2 .card {
display: flex;
flex-direction: column;
min-height: 100%;
}
.stock-pair.grid2 .card-stock-alert .card-bd {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.stock-pair.grid2 .card-stock-alert .alert-levels {
flex: 1;
align-items: stretch;
min-height: 9.5rem;
}
.stock-pair.grid2 .card-stock-alert .alert-box {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
min-height: 100%;
padding: 0.625rem 0.3rem;
box-sizing: border-box;
border-radius: 6px;
}
.stock-pair.grid2 .card-stock-alert .alert-box .n {
font-size: 1.35rem;
}
.stock-pair.grid2 .card-stock-alert .alert-box .alert-bags {
font-size: 0.625rem;
margin-top: 0.45rem;
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 0.12rem;
}
.stock-pair.grid2 .card-stock-alert .alert-box .alert-bags li {
white-space: normal;
line-height: 1.25;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/** @var list<array{name:string,percent:int}> $lowStock */
?>
<div class="card card-stock-alert">
<div class="card-hd"><span><i class="fa-solid fa-triangle-exclamation"></i> 재고 경보</span></div>
<div class="card-bd">
<?= view('home/_dashboard_gov_portal_stock_alert_levels', ['stockAlerts' => $stockAlerts]) ?>
</div>
</div>
<div class="card card-low-stock">
<div class="card-hd"><span><i class="fa-solid fa-box-open"></i> 부족 재고</span></div>
<div class="card-bd">
<div class="low-stock-grid">
<?php foreach ($lowStock as $item): ?>
<div class="bar-row">
<div class="meta"><span><?= esc($item['name']) ?></span><span><?= esc((string) $item['percent']) ?>%</span></div>
<div class="bar-track"><div class="bar-fill" style="width:<?= (int) $item['percent'] ?>%"></div></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/** gov-portal-strip 메인 대시보드 본문 */
?>
<div class="kpi-strip">
<div class="kpi-card">
<div class="ico"><i class="fa-solid fa-boxes-stacked"></i></div>
<div><div class="n ok">양호</div><div class="l">봉투 재고 상태</div></div>
</div>
<div class="kpi-card">
<div class="ico"><i class="fa-solid fa-inbox"></i></div>
<div><div class="n">12</div><div class="l">미처리 구매신청</div></div>
</div>
<div class="kpi-card">
<div class="ico"><i class="fa-solid fa-user-check"></i></div>
<div><div class="n">4</div><div class="l">승인 대기</div></div>
</div>
<div class="kpi-card">
<div class="ico"><i class="fa-solid fa-location-dot"></i></div>
<div><div class="n" style="font-size:.95rem;color:var(--text-dark);"><?= esc($lgLabel) ?></div><div class="l">담당 지자체</div></div>
</div>
</div>
<section class="hero">
<div class="hero-hd">
<span><i class="fa-solid fa-map"></i> GIS 통합 현황판</span>
<a href="<?= base_url('bag/flow') ?>">수불 조회 &gt;</a>
</div>
<div class="hero-body">
<div class="hero-map">
<?= view('home/_dashboard_gov_portal_map_panel', [
'mapId' => 'govPortalStripMap',
'mapFill' => true,
'lgLabel' => $lgLabel,
'govMapPanel' => $govMapPanel,
]) ?>
</div>
<div class="hero-tl">
<?php foreach ($timeline as $ev): ?>
<div class="item">
<span class="time"><?= esc($ev['time']) ?></span>
<span class="txt"><?= esc($ev['text']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<div class="grid2 stock-pair">
<?= view('home/_dashboard_gov_portal_stock_cards_pair', ['lowStock' => $lowStock, 'stockAlerts' => $stockAlerts]) ?>
</div>
<div class="grid2">
<div class="card">
<div class="card-hd"><i class="fa-regular fa-envelope"></i> 메시지</div>
<div class="card-bd">
<?php foreach ($notices as $n): ?>
<div class="notice-row">
<span class="notice-t"><?= esc($n['title']) ?></span>
<span class="notice-d"><?= esc($n['date']) ?></span>
</div>
<?php endforeach; ?>
<?= view('home/_dashboard_gov_portal_menu_search', [
'variant' => 'inline',
'inputId' => 'menuSearch',
'menuSearchOptions' => $menuSearchOptions,
]) ?>
</div>
</div>
<div class="card">
<div class="card-hd"><i class="fa-solid fa-chart-column"></i> 최근 7일 신청 · 바로가기</div>
<div class="card-bd">
<div class="bars">
<?php $maxReq = max($weeklyRequests); foreach ($weeklyRequests as $idx => $v): $h = (int) round(($v / $maxReq) * 100); ?>
<div class="col"><span><?= esc((string) $v) ?></span><div class="bar" style="height:<?= $h ?>%"></div><span>D<?= 6 - $idx ?></span></div>
<?php endforeach; ?>
</div>
<div class="quick-list" style="margin-top:.75rem;">
<?php foreach ($quickLinks as $link): ?>
<a href="<?= esc($link['url']) ?>">
<i class="fa-solid <?= esc($link['icon'], 'attr') ?>"></i>
<?= esc($link['label']) ?>
<span><?= esc($link['desc']) ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* gov-portal-strip 공통 페이지 셸 (상단 호버 메뉴 + 프로필 + 가로 MY MENU + 본문)
*
* @var string $stripInnerView 본문 partial 경로 (home/...)
* @var bool $stripIncludeWorkCss NDMS 작업화면 CSS 포함 여부
*/
helper('admin');
$govPortalNavPartial = gov_portal_nav_partial_vars(get_defined_vars());
$activeVariant = $activeVariant ?? 'strip';
$stripInnerView = (string) ($stripInnerView ?? '');
$stripIncludeWorkCss = ! empty($stripIncludeWorkCss);
$stripShowProfileLinks = ($stripShowProfileLinks ?? true) !== false;
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<?= view('home/_dashboard_gov_portal_head') ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<?php if (! $stripIncludeWorkCss): ?>
<?= view('home/_dashboard_gov_portal_map_leaflet_assets') ?>
<?php endif; ?>
<style>
<?php include __DIR__ . '/_dashboard_gov_portal_brand_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_topnav_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_strip_styles.php'; ?>
<?php if (! $stripIncludeWorkCss): ?>
<?php include __DIR__ . '/_dashboard_gov_portal_map_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_menu_search_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_stock_cards_css.php'; ?>
<?php endif; ?>
<?php if ($stripIncludeWorkCss): ?>
<?php include __DIR__ . '/_dashboard_gov_portal_workpage_css.php'; ?>
.page.strip-work-page .work-main { padding:0; background:transparent; }
<?php endif; ?>
</style>
</head>
<body>
<header class="portal-header">
<div class="portal-header-inner">
<?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('dashboard/gov-portal-strip')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_hover', $govPortalNavPartial) ?>
<?= view('home/_dashboard_gov_portal_header_utils', [
'activeVariant' => $activeVariant,
'portalVariants' => $portalVariants,
'mbName' => $mbName,
'levelName' => $levelName,
'lgLabel' => $lgLabel,
]) ?>
</div>
</header>
<div class="page<?= $stripIncludeWorkCss ? ' strip-work-page' : '' ?>">
<div class="profile-inline">
<div>
<div class="name"><?= esc($mbName) ?>님, 환영합니다</div>
<div class="sub"><?= esc($lgLabel) ?> · <?= esc($levelName) ?> · 최근접속 <?= date('Y.m.d H:i') ?></div>
</div>
<?php if ($stripShowProfileLinks): ?>
<div style="display:flex;gap:.35rem;flex-wrap:wrap;">
<a href="<?= base_url('dashboard/simple') ?>">마이페이지</a>
<a href="<?= base_url('logout') ?>">로그아웃</a>
</div>
<?php endif; ?>
</div>
<?= view('home/_dashboard_gov_portal_strip_my_menu', $govPortalNavPartial) ?>
<?php if ($stripInnerView !== ''): ?>
<div class="<?= $stripIncludeWorkCss ? 'work-main' : 'strip-page-body' ?>">
<?= view($stripInnerView, get_defined_vars()) ?>
</div>
<?php endif; ?>
</div>
<?= view('home/_dashboard_gov_portal_nav_script_base', $govPortalNavPartial) ?>
<?= view('home/_dashboard_gov_portal_font_zoom_script') ?>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
/**
* gov-portal-strip 가로 MY MENU (활성 대메뉴 하위 소메뉴 칩)
*
* @var list<array> $govNavItems
* @var int $govActiveParentIdx
* @var string $govActiveChildHref
*/
$activeParent = $govNavItems[$govActiveParentIdx] ?? $govNavItems[0] ?? null;
if ($activeParent === null) {
return;
}
$children = $activeParent['children'] ?? [];
if ($children === []) {
return;
}
$activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
$sectionTitle = (string) ($activeParent['name'] ?? 'MY MENU');
?>
<nav class="strip-my-menu" aria-label="<?= esc($sectionTitle) ?> 하위 메뉴">
<span class="strip-my-menu-title">MY MENU · <?= esc($sectionTitle) ?></span>
<div class="strip-my-menu-chips">
<?php foreach ($children as $ci => $child): ?>
<?php if (($child['href'] ?? '') === ''): ?>
<span class="menu-sub" style="opacity:.55;font-size:.75rem;padding:.35rem .5rem;"><?= esc($child['name']) ?></span>
<?php else: ?>
<?php
$childHref = strtolower(ltrim((string) $child['href'], '/'));
$isActive = $activeChildHref !== ''
? ($childHref === $activeChildHref)
: ($ci === 0);
?>
<a href="<?= esc($child['url']) ?>" class="<?= $isActive ? 'active' : '' ?>">
<span class="menu-ico"><?= $isActive ? '×' : '+' ?></span>
<?= esc($child['name']) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
</nav>

View File

@@ -0,0 +1,67 @@
:root { --navy:#1a2b4b; --navy-deep:#002b4e; --blue:#007bff; --blue-menu:#4a69bd; --teal:#009688; --bg:#f0f4f8; --border:#dde4ec; --text:#444; --text-dark:#222; --muted:#888; --font-scale:1; }
* { box-sizing:border-box; margin:0; padding:0; }
html { font-size:calc(14px * var(--font-scale)); -webkit-text-size-adjust:100%; }
body { font-family:'Pretendard','Malgun Gothic','Noto Sans KR',sans-serif; font-size:.8125rem; font-weight:400; line-height:1.45; letter-spacing:-.02em; color:var(--text); background:var(--bg); min-height:100vh; -webkit-font-smoothing:antialiased; }
.page { padding:.875rem 1rem 1.25rem; max-width:1400px; margin:0 auto; }
.profile-inline { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; background:#4a5568; color:#fff; padding:1rem; border-radius:12px; margin-bottom:.875rem; }
.profile-inline .name { font-size:1rem; font-weight:700; }
.profile-inline .sub { font-size:.6875rem; opacity:.8; margin-top:.15rem; }
.profile-inline a { color:#fff; font-size:.75rem; font-weight:600; text-decoration:none; border:1px solid rgba(255,255,255,.4); padding:.3rem .55rem; border-radius:4px; }
.strip-my-menu {
display:flex; flex-wrap:wrap; align-items:center; gap:.35rem;
padding:.5rem .75rem; margin-bottom:.875rem;
background:#fff; border:1px solid var(--border); border-radius:10px;
box-shadow:0 1px 3px rgba(26,43,75,.05);
}
.strip-my-menu .strip-my-menu-title {
font-size:.6875rem; font-weight:700; color:var(--navy);
margin-right:.35rem; letter-spacing:.04em; white-space:nowrap;
}
.strip-my-menu .strip-my-menu-chips { display:flex; flex-wrap:wrap; gap:.35rem; flex:1; }
.strip-my-menu a {
display:inline-flex; align-items:center; gap:.25rem;
padding:.4rem .65rem; border-radius:10px;
background:var(--blue-menu); color:#fff; text-decoration:none;
font-size:.8125rem; font-weight:600;
border:1px solid rgba(255,255,255,.22);
}
.strip-my-menu a:hover { filter:brightness(1.06); }
.strip-my-menu a.active { background:#3d5a9e; border-color:rgba(255,255,255,.4); }
.strip-my-menu a .menu-ico { font-size:.625rem; width:.75rem; text-align:center; }
.kpi-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:.75rem; margin-bottom:.875rem; }
@media(max-width:900px){.kpi-strip{grid-template-columns:repeat(2,1fr)}}
.kpi-card { background:#fff; border-radius:12px; border:1px solid var(--border); padding:.75rem 1rem; display:flex; align-items:center; gap:.75rem; box-shadow:0 1px 3px rgba(26,43,75,.05); }
.kpi-card .ico { width:40px; height:40px; border-radius:10px; background:#eef6ff; color:var(--blue); display:flex; align-items:center; justify-content:center; font-size:1rem; }
.kpi-card .n { font-size:1.35rem; font-weight:700; color:#2563eb; line-height:1.1; }
.kpi-card .n.ok { font-size:1.1rem; color:#10b981; }
.kpi-card .l { font-size:.6875rem; color:var(--muted); font-weight:600; margin-top:.1rem; }
.hero { background:#fff; border-radius:12px; border:1px solid var(--border); overflow:hidden; margin-bottom:.875rem; box-shadow:0 1px 3px rgba(26,43,75,.06); }
.hero-hd { display:flex; justify-content:space-between; align-items:center; padding:.6rem .875rem; border-bottom:1px solid var(--border); font-size:1rem; font-weight:700; color:var(--text-dark); }
.hero-hd a { font-size:.6875rem; font-weight:600; color:#fff; background:var(--blue); padding:.25rem .5rem; border-radius:3px; text-decoration:none; }
.hero-body { display:grid; grid-template-columns:1fr 200px; min-height:200px; }
@media(max-width:800px){.hero-body{grid-template-columns:1fr}}
.hero-map { position:relative; min-height:220px; overflow:hidden; }
.hero-tl { background:var(--navy-deep); color:#fff; padding:.4rem; overflow-y:auto; max-height:220px; }
.hero-tl .item { padding:.4rem .35rem; border-bottom:1px solid rgba(255,255,255,.1); font-size:.75rem; }
.hero-tl .time { font-weight:700; font-size:.8125rem; display:block; }
.hero-tl .txt { color:#4fc3f7; font-weight:600; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:.875rem; }
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
.card { background:#fff; border-radius:12px; border:1px solid var(--border); box-shadow:0 1px 3px rgba(26,43,75,.05); overflow:hidden; }
.card-hd { padding:.6rem .875rem; border-bottom:1px solid var(--border); font-weight:700; font-size:1rem; color:var(--text-dark); }
.card-hd i { color:var(--blue); margin-right:.3rem; }
.card-bd { padding:.875rem 1rem; }
.notice-t { font-size:.8125rem; font-weight:600; color:var(--text-dark); }
.notice-d { font-size:.6875rem; color:var(--blue); background:#eef6ff; padding:.1rem .35rem; border-radius:2px; font-weight:600; margin-left:.35rem; }
.notice-row { padding:.4rem 0; border-bottom:1px dashed #e8edf2; }
.bars { display:flex; align-items:flex-end; gap:4px; height:56px; }
.bars .col { flex:1; text-align:center; font-size:.625rem; color:var(--muted); font-weight:500; display:flex; flex-direction:column; justify-content:flex-end; gap:2px; }
.bars .bar { background:linear-gradient(180deg,#2563eb,#60a5fa); border-radius:3px 3px 0 0; min-height:4px; width:100%; }
.quick-list a { display:flex; align-items:center; gap:.5rem; padding:.4rem 0; text-decoration:none; color:var(--text); border-bottom:1px solid #f1f5f9; font-size:.8125rem; font-weight:600; }
.quick-list a:last-child { border-bottom:none; }
.quick-list a:hover { color:var(--blue); }
.quick-list span { font-size:.6875rem; font-weight:400; color:var(--muted); margin-left:auto; }
.search-inline { display:flex; gap:.5rem; background:var(--teal); padding:.65rem .75rem; border-radius:10px; color:#fff; align-items:center; margin-top:.5rem; }
.search-inline input { flex:1; border:none; border-radius:4px; padding:.4rem .5rem; font-size:.8125rem; font-family:inherit; }
.search-inline label { font-weight:700; font-size:.8125rem; white-space:nowrap; }
.stock-pair.grid2 { margin-bottom:.875rem; }

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* 대메뉴 클릭 → 좌측 MY MENU에 소메뉴 표시 (gov-portal 기본)
*
* @var list<array> $govNavItems
* @var int $govActiveParentIdx
* @var string $govCurrentPath
* @var list<string> $govDashboardAliases
*/
helper('admin');
?>
<nav class="portal-top-nav" id="portalTopNavClick" aria-label="대분류 메뉴">
<?php foreach ($govNavItems as $pIdx => $item): ?>
<?php
$isActive = ($pIdx === $govActiveParentIdx);
$hasChildren = ! empty($item['hasChildren']);
if ($hasChildren):
?>
<div class="portal-nav-item">
<button type="button"
class="portal-nav-trigger <?= $isActive ? 'is-active' : '' ?>"
data-parent-idx="<?= (int) $pIdx ?>"
aria-expanded="<?= $isActive ? 'true' : 'false' ?>">
<?= esc($item['name']) ?>
</button>
</div>
<?php elseif ($item['href'] !== ''): ?>
<div class="portal-nav-item">
<a href="<?= esc($item['url']) ?>"
class="portal-nav-link <?= $isActive ? 'is-active' : '' ?>"
data-parent-idx="<?= (int) $pIdx ?>">
<?= esc($item['name']) ?>
</a>
</div>
<?php else: ?>
<div class="portal-nav-item">
<span class="portal-nav-trigger" style="opacity:.5;cursor:default;"><?= esc($item['name']) ?></span>
</div>
<?php endif; ?>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,171 @@
/* 상단 메뉴바 — 기본·변형 동일 높이 */
.portal-header {
background: var(--navy);
color: #fff;
flex-shrink: 0;
}
.portal-header-inner {
display: flex;
align-items: stretch;
justify-content: space-between;
padding: 0 1rem;
min-height: 48px;
gap: 0.5rem;
flex-wrap: wrap;
}
.portal-top-nav {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 0 0.25rem;
min-height: 48px;
font-size: 0.875rem;
font-weight: 600;
}
.portal-nav-item {
display: flex;
align-items: stretch;
position: relative;
}
.portal-nav-link,
.portal-nav-trigger {
display: inline-flex;
align-items: center;
padding: 0 0.625rem;
min-height: 48px;
box-sizing: border-box;
color: rgba(255,255,255,.9);
text-decoration: none;
border: none;
border-bottom: 4px solid transparent;
background: transparent;
font: inherit;
letter-spacing: -0.02em;
cursor: pointer;
white-space: nowrap;
}
.portal-nav-link:hover,
.portal-nav-trigger:hover {
color: #fff;
font-weight: 700;
}
.portal-nav-link.is-active,
.portal-nav-trigger.is-active {
color: #fff;
font-weight: 700;
border-bottom-color: #fff;
}
.portal-nav-dropdown {
position: absolute;
left: 0;
top: 100%;
z-index: 300;
margin-top: -2px;
padding-top: 4px;
min-width: 12rem;
display: none;
}
.portal-nav-item:hover .portal-nav-dropdown,
.portal-nav-item:focus-within .portal-nav-dropdown {
display: block;
}
.portal-nav-dropdown-panel {
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: 0 6px 16px rgba(26,43,75,.12);
padding: 0.25rem 0;
}
.portal-nav-dropdown-panel a {
display: block;
padding: 0.45rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: #334155;
text-decoration: none;
white-space: nowrap;
}
.portal-nav-dropdown-panel a:hover {
background: #eff6ff;
color: var(--blue-ui);
}
.portal-nav-dropdown-panel a.is-active {
background: #eff6ff;
color: var(--blue-ui);
font-weight: 700;
}
.portal-nav-dropdown-panel .no-link {
display: block;
padding: 0.45rem 0.75rem;
font-size: 0.8125rem;
color: #94a3b8;
cursor: default;
}
.portal-header-utils {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: rgba(255,255,255,.92);
flex-wrap: wrap;
min-height: 48px;
}
.portal-header-utils .user-line {
max-width: 11rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-header-utils .extend-btn {
background: var(--blue-ui);
border: none;
color: #fff;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.portal-header-utils .font-zoom {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.6875rem;
border: 1px solid rgba(255,255,255,.25);
border-radius: 3px;
padding: 0.125rem 0.25rem;
background: rgba(0,0,0,.12);
}
.portal-header-utils .font-zoom button {
background: transparent;
border: none;
color: #fff;
width: 1.125rem;
height: 1.125rem;
cursor: pointer;
padding: 0;
}
.portal-header-utils .util-ico {
color: inherit;
text-decoration: none;
opacity: 0.88;
font-size: 0.875rem;
padding: 0.2rem;
}
.portal-header-utils .util-ico:hover { opacity: 1; color: #fff; }
.variant-nav {
display: inline-flex;
gap: 0.125rem;
padding: 0.125rem;
background: rgba(0,0,0,.15);
border-radius: 4px;
}
.variant-nav a {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.2rem 0.45rem;
border-radius: 3px;
color: rgba(255,255,255,.78);
text-decoration: none;
}
.variant-nav a.on, .variant-nav a:hover { background: rgba(255,255,255,.18); color: #fff; }

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* 대메뉴 호버 시 소메뉴 드롭다운 (gov-portal-strip)
*
* @var list<array> $govNavItems
* @var int $govActiveParentIdx
* @var string $govCurrentPath
* @var list<string> $govDashboardAliases
*/
helper('admin');
?>
<nav class="portal-top-nav" aria-label="대분류 메뉴">
<?php foreach ($govNavItems as $pIdx => $item): ?>
<?php
$isActive = ($pIdx === $govActiveParentIdx);
$parentHref = $item['href'] ?? '';
$hasChildren = ! empty($item['hasChildren']);
$activeChild = null;
if ($hasChildren && ! empty($siteNavTree[$pIdx])) {
$activeChild = menu_active_child_for_parent($siteNavTree[$pIdx], $govCurrentPath, $govDashboardAliases);
}
?>
<div class="portal-nav-item">
<?php if ($hasChildren): ?>
<?php if ($parentHref !== ''): ?>
<a href="<?= esc($item['url']) ?>" class="portal-nav-link <?= $isActive ? 'is-active' : '' ?>">
<?= esc($item['name']) ?>
</a>
<?php else: ?>
<span class="portal-nav-trigger <?= $isActive ? 'is-active' : '' ?>" tabindex="0"><?= esc($item['name']) ?></span>
<?php endif; ?>
<div class="portal-nav-dropdown">
<div class="portal-nav-dropdown-panel">
<?php foreach ($item['children'] as $child): ?>
<?php if ($child['href'] !== ''): ?>
<?php
$childActive = $activeChild !== null
&& (int) ($activeChild->mm_idx ?? 0) === (int) ($child['idx'] ?? 0);
?>
<a href="<?= esc($child['url']) ?>" class="<?= $childActive ? 'is-active' : '' ?>">
<?= esc($child['name']) ?>
</a>
<?php else: ?>
<span class="no-link" title="메뉴 링크 미설정"><?= esc($child['name']) ?></span>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php elseif ($parentHref !== ''): ?>
<a href="<?= esc($item['url']) ?>" class="portal-nav-link <?= $isActive ? 'is-active' : '' ?>">
<?= esc($item['name']) ?>
</a>
<?php else: ?>
<span class="portal-nav-trigger" style="opacity:.5;"><?= esc($item['name']) ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
/** @var array<int, array{label: string, url: string}> $portalVariants */
/** @var string $activeVariant base|alt|strip */
?>
<nav class="variant-nav" aria-label="포털 시안 전환">
<?php foreach ($portalVariants as $v):
$key = match ($v['label']) {
'기본' => 'base',
'변형' => 'strip',
default => '',
};
$isActive = ($activeVariant ?? '') === $key;
?>
<a href="<?= esc($v['url']) ?>" class="<?= $isActive ? 'on' : '' ?>"><?= esc($v['label']) ?></a>
<?php endforeach; ?>
</nav>

View File

@@ -0,0 +1,192 @@
.main.work-main {
flex: 1;
min-width: 0;
overflow: auto;
background: #f5f7fa;
padding: 0.75rem 1rem 1.5rem;
}
.work-breadcrumb {
font-size: 0.6875rem;
color: #888;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.work-breadcrumb a { color: #666; text-decoration: none; }
.work-breadcrumb a:hover { text-decoration: underline; }
.work-breadcrumb .sep { margin: 0 0.35rem; color: #bbb; }
.work-page-hd {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.work-page-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 700;
color: #222;
letter-spacing: -0.03em;
}
.work-page-title .fav {
color: #f59e0b;
font-size: 0.875rem;
cursor: pointer;
opacity: 0.85;
}
.work-actions { display: flex; flex-wrap: wrap; gap: 0.375rem; }
.btn-portal {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.85rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 3px;
border: 1px solid transparent;
text-decoration: none;
cursor: pointer;
letter-spacing: -0.02em;
line-height: 1.3;
}
.btn-portal-primary {
background: #00205b;
color: #fff;
border-color: #00205b;
}
.btn-portal-primary:hover { background: #003080; }
.btn-portal-secondary {
background: #fff;
color: #333;
border-color: #c5cdd8;
}
.btn-portal-secondary:hover { background: #f8fafc; }
.btn-portal-search {
background: #3366ff;
color: #fff;
border-color: #3366ff;
}
.btn-portal-search:hover { background: #2952cc; }
.btn-portal-reset {
background: #fff;
color: #555;
border-color: #c5cdd8;
}
.search-panel {
background: #fff;
border: 1px solid #d1d5db;
margin-bottom: 0.75rem;
}
.search-panel table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.search-panel th {
width: 10%;
min-width: 5.5rem;
background: #f8fafc;
border: 1px solid #e5e7eb;
padding: 0.45rem 0.65rem;
font-weight: 600;
color: #444;
text-align: left;
vertical-align: middle;
}
.search-panel td {
border: 1px solid #e5e7eb;
padding: 0.35rem 0.5rem;
background: #fff;
}
.search-panel input,
.search-panel select {
width: 100%;
max-width: 14rem;
border: 1px solid #d1d5db;
padding: 0.3rem 0.45rem;
font-size: 0.75rem;
font-family: inherit;
border-radius: 2px;
}
.search-panel-foot {
display: flex;
justify-content: flex-end;
gap: 0.35rem;
padding: 0.5rem 0.65rem;
border-top: 1px solid #e5e7eb;
background: #fafbfc;
}
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.75rem;
}
.table-toolbar .total strong { color: #3366ff; font-weight: 700; }
.table-toolbar .tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem; }
.table-toolbar select {
border: 1px solid #d1d5db;
padding: 0.25rem 0.4rem;
font-size: 0.75rem;
}
.portal-data-table-wrap {
background: #fff;
border: 1px solid #d1d5db;
overflow: auto;
}
.portal-data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
.portal-data-table thead th {
background: #e8eef5;
color: #00205b;
font-weight: 700;
text-align: center;
padding: 0.5rem 0.4rem;
border: 1px solid #d1d5db;
white-space: nowrap;
}
.portal-data-table tbody td {
border: 1px solid #e5e7eb;
padding: 0.4rem 0.5rem;
text-align: center;
color: #333;
}
.portal-data-table tbody tr:nth-child(even) { background: #f9fafb; }
.portal-data-table tbody tr:hover { background: #eef6ff; }
.portal-data-table tbody tr.is-selected { background: #dbeafe; }
.portal-data-table tbody td.text-left { text-align: left; }
.portal-data-table tbody td a { color: #3366ff; text-decoration: none; }
.portal-data-table tbody td a:hover { text-decoration: underline; }
.portal-data-table .empty-row td {
padding: 2rem;
color: #888;
text-align: center;
}
.detail-section {
margin-top: 1rem;
}
.detail-section-hd {
font-size: 0.8125rem;
font-weight: 700;
color: #222;
margin-bottom: 0.4rem;
padding-bottom: 0.35rem;
border-bottom: 2px solid #00205b;
}
.flash-banner {
margin-bottom: 0.65rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
}
.flash-banner.ok { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; }
.flash-banner.err { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
/** @var list<object> $codeKinds */
/** @var array<int,int> $countMap */
/** @var bool $canManageKinds */
/** @var bool $canManageDetails */
/** @var object|null $selectedKind */
/** @var list<object> $detailList */
/** @var array<int,bool> $rowCanEdit */
/** @var int $totalCount */
/** @var array<string,string> $filters */
/** @var string $pageBaseUrl */
$canManageKinds = ! empty($canManageKinds);
$canManageDetails = ! empty($canManageDetails);
$selectedKindId = (int) ($selectedKind->ck_idx ?? 0);
$filters = is_array($filters ?? null) ? $filters : [];
$activeVariant = (string) ($activeVariant ?? 'base');
$pageBaseUrl = (string) ($pageBaseUrl ?? site_url(gov_portal_code_kinds_portal_path($activeVariant)));
$buildUrl = static function (array $extra = []) use ($filters, $selectedKindId, $pageBaseUrl): string {
$q = array_merge([
'search' => '1',
'q_code' => $filters['q_code'] ?? '',
'q_name' => $filters['q_name'] ?? '',
'q_state' => $filters['q_state'] ?? '',
'ck_idx' => $selectedKindId > 0 ? (string) $selectedKindId : '',
], $extra);
foreach ($q as $k => $v) {
if ($v === '' || $v === null) {
unset($q[$k]);
}
}
return $pageBaseUrl . '?' . http_build_query($q);
};
?>
<nav class="work-breadcrumb" aria-label="breadcrumb">
<a href="<?= base_url($activeVariant === 'strip' ? 'dashboard/gov-portal-strip' : 'dashboard/gov-portal') ?>">Home</a>
<span class="sep">&gt;</span>
<span>시스템관리</span>
<span class="sep">&gt;</span>
<span>기본 코드관리(포털 UI 시안)</span>
</nav>
<div class="work-page-hd">
<h1 class="work-page-title">
<span>기본 코드관리</span>
<span style="font-size:0.75rem;font-weight:600;color:#666;">포털 UI 시안</span>
<i class="fa-regular fa-star fav" title="즐겨찾기(목업)" aria-hidden="true"></i>
</h1>
<div class="work-actions">
<?php if ($canManageKinds): ?>
<a href="<?= base_url('admin/code-kinds/create') ?>" class="btn-portal btn-portal-primary">
<i class="fa-solid fa-plus"></i> 등록
</a>
<?php endif; ?>
<a href="<?= base_url('bag/code-kinds') ?>" class="btn-portal btn-portal-secondary" title="운영 중인 기본 코드관리 화면">
<i class="fa-solid fa-table-columns"></i> 운영 화면 (/bag/code-kinds)
</a>
</div>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="flash-banner ok"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="flash-banner err"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<form method="get" action="<?= esc($pageBaseUrl, 'attr') ?>" class="search-panel">
<input type="hidden" name="search" value="1"/>
<?php if ($selectedKindId > 0): ?>
<input type="hidden" name="ck_idx" value="<?= $selectedKindId ?>"/>
<?php endif; ?>
<table>
<tbody>
<tr>
<th>코드</th>
<td><input type="text" name="q_code" value="<?= esc($filters['q_code'] ?? '') ?>" placeholder="종류 코드"/></td>
<th>코드명</th>
<td><input type="text" name="q_name" value="<?= esc($filters['q_name'] ?? '') ?>" placeholder="종류명"/></td>
<th>사용여부</th>
<td>
<select name="q_state">
<option value="">전체</option>
<option value="1" <?= ($filters['q_state'] ?? '') === '1' ? 'selected' : '' ?>>사용</option>
<option value="0" <?= ($filters['q_state'] ?? '') === '0' ? 'selected' : '' ?>>미사용</option>
</select>
</td>
</tr>
</tbody>
</table>
<div class="search-panel-foot">
<a href="<?= esc($pageBaseUrl, 'attr') ?>" class="btn-portal btn-portal-reset">
<i class="fa-solid fa-rotate-left"></i> 초기화
</a>
<button type="submit" class="btn-portal btn-portal-search">
<i class="fa-solid fa-magnifying-glass"></i> 검색
</button>
</div>
</form>
<section aria-labelledby="codeKindTableLabel">
<div class="table-toolbar">
<div class="total">전체 <strong><?= number_format($totalCount) ?></strong> 건</div>
<div class="tools">
<span class="text-gray-500">코드 종류</span>
</div>
</div>
<div class="portal-data-table-wrap">
<table class="portal-data-table" id="codeKindTableLabel">
<thead>
<tr>
<th class="w-12">번호</th>
<th class="w-20">코드</th>
<th>코드명</th>
<th class="w-20">세부코드</th>
<th class="w-16">사용여부</th>
<th class="w-28">등록일</th>
<?php if ($canManageKinds): ?>
<th class="w-24">작업</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php if ($codeKinds !== []): ?>
<?php $i = 0; foreach ($codeKinds as $row): $i++; ?>
<?php $isSelected = (int) $row->ck_idx === $selectedKindId; ?>
<tr class="<?= $isSelected ? 'is-selected' : '' ?>"
onclick="window.location.href='<?= esc($buildUrl(['ck_idx' => (string) $row->ck_idx]), 'attr') ?>'"
style="cursor:pointer">
<td><?= (string) $i ?></td>
<td class="font-mono"><?= esc($row->ck_code) ?></td>
<td class="text-left"><?= esc($row->ck_name) ?></td>
<td><?= (int) ($countMap[$row->ck_idx] ?? 0) ?>개</td>
<td><?= (int) ($row->ck_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td><?= esc($row->ck_regdate ?? '') ?></td>
<?php if ($canManageKinds): ?>
<td onclick="event.stopPropagation()">
<a href="<?= base_url('admin/code-kinds/edit/' . (int) $row->ck_idx) ?>">수정</a>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr class="empty-row">
<td colspan="<?= $canManageKinds ? '7' : '6' ?>">등록된 코드 종류가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php if ($selectedKind !== null): ?>
<section class="detail-section" aria-labelledby="codeDetailTableLabel">
<h2 class="detail-section-hd" id="codeDetailTableLabel">
세부코드 — <?= esc($selectedKind->ck_name) ?> (<?= esc($selectedKind->ck_code) ?>)
</h2>
<div class="table-toolbar">
<div class="total">세부 <strong><?= number_format(count($detailList)) ?></strong> 건</div>
<div class="tools">
<?php if ($canManageDetails): ?>
<a href="<?= base_url('admin/code-details/' . (int) $selectedKind->ck_idx . '/create') ?>" class="btn-portal btn-portal-primary">
<i class="fa-solid fa-plus"></i> 세부코드 등록
</a>
<?php endif; ?>
</div>
</div>
<div class="portal-data-table-wrap">
<table class="portal-data-table">
<thead>
<tr>
<th>번호</th>
<th>코드</th>
<th>코드명</th>
<th>범위</th>
<th>정렬</th>
<th>사용여부</th>
<th>등록일</th>
<?php if ($canManageDetails): ?>
<th>작업</th>
<?php endif; ?>
</tr>
</thead>
<tbody>
<?php if ($detailList !== []): ?>
<?php $dNo = 0; foreach ($detailList as $row): $dNo++; ?>
<?php
$isPlatform = (($row->cd_source ?? 'platform') === 'platform' && (int) ($row->cd_lg_idx ?? 0) === 0);
$scopeLabel = $isPlatform ? '공통' : '지자체';
?>
<tr>
<td><?= (string) $dNo ?></td>
<td class="font-mono"><?= esc($row->cd_code) ?></td>
<td class="text-left"><?= esc($row->cd_name) ?></td>
<td><?= esc($scopeLabel) ?></td>
<td><?= (int) ($row->cd_sort ?? 0) ?></td>
<td><?= (int) ($row->cd_state ?? 0) === 1 ? '사용' : '미사용' ?></td>
<td><?= esc($row->cd_regdate ?? '') ?></td>
<?php if ($canManageDetails): ?>
<td>
<?php if (! empty($rowCanEdit[$row->cd_idx])): ?>
<a href="<?= base_url('admin/code-details/edit/' . (int) $row->cd_idx) ?>">수정</a>
<?php else: ?>
<span style="color:#aaa">—</span>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr class="empty-row">
<td colspan="<?= $canManageDetails ? '8' : '7' ?>">등록된 세부코드가 없습니다.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
<?php elseif ($codeKinds !== []): ?>
<p style="font-size:0.75rem;color:#888;margin-top:0.75rem;">위 표에서 코드 종류를 선택하면 세부코드가 표시됩니다.</p>
<?php endif; ?>

View File

@@ -0,0 +1,638 @@
<?php
declare(strict_types=1);
/**
* 공공 포털형 UI — 기본 레이아웃 (좌측 MY MENU)
*
* @var string $lgLabel
* @var string $activeVariant
*/
helper('admin');
$govPortalNavPartial = gov_portal_nav_partial_vars(get_defined_vars());
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<?= view('home/_dashboard_gov_portal_head') ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<?= view('home/_dashboard_gov_portal_map_leaflet_assets') ?>
<style>
:root {
--navy: #1a2b4b;
--navy-deep: #002b4e;
--blue: #0056b3;
--blue-ui: #007bff;
--blue-menu: #4a69bd;
--blue-light: #eef6ff;
--teal: #009688;
--bg: #f0f4f8;
--card: #fff;
--text: #444;
--text-dark: #222;
--muted: #888;
--border: #dde4ec;
--font-scale: 1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html {
font-size: calc(14px * var(--font-scale));
-webkit-text-size-adjust: 100%;
}
body {
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
font-size: 0.8125rem; /* ~13px @14px root — 본문 */
font-weight: 400;
line-height: 1.45;
letter-spacing: -0.01em;
color: var(--text);
background: var(--bg);
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
<?php include __DIR__ . '/_dashboard_gov_portal_brand_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_topnav_css.php'; ?>
.layout {
display: flex;
flex: 1;
min-height: 0;
}
/* 좌측 사이드 */
.sidebar {
width: 168px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
font-size: 0.8125rem;
}
.my-menu-hd {
background: var(--navy);
color: #fff;
padding: 0.5rem 0.625rem;
font-weight: 700;
font-size: 0.6875rem; /* 11px — MY MENU 라벨 */
letter-spacing: 0.04em;
}
.my-menu-list { list-style: none; padding: 0.375rem 0.25rem; flex: 1; }
.my-menu-list li { margin: 0.1875rem 0.375rem; }
.my-menu-list a,
.my-menu-list .menu-sub {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.4375rem 0.625rem;
margin: 0;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
text-decoration: none;
font-size: 0.8125rem; /* 13px */
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.02em;
box-sizing: border-box;
transition: filter 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.my-menu-list a {
color: #fff;
background: var(--blue-menu);
}
.my-menu-list a .menu-ico,
.my-menu-list .menu-sub .menu-ico {
font-size: 0.625rem;
opacity: .9;
width: 0.75rem;
text-align: center;
flex-shrink: 0;
}
.my-menu-list a:hover { filter: brightness(1.06); border-color: rgba(255, 255, 255, 0.35); }
.my-menu-list a.active {
background: #3d5a9e;
font-weight: 700;
border-color: rgba(255, 255, 255, 0.4);
box-shadow: 0 1px 3px rgba(26, 43, 75, 0.12);
}
.my-menu-list a.menu-sub,
.my-menu-list .menu-sub {
background: var(--blue-light);
color: var(--blue);
font-size: 0.75rem;
font-weight: 600;
border-color: rgba(0, 86, 179, 0.18);
}
.sidebar-blocks { margin-top: auto; }
.sb-teal {
background: var(--teal);
color: #fff;
padding: 0.75rem 0.625rem;
font-size: 0.6875rem;
line-height: 1.5;
letter-spacing: -0.02em;
}
.sb-teal i { font-size: 1.125rem; margin-bottom: 0.25rem; display: block; }
.sb-gray {
background: #4a5568;
color: #fff;
padding: 0.625rem;
font-size: 0.6875rem;
line-height: 1.45;
}
.sb-links {
padding: 0.625rem;
background: #f5f7fa;
font-size: 0.6875rem;
}
.sb-links a {
display: block;
color: #555;
text-decoration: none;
padding: 0.1875rem 0;
letter-spacing: -0.02em;
font-weight: 600;
}
.sb-links a:hover { color: var(--blue-ui); }
/* 메인 그리드 */
.main {
flex: 1;
padding: 0.875rem 1rem 1rem;
overflow-y: auto;
}
.grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 0.875rem; /* ~14px */
align-items: stretch;
}
.grid .card-low-stock.stock-tall {
grid-row: span 2;
}
.card {
background: var(--card);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(26,43,75,.06), 0 2px 8px rgba(26,43,75,.04);
border: 1px solid var(--border);
overflow: hidden;
}
.card-hd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 1rem; /* ~16px 카드 제목 */
color: var(--text-dark);
letter-spacing: -0.03em;
line-height: 1.3;
}
.card-hd i {
color: var(--blue-ui);
margin-right: 0.3rem;
font-size: 0.875rem;
}
.card-bd { padding: 0.875rem 1rem; }
.span-3 { grid-column: span 3; }
.span-4 { grid-column: span 4; }
.span-5 { grid-column: span 5; }
.span-6 { grid-column: span 6; }
.span-8 { grid-column: span 8; }
.span-12 { grid-column: span 12; }
@media (max-width: 1200px) {
.span-3, .span-4, .span-5, .span-6, .span-8 { grid-column: span 12; }
.grid .card-low-stock.stock-tall { grid-row: span 1; }
.sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); }
.layout { flex-direction: column; }
.my-menu-list { display: flex; flex-wrap: wrap; }
}
.card-welcome {
background: #4a5568;
color: #fff;
border: none;
box-shadow: 0 2px 6px rgba(74,85,104,.25);
}
.card-welcome .card-bd { padding: 1rem 0.875rem; }
.welcome-hi {
font-size: 0.75rem;
color: rgba(255,255,255,.75);
letter-spacing: -0.02em;
}
.welcome-name {
font-size: 1rem;
font-weight: 700;
margin: 0.25rem 0 0.375rem;
letter-spacing: -0.03em;
}
.welcome-meta {
font-size: 0.6875rem;
color: rgba(255,255,255,.7);
line-height: 1.5;
letter-spacing: -0.02em;
}
.welcome-btns { display: flex; gap: 0.375rem; margin-top: 0.625rem; }
.welcome-btns a {
padding: 0.3125rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
border: 1px solid rgba(255,255,255,.35);
background: rgba(255,255,255,.08);
color: #fff;
letter-spacing: -0.02em;
}
.welcome-btns a:hover { background: rgba(255,255,255,.18); }
.notice-item {
padding: 0.4375rem 0;
border-bottom: 1px dashed #e8edf2;
}
.notice-item:last-child { border-bottom: none; }
.notice-item .notice-title {
display: block;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 0.25rem;
line-height: 1.4;
letter-spacing: -0.02em;
}
.notice-item .notice-date {
display: inline-block;
font-size: 0.6875rem;
font-weight: 500;
color: var(--blue-ui);
background: var(--blue-light);
padding: 0.125rem 0.375rem;
border-radius: 2px;
letter-spacing: 0;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.375rem;
text-align: center;
}
.stat-grid .num {
font-size: 1.5rem; /* ~22px */
font-weight: 700;
color: #2563eb;
line-height: 1.15;
letter-spacing: -0.03em;
}
.stat-grid .num.num-text { font-size: 1.125rem; color: #10b981; }
.stat-grid .lbl {
font-size: 0.6875rem;
color: var(--muted);
margin-top: 0.1875rem;
font-weight: 400;
letter-spacing: -0.02em;
}
.stat-foot {
margin-top: 0.625rem;
padding-top: 0.4375rem;
border-top: 1px solid var(--border);
font-size: 0.6875rem;
color: var(--muted);
letter-spacing: -0.02em;
}
<?php include __DIR__ . '/_dashboard_gov_portal_stock_cards_css.php'; ?>
.search-teal {
background: var(--teal);
color: #fff;
padding: 0.875rem;
border-radius: 12px;
height: 100%;
min-height: 5.5rem;
}
.search-teal strong {
font-size: 0.875rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.search-teal input {
width: 100%;
margin-top: 0.4375rem;
padding: 0.4375rem 1.75rem 0.4375rem 0.625rem;
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-family: inherit;
letter-spacing: -0.02em;
}
.search-wrap { position: relative; }
.search-wrap i {
position: absolute;
right: 0.65rem;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
}
<?php include __DIR__ . '/_dashboard_gov_portal_menu_search_css.php'; ?>
.desk-blue {
background: linear-gradient(160deg, #1a4a8a 0%, #007bff 100%);
color: #fff;
padding: 0.875rem;
border-radius: 12px;
font-size: 0.75rem;
line-height: 1.65;
letter-spacing: -0.02em;
height: 100%;
min-height: 5.5rem;
}
.desk-blue strong {
font-size: 0.875rem;
font-weight: 700;
display: block;
margin-bottom: 0.3rem;
letter-spacing: -0.03em;
}
<?php include __DIR__ . '/_dashboard_gov_portal_map_css.php'; ?>
.timeline-side {
background: var(--navy-deep);
color: #fff;
padding: 0.375rem 0.25rem;
max-height: 200px;
overflow-y: auto;
font-size: 0.75rem;
}
.timeline-side .item {
padding: 0.375rem 0.3125rem;
border-bottom: 1px solid rgba(255,255,255,.12);
}
.timeline-side .time {
display: block;
font-size: 0.8125rem;
font-weight: 700;
color: #fff;
letter-spacing: -0.02em;
margin-bottom: 0.125rem;
}
.timeline-side .ev-text {
font-size: 0.75rem;
font-weight: 600;
color: #4fc3f7;
line-height: 1.35;
letter-spacing: -0.02em;
}
.donut-wrap {
display: flex;
align-items: center;
gap: 1rem;
}
.donut {
width: 82px;
height: 82px;
border-radius: 50%;
background: conic-gradient(#3b82f6 0% 52%, #10b981 52% 80%, #f59e0b 80% 100%);
position: relative;
flex-shrink: 0;
}
.donut::after {
content: '52%';
position: absolute;
inset: 24%;
background: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.125rem;
color: var(--navy);
letter-spacing: -0.03em;
}
.donut-legend { list-style: none; font-size: 0.6875rem; color: var(--muted); line-height: 1.5; }
.mini-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 64px;
}
.mini-bars .col {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 2px;
font-size: 0.625rem;
color: var(--muted);
font-weight: 500;
}
.mini-bars .bar {
width: 100%;
background: linear-gradient(180deg, #2563eb, #60a5fa);
border-radius: 3px 3px 0 0;
min-height: 4px;
}
.gis-btn {
background: var(--blue-ui);
color: #fff;
font-size: 0.6875rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 3px;
text-decoration: none;
letter-spacing: -0.02em;
}
.flash-ok {
grid-column: 1 / -1;
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #065f46;
padding: 0.65rem 1rem;
border-radius: 8px;
font-size: 12px;
}
</style>
</head>
<body>
<header class="portal-header">
<div class="portal-header-inner">
<?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('dashboard/gov-portal')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_click', $govPortalNavPartial) ?>
<?= view('home/_dashboard_gov_portal_header_utils', ['activeVariant' => $activeVariant, 'portalVariants' => $portalVariants, 'mbName' => $mbName, 'levelName' => $levelName, 'lgLabel' => $lgLabel]) ?>
</div>
</header>
<div class="layout">
<?= view('home/_dashboard_gov_portal_sidebar', $govPortalNavPartial) ?>
<main class="main">
<div class="grid">
<?php if (session()->getFlashdata('success')): ?>
<div class="flash-ok"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<!-- 사용자 정보 (스크린샷: 다크 그레이 프로필 카드) -->
<div class="card card-welcome span-3">
<div class="card-bd">
<p class="welcome-hi">안녕하세요.</p>
<p class="welcome-name"><?= esc($mbName) ?>님</p>
<p class="welcome-meta">아이디 <?= esc($mbId) ?><br/>최근접속 <?= date('Y.m.d H:i') ?></p>
<div class="welcome-btns">
<a href="<?= base_url(gov_portal_code_kinds_portal_path('base')) ?>">기본 코드관리</a>
<a href="<?= base_url('dashboard/simple') ?>">마이페이지</a>
<a href="<?= base_url('logout') ?>">로그아웃</a>
</div>
</div>
</div>
<!-- 메시지 -->
<div class="card span-4">
<div class="card-hd"><span><i class="fa-regular fa-envelope"></i> 메시지</span></div>
<div class="card-bd">
<?php foreach ($notices as $n): ?>
<div class="notice-item">
<span class="notice-title"><?= esc($n['title']) ?></span>
<span class="notice-date"><?= esc($n['date']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- 핵심 지표 (메인 요약과 동일) -->
<div class="card span-5">
<div class="card-hd"><span><i class="fa-solid fa-warehouse"></i> 업무 현황 요약</span></div>
<div class="card-bd">
<div class="stat-grid">
<div>
<div class="num num-text">양호</div>
<div class="lbl">봉투 재고</div>
</div>
<div>
<div class="num">12</div>
<div class="lbl">미처리 구매신청</div>
</div>
<div>
<div class="num">4</div>
<div class="lbl">승인 대기</div>
</div>
</div>
<div class="stat-foot">
<i class="fa-solid fa-location-dot"></i> <?= esc($lgLabel) ?> · 기준일 <?= date('Y.m.d') ?>
</div>
</div>
</div>
<!-- 지도/타임라인 영역 -->
<div class="card span-8" style="padding:0;">
<div class="card-hd">
<span><i class="fa-solid fa-map"></i> 판매·수불 최근 동향</span>
<a href="<?= base_url('bag/flow') ?>" class="gis-btn">수불 통합 조회 &gt;</a>
</div>
<div style="display:grid;grid-template-columns:1fr 140px;">
<?= view('home/_dashboard_gov_portal_map_panel', [
'mapId' => 'govPortalMainMap',
'mapHeight' => '200px',
'lgLabel' => $lgLabel,
'govMapPanel' => $govMapPanel,
]) ?>
<div class="timeline-side">
<?php foreach ($timeline as $ev): ?>
<div class="item">
<span class="time"><?= esc($ev['time']) ?></span>
<span class="ev-text"><?= esc($ev['text']) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- 메뉴 검색 -->
<div class="span-4">
<?= view('home/_dashboard_gov_portal_menu_search', [
'variant' => 'teal',
'inputId' => 'menuSearch',
'menuSearchOptions' => $menuSearchOptions,
]) ?>
</div>
<!-- 서비스 데스크 -->
<div class="span-4">
<div class="desk-blue">
<strong><i class="fa-solid fa-headset"></i> 서비스 데스크</strong>
담당: 시스템 운영팀<br/>
문의: help@wxn.local (목업)<br/>
평일 09:00 ~ 18:00
</div>
</div>
<!-- 재고 경보 단계 -->
<div class="card span-4">
<div class="card-hd"><span><i class="fa-solid fa-triangle-exclamation"></i> 재고 경보</span></div>
<div class="card-bd">
<?= view('home/_dashboard_gov_portal_stock_alert_levels', ['stockAlerts' => $stockAlerts]) ?>
</div>
</div>
<!-- 부족 재고 — 2행 span으로 재고 구성 오른쪽 빈칸(4열)까지 세로 채움 -->
<div class="card card-low-stock stock-tall span-4">
<div class="card-hd"><span><i class="fa-solid fa-box-open"></i> 부족 재고</span></div>
<div class="card-bd">
<div class="low-stock-grid">
<?php foreach ($lowStock as $item): ?>
<div class="bar-row">
<div class="meta"><span><?= esc($item['name']) ?></span><span><?= esc((string) $item['percent']) ?>%</span></div>
<div class="bar-track"><div class="bar-fill" style="width:<?= (int) $item['percent'] ?>%"></div></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- 7일 추이 -->
<div class="card span-4">
<div class="card-hd"><span><i class="fa-solid fa-chart-column"></i> 최근 7일 신청</span></div>
<div class="card-bd">
<?php $maxReq = max($weeklyRequests); ?>
<div class="mini-bars">
<?php foreach ($weeklyRequests as $idx => $v): ?>
<?php $h = (int) round(($v / $maxReq) * 100); ?>
<div class="col">
<span><?= esc((string) $v) ?></span>
<div class="bar" style="height:<?= $h ?>%"></div>
<span>D<?= 6 - $idx ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<!-- 재고 구성 -->
<div class="card span-4">
<div class="card-hd"><span><i class="fa-solid fa-chart-pie"></i> 재고 구성</span></div>
<div class="card-bd">
<div class="donut-wrap">
<div class="donut" aria-hidden="true"></div>
<ul class="donut-legend">
<?php foreach ($stockMix as $item): ?>
<li>
<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:<?= esc($item['color'], 'attr') ?>;vertical-align:middle;margin-right:3px;"></span>
<?= esc($item['name']) ?> <?= esc((string) $item['value']) ?>%
</li>
<?php endforeach; ?>
</ul>
</div>
</div>
</div>
</div>
</main>
</div>
<?= view('home/_dashboard_gov_portal_nav_script_base', $govPortalNavPartial) ?>
<?= view('home/_dashboard_gov_portal_font_zoom_script') ?>
</body>
</html>

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
/**
* 공공 포털형 — 기본 코드관리 (NDMS 스타일 본문 + gov-portal 상단·사이드 메뉴)
*/
helper('admin');
$govPortalNavPartial = gov_portal_nav_partial_vars(get_defined_vars());
$activeVariant = $activeVariant ?? 'base';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<?= view('home/_dashboard_gov_portal_head') ?>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
:root {
--navy: #1a2b4b;
--navy-deep: #002b4e;
--blue: #0056b3;
--blue-ui: #007bff;
--blue-menu: #4a69bd;
--blue-light: #eef6ff;
--teal: #009688;
--bg: #f0f4f8;
--text: #444;
--border: #dde4ec;
--font-scale: 1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html {
font-size: calc(14px * var(--font-scale));
-webkit-text-size-adjust: 100%;
}
body {
font-family: 'Pretendard', 'Malgun Gothic', 'Apple SD Gothic Neo', 'Noto Sans KR', sans-serif;
font-size: 0.8125rem;
font-weight: 400;
line-height: 1.45;
letter-spacing: -0.01em;
color: var(--text);
background: var(--bg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
<?php include __DIR__ . '/_dashboard_gov_portal_brand_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_topnav_css.php'; ?>
<?php include __DIR__ . '/_dashboard_gov_portal_workpage_css.php'; ?>
.layout {
display: flex;
flex: 1;
min-height: 0;
}
.sidebar {
width: 168px;
flex-shrink: 0;
background: #fff;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
font-size: 0.8125rem;
}
.my-menu-hd {
background: var(--navy);
color: #fff;
padding: 0.5rem 0.625rem;
font-weight: 700;
font-size: 0.6875rem;
letter-spacing: 0.04em;
}
.my-menu-list { list-style: none; padding: 0.375rem 0.25rem; flex: 1; }
.my-menu-list li { margin: 0.1875rem 0.375rem; }
.my-menu-list a,
.my-menu-list .menu-sub {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.4375rem 0.625rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
text-decoration: none;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.02em;
}
.my-menu-list a {
color: #fff;
background: var(--blue-menu);
}
.my-menu-list a .menu-ico { font-size: 0.625rem; width: 0.75rem; text-align: center; }
.my-menu-list a.active {
background: #3d5a9e;
font-weight: 700;
border-color: rgba(255, 255, 255, 0.4);
}
.sidebar-blocks { padding: 0.5rem; font-size: 0.6875rem; }
.sb-teal {
background: var(--teal);
color: #fff;
padding: 0.5rem;
border-radius: 8px;
margin-bottom: 0.35rem;
line-height: 1.4;
}
.sb-gray {
background: #6b7280;
color: #fff;
padding: 0.5rem;
border-radius: 8px;
margin-bottom: 0.35rem;
line-height: 1.4;
}
.sb-links a { display: block; color: #3366ff; margin-top: 0.25rem; font-size: 0.6875rem; }
</style>
</head>
<body>
<header class="portal-header">
<div class="portal-header-inner">
<?= view('home/_dashboard_gov_portal_brand', ['brandHref' => base_url('dashboard/gov-portal')]) ?>
<?= view('home/_dashboard_gov_portal_topnav_click', $govPortalNavPartial) ?>
<?= view('home/_dashboard_gov_portal_header_utils', [
'activeVariant' => $activeVariant,
'portalVariants' => $portalVariants,
'mbName' => $mbName,
'levelName' => $levelName,
'lgLabel' => $lgLabel,
]) ?>
</div>
</header>
<div class="layout">
<?= view('home/_dashboard_gov_portal_sidebar', $govPortalNavPartial) ?>
<main class="main work-main">
<?= view('home/_gov_portal_code_kinds_body', get_defined_vars()) ?>
</main>
</div>
<?= view('home/_dashboard_gov_portal_nav_script_base', $govPortalNavPartial) ?>
<?= view('home/_dashboard_gov_portal_font_zoom_script') ?>
</body>
</html>

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
/**
* @deprecated 컨트롤러가 _dashboard_gov_portal_strip_layout 을 직접 렌더합니다.
*/
echo view('home/_dashboard_gov_portal_strip_layout', array_merge(get_defined_vars(), [
'stripInnerView' => 'home/_dashboard_gov_portal_strip_home_inner',
]));

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
/**
* @deprecated 컨트롤러가 _dashboard_gov_portal_strip_layout 을 직접 렌더합니다.
*/
echo view('home/_dashboard_gov_portal_strip_layout', array_merge(get_defined_vars(), [
'stripInnerView' => 'home/_gov_portal_code_kinds_body',
'stripIncludeWorkCss' => true,
'stripShowProfileLinks' => true,
]));

View File

@@ -12,6 +12,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"codeigniter4/framework": "^4.7", "codeigniter4/framework": "^4.7",
"league/commonmark": "^2.4",
"phpoffice/phpspreadsheet": "^2.2", "phpoffice/phpspreadsheet": "^2.2",
"robthree/twofactorauth": "^3.0" "robthree/twofactorauth": "^3.0"
}, },

692
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "75c3dc434e0c074b48fe108135f527ad", "content-hash": "453c36cb480c6356bc20f71f8a3d1603",
"packages": [ "packages": [
{ {
"name": "codeigniter4/framework", "name": "codeigniter4/framework",
@@ -162,6 +162,81 @@
], ],
"time": "2024-11-12T16:29:46+00:00" "time": "2024-11-12T16:29:46+00:00"
}, },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
"source": {
"type": "git",
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
"scrutinizer/ocular": "1.6.0",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Dflydev\\DotAccessData\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dragonfly Development Inc.",
"email": "info@dflydev.com",
"homepage": "http://dflydev.com"
},
{
"name": "Beau Simensen",
"email": "beau@dflydev.com",
"homepage": "http://beausimensen.com"
},
{
"name": "Carlos Frutos",
"email": "carlos@kiwing.it",
"homepage": "https://github.com/cfrutos"
},
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com"
}
],
"description": "Given a deep data structure, access data by dot notation.",
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
"keywords": [
"access",
"data",
"dot",
"notation"
],
"support": {
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
},
"time": "2024-07-08T12:26:09+00:00"
},
{ {
"name": "laminas/laminas-escaper", "name": "laminas/laminas-escaper",
"version": "2.18.0", "version": "2.18.0",
@@ -223,6 +298,195 @@
], ],
"time": "2025-10-14T18:31:13+00:00" "time": "2025-10-14T18:31:13+00:00"
}, },
{
"name": "league/commonmark",
"version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "59fb075d2101740c337c7216e3f32b36c204218b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b",
"reference": "59fb075d2101740c337c7216e3f32b36c204218b",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"league/config": "^1.1.1",
"php": "^7.4 || ^8.0",
"psr/event-dispatcher": "^1.0",
"symfony/deprecation-contracts": "^2.1 || ^3.0",
"symfony/polyfill-php80": "^1.16"
},
"require-dev": {
"cebe/markdown": "^1.0",
"commonmark/cmark": "0.31.1",
"commonmark/commonmark.js": "0.31.1",
"composer/package-versions-deprecated": "^1.8",
"embed/embed": "^4.4",
"erusev/parsedown": "^1.0",
"ext-json": "*",
"github/gfm": "0.29.0",
"michelf/php-markdown": "^1.4 || ^2.0",
"nyholm/psr7": "^1.5",
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0",
"scrutinizer/ocular": "^1.8.1",
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
"unleashedtech/php-coding-standard": "^3.1.1",
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
},
"suggest": {
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
}
},
"autoload": {
"psr-4": {
"League\\CommonMark\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
"homepage": "https://commonmark.thephpleague.com",
"keywords": [
"commonmark",
"flavored",
"gfm",
"github",
"github-flavored",
"markdown",
"md",
"parser"
],
"support": {
"docs": "https://commonmark.thephpleague.com/",
"forum": "https://github.com/thephpleague/commonmark/discussions",
"issues": "https://github.com/thephpleague/commonmark/issues",
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
"source": "https://github.com/thephpleague/commonmark"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
"type": "tidelift"
}
],
"time": "2026-03-19T13:16:38+00:00"
},
{
"name": "league/config",
"version": "v1.2.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/config.git",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
"shasum": ""
},
"require": {
"dflydev/dot-access-data": "^3.0.1",
"nette/schema": "^1.2",
"php": "^7.4 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.8.2",
"phpunit/phpunit": "^9.5.5",
"scrutinizer/ocular": "^1.8.1",
"unleashedtech/php-coding-standard": "^3.1",
"vimeo/psalm": "^4.7.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.2-dev"
}
},
"autoload": {
"psr-4": {
"League\\Config\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Colin O'Dell",
"email": "colinodell@gmail.com",
"homepage": "https://www.colinodell.com",
"role": "Lead Developer"
}
],
"description": "Define configuration arrays with strict schemas and access values with dot notation",
"homepage": "https://config.thephpleague.com",
"keywords": [
"array",
"config",
"configuration",
"dot",
"dot-access",
"nested",
"schema"
],
"support": {
"docs": "https://config.thephpleague.com/",
"issues": "https://github.com/thephpleague/config/issues",
"rss": "https://github.com/thephpleague/config/releases.atom",
"source": "https://github.com/thephpleague/config"
},
"funding": [
{
"url": "https://www.colinodell.com/sponsor",
"type": "custom"
},
{
"url": "https://www.paypal.me/colinpodell/10.00",
"type": "custom"
},
{
"url": "https://github.com/colinodell",
"type": "github"
}
],
"time": "2022-12-11T20:36:23+00:00"
},
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
"version": "3.2.2", "version": "3.2.2",
@@ -408,6 +672,164 @@
}, },
"time": "2022-12-02T22:17:43+00:00" "time": "2022-12-02T22:17:43+00:00"
}, },
{
"name": "nette/schema",
"version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
"nette/utils": "^4.0",
"php": "8.1 - 8.5"
},
"require-dev": {
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.6",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.3-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "📐 Nette Schema: validating data structures against a given Schema.",
"homepage": "https://nette.org",
"keywords": [
"config",
"nette"
],
"support": {
"issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.5"
},
"time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
"version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
"php": "8.2 - 8.5"
},
"conflict": {
"nette/finder": "<3",
"nette/schema": "<1.2.2"
},
"require-dev": {
"jetbrains/phpstorm-attributes": "^1.2",
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.5",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1@stable",
"tracy/tracy": "^2.9"
},
"suggest": {
"ext-gd": "to use Image",
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
"ext-json": "to use Nette\\Utils\\Json",
"ext-mbstring": "to use Strings::lower() etc...",
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "4.1-dev"
}
},
"autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
"GPL-2.0-only",
"GPL-3.0-only"
],
"authors": [
{
"name": "David Grudl",
"homepage": "https://davidgrudl.com"
},
{
"name": "Nette Community",
"homepage": "https://nette.org/contributors"
}
],
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
"homepage": "https://nette.org",
"keywords": [
"array",
"core",
"datetime",
"images",
"json",
"nette",
"paginator",
"password",
"slugify",
"string",
"unicode",
"utf-8",
"utility",
"validation"
],
"support": {
"issues": "https://github.com/nette/utils/issues",
"source": "https://github.com/nette/utils/tree/v4.1.4"
},
"time": "2026-05-11T20:49:54+00:00"
},
{ {
"name": "phpoffice/phpspreadsheet", "name": "phpoffice/phpspreadsheet",
"version": "2.4.4", "version": "2.4.4",
@@ -514,6 +936,56 @@
}, },
"time": "2026-04-10T03:20:38+00:00" "time": "2026-04-10T03:20:38+00:00"
}, },
{
"name": "psr/event-dispatcher",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/event-dispatcher.git",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
"reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
"shasum": ""
},
"require": {
"php": ">=7.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\EventDispatcher\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Standard interfaces for event handling.",
"keywords": [
"events",
"psr",
"psr-14"
],
"support": {
"issues": "https://github.com/php-fig/event-dispatcher/issues",
"source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
},
"time": "2019-01-08T18:20:26+00:00"
},
{ {
"name": "psr/log", "name": "psr/log",
"version": "3.0.2", "version": "3.0.2",
@@ -695,6 +1167,157 @@
} }
], ],
"time": "2026-01-05T13:17:41+00:00" "time": "2026-01-05T13:17:41+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php80\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ion Bazan",
"email": "ion.bazan@gmail.com"
},
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [
@@ -2485,73 +3108,6 @@
], ],
"time": "2023-02-07T11:34:05+00:00" "time": "2023-02-07T11:34:05+00:00"
}, },
{
"name": "symfony/deprecation-contracts",
"version": "v3.6.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
"reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/contracts",
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.6-dev"
}
},
"autoload": {
"files": [
"function.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-25T14:21:43+00:00"
},
{ {
"name": "theseer/tokenizer", "name": "theseer/tokenizer",
"version": "1.3.1", "version": "1.3.1",

View File

@@ -0,0 +1,294 @@
# 봉투·LOT·바코드 코드 체계
종량제 물류 시스템에서 사용하는 **봉투번호(바코드)**, **LOT 번호**, **품목코드** 등이 어디서 만들어지고 무엇을 의미하는지 정리한 문서입니다.
구현 기준: `app/Controllers/Bag.php`, `app/Controllers/Admin/BagOrder.php`, `app/Libraries/BagLotFlowBuilder.php`, `writable/database/*.sql`.
---
## 1. 계층 구조 (한눈에 보기)
```
[지자체] lg_code (예: 110204 남구)
└── [발주] bag_order
├── bo_lot_no … LOT 번호 (제작·추적 단위)
├── bo_uuid … 발주 식별자 (버전 이력)
└── bo_hash … 발주 내용 무결성 (SHA-256)
└── [입고] bag_receiving (br_idx)
└── bag_receiving_pack_code
├── brpc_box_code … 박스 바코드
├── brpc_pack_code … 팩 바코드
├── brpc_sheet_start_code ~ end … 낱장 구간
└── brpc_lot_no … 발주 LOT 복사
└── [판매/반품 스캔]
├── bag_sale_scan_code.bssc_code
└── bag_return_scan_code.brsc_code
```
| 단위 | 예시 | 조회·스캔 용도 |
|------|------|----------------|
| **LOT** | `OQXCKH` | 발주 단위, LOT 수불·디스켓 시드 |
| **박스** | `OQXCKH-000008-B001` | 박스 단위 입출고·LOT 수불 |
| **팩** | `OQXCKH-000008-P299` | 팩 단위 (스캔 가능) |
| **낱장** | `OQXCKH-000008-P299-S00125` | 장(매) 단위 판매·반품·LOT 수불 |
> **참고:** `000008`은 “8번째 봉투”가 아니라 **입고 건 PK(`br_idx`)를 6자리로 채운 값**입니다.
---
## 2. 발주 LOT 번호 (`bo_lot_no`)
### 생성 시점·위치
- **화면:** 관리자 발주 등록·수정 (`Admin\BagOrder::store`)
- **테이블:** `bag_order.bo_lot_no`
### 생성 규칙
1. **신규 발주:** `generateLotNo6()` — 영문 대문자+숫자 **6자리** 난수 (`0-9`, `A-Z`), DB 중복 시 최대 20회 재시도.
2. **실패 시:** 타임스탬프 기반 6자리 fallback (`base_convert(time, 10, 36)`).
3. **발주 수정(재발주):** 기존 건의 `bo_lot_no`**유지** (새 LOT를 붙이지 않음).
4. **입고 시 LOT 미지정:** 주문에 LOT가 없으면 `bag_code`(품목코드)를 LOT 자리에 대체 사용 (`createReceivingPackCodes`).
### 의미
- 제작업체·지자체가 **한 번의 발주 묶음**을 식별하는 번호.
- 입고 후 `bag_receiving_pack_code.brpc_lot_no`에 복사되어 박스/팩/낱장 코드의 **접두어**가 됩니다.
- **LOT-No 디스켓 불출** (`/bag/order/lot-seed`)·바코드 시드 파일명에 사용.
### 예시
| 값 | 설명 |
|----|------|
| `OQXCKH` | 발주 시 부여된 6자리 LOT (시드 예: `writable/barcode-seeds/OQXCKH_v1.seed.json`) |
| `3K9F2A` | 다른 발주 건의 LOT |
---
## 3. 발주 UUID·버전·해시
| 필드 | 형식 | 의미 |
|------|------|------|
| `bo_uuid` | UUID v4 (`xxxxxxxx-xxxx-4xxx-...`) | 동일 발주의 **버전 묶음** 식별자 |
| `bo_version` | 정수 (1부터 증가) | 발주 **개정 이력** (복합 PK: `uuid` + `version`) |
| `bo_hash` | SHA-256 64자 hex | 발주 헤더+품목 JSON의 **무결성 검증**용 |
- 수정·재발주 시 새 `bo_version` 행이 생기고, `bo_hash`가 갱신됩니다.
- 블록체인 연동 시 `SqlLedger``ORDER_CREATE` / `ORDER_UPDATE`와 함께 기록됩니다.
---
## 4. 바코드 시드 파일 (제작업체 연동)
### 생성 시점
- 발주 저장 시: `Admin\BagOrder::generateBarcodeSeedFile()`
- LOT 디스켓 불출 시: `Bag::orderLotSeedGenerate()` (동일 포맷)
### 저장 위치·파일명
- 디렉터리: `writable/barcode-seeds/`
- 파일명: `{bo_lot_no}_v{bo_version}.seed.json`
예: `OQXCKH_v1.seed.json`
### 내용
- 발주·품목 메타를 JSON으로 묶고 **AES-256-CBC**로 암호화.
- AES 키는 **RSA-2048** 공개키로 래핑 (`writable/keys/barcode_seed_*.pem`).
- 평문에는 `lot_no`, `uuid`, `version`, `order`, `items`, `order_hash` 등이 포함됩니다.
### 의미 (운영)
- 레거시/요구사항상 **제작업체 인쇄용 바코드 원시데이터**를 지자체가 발주와 함께 넘기는 용도.
- **현재 웹 입고 바코드**(`bag_receiving_pack_code`)는 아래 5절처럼 **입고 처리 시 서버가 별도 생성**합니다.
(README 「바코드 생성/사용 시점」 참고 — 발주 시점 생성 vs 입고 시점 생성 정책 검토 중)
---
## 5. 입고 시 박스·팩·낱장 바코드 (`bag_receiving_pack_code`)
### 생성 시점·함수
- **함수:** `Bag::createReceivingPackCodes()`
- **호출:** 입고 스캐너·일괄 입고 저장 시 (`receiving/scanner/store`, `receiving/batch/store` 등)
- **조건:** 해당 `br_idx`에 팩 코드가 아직 없을 때만 1회 생성
### 입력·포장 단위
- `packaging_unit` (`pu_pack_per_sheet`, `pu_total_per_box`): 팩당 낱장 수, 박스당 총 낱장 수.
- 입고 낱장 수 `qtySheet`**필요 팩 개수** = `ceil(qtySheet / pack_per_sheet)`.
- **박스당 팩 수** = `total_per_box / pack_per_sheet`.
### 코드 포맷 (구현)
`$lotNo` = 발주 `bo_lot_no` (없으면 품목코드), `$brIdx` = `bag_receiving.br_idx`.
| 구분 | sprintf 패턴 | 예 (`lot=OQXCKH`, `br_idx=8`) |
|------|----------------|-------------------------------|
| 박스 | `{lotNo}-%06d-B%03d` | `OQXCKH-000008-B001` |
| 팩 | `{lotNo}-%06d-P%03d` | `OQXCKH-000008-P001``P299` |
| 낱장 시작 | `{packCode}-S%05d` | `OQXCKH-000008-P299-S00001` |
| 낱장 끝 | `{packCode}-S%05d` | `OQXCKH-000008-P299-S00300` (팩당 300장 예) |
- `%06d``br_idx` 6자리 (000008 = 8번 입고 건).
- `%03d` → 박스·팩 **순번** (001~999).
- `%05d` → 팩 **내부 낱장 순번** (00001~).
### DB 컬럼 의미
| 컬럼 | 설명 |
|------|------|
| `brpc_lot_no` | 발주 LOT |
| `brpc_box_code` | 박스 바코드 |
| `brpc_pack_code` | 팩 바코드 (UNIQUE) |
| `brpc_sheet_start_code` / `brpc_sheet_end_code` | 해당 팩에 속한 낱장 코드 구간 |
| `brpc_sheet_qty` | 그 팩의 낱장 매수 |
| `brpc_state` | `in_stock` / `sold` 등 재고 상태 |
### LOT 수불 조회에서의 해석
- **낱장 번호 입력** → 해당 장의 판매·반품만 + 소속 팩 **입고 1건**.
- **팩 번호** (`…-P299`) → 팩·박스 코드 기준 이력.
- **LOT만** (`lot_no` 파라미터) → 동일 LOT 전체 팩·입고·발주 요약.
---
## 6. 봉투 품목코드 (`code_detail`, 종류 `O`)
### 등록
- **종류:** `code_kind.ck_code = 'O'` (봉투명/품목)
- **테이블:** `code_detail.cd_code`, `cd_name`
- 지자체별 확장: `cd_lg_idx` (0이면 공통)
### 코드 구성 (5자리 숫자 관례)
대구 시드(`code_master_init_daegu.sql`) 기준:
| 자리 | 연계 종류 | 예 |
|------|-----------|-----|
| 앞 2자리 | `E` 봉투구분 (10=일반, 20=공공, 30=무료, 60=음식물…) | `10` |
| 다음 2자리 | `G` 용량 (15=20L, 13=10L…) | `15` |
| 마지막 1자리 | `F` 재질 등 | `2` |
**예:** `10152` = 일반용(`10`) + 20L(`15`) + … → **일반용 20L**
- 발주·입고·재고·판매 집계는 이 코드(`bs_bag_code`, `br_bag_code`, `bi_bag_code` 등)로 묶입니다.
- **바코드(LOT·팩·낱장)와는 별개** — 품목 종류 식별용 마스터 코드입니다.
---
## 7. 지자체·행정 코드
| 코드 | 필드 | 예 | 의미 |
|------|------|-----|------|
| 지자체 | `local_government.lg_code` | `110204` | 대구 남구 (6자리 관례) |
| 발주 구·군 | `bag_order.bo_gugun_code` | `110204` | 발주 시 지자체 `lg_code` 복사 |
| 발주 동 | `bag_order.bo_dong_code` | (동 코드) | 세부 행정 단위 |
---
## 8. 지정판매소 번호 (`ds_shop_no`)
### 자동 부여 (주소 기반)
`Admin\DesignatedShop::resolveDesignatedShopNumberFromAddress()`:
```
판매소번호 = B코드 + C코드 + D코드 + 3자리 일련번호
```
- **B:** 시·도 (`code_kind` = `B`, 주소 매칭)
- **C:** 구·군 (`C`, B 접두 포함)
- **D:** 동 (`D`, C 접두 포함)
- **일련:** 동일 지자체 내 기존 번호 끝 3자리 숫자 최댓값 + 1
### 시드·레거시
- 초기 데이터에 `DS-2024-001` 형태가 있을 수 있음 (수동/이관 데이터).
- **가상계좌:** `ds_va_number` / `ds_va_bank` + `ds_va_account`
### 판매 스캔과의 관계
- 판매·반품 시 `bssc_ds_idx` / `brsc_ds_idx`로 판매소 연결.
- LOT 수불·판매 대장의 **입출고처**에 `ds_name` 표시.
---
## 9. 판매·반품 스캔 코드
### 저장 테이블
| 테이블 | 코드 컬럼 | 생성 시점 |
|--------|-----------|-----------|
| `bag_sale_scan_code` | `bssc_code` | 지정판매소 판매 (`/bag/sale/designated`) |
| `bag_return_scan_code` | `brsc_code` | 지정판매소 반품 (`/bag/sale/designated-return`) |
### 규칙
- 스캔·입력 값 = **입고 시 생성된 바코드** (보통 **낱장** 또는 팩).
- `bssc_state`: `in_stock` → 판매 시 `sold`, 반품 시 다시 `in_stock`.
- **낱장 판매** 시 동일 팩의 다른 낱장을 위해 `bag_receiving_pack_code` 팩 상태는 유지할 수 있음 (코드 주석 참고).
### 주문·판매 집계 코드
- `bag_sale.bs_type`: `sale` / `return` / `cancel`
- `shop_order` / `shop_order_item`: 주문 접수용 (전화·웹 등), 판매소·품목·수량 — **LOT 바코드와 별도 흐름**.
---
## 10. 기타 코드
| 구분 | 예 | 설명 |
|------|-----|------|
| 회원 | `mb_id` | 로그인 ID (시스템 부여·가입) |
| 제작업체 | `company.cp_*` | 발주 `bo_company_idx` |
| 판매 대행소 | `sales_agency.sa_*` | 발주·입고 `bo_agency_idx` |
| 무료 불출 | `bag_issue` | 불출처·수량 (바코드 LOT 체계와 별도) |
| 반품/파기 시드 | `RET-20260508-DS1-001` | 테스트용 반품 스캔 코드 (`bag_dispose_tables.sql`) |
| 파기 | `bag_dispose` | 입고분 파기 이력 (반품/파기 현황 **입고** 탭) |
---
## 11. 화면별 “어떤 코드를 넣나”
| 화면 | 입력·표시 코드 | 데이터 소스 |
|------|----------------|-------------|
| LOT 수불 조회 | 봉투번호(바코드)·`lot_no` | `BagLotFlowBuilder` |
| 지정판매소 판매/반품 | 스캔 바코드 | `bag_sale_scan_code` / `bag_return_scan_code` |
| 반품/파기 현황 출고 | (조회만) | `bag_return_scan_code` |
| 반품/파기 현황 입고 | (조회만) | `bag_dispose` |
| 기간별 봉투 수불 | 품목코드 `O` | 집계 (`BagFlowReportBuilder`) |
| 발주·입고 | LOT, 품목코드 | `bag_order`, `bag_receiving` |
---
## 12. 구현·스키마 참고 파일
| 주제 | 파일 |
|------|------|
| 입고 바코드 생성 | `app/Controllers/Bag.php``createReceivingPackCodes()` |
| LOT 6자리 생성 | `app/Controllers/Admin/BagOrder.php``generateLotNo6()` |
| LOT 수불 조회 | `app/Libraries/BagLotFlowBuilder.php` |
| 팩 코드 테이블 | `writable/database/receiving_pack_code_tables.sql` |
| 발주 테이블 | `writable/database/order_tables.sql` |
| 품목 마스터 | `writable/database/code_master_init_daegu.sql` (종류 `O`) |
| 바코드 시드 샘플 | `writable/barcode-seeds/*.seed.json` |
---
## 13. 자주 묻는 혼동
1. **`OQXCKH-000008-P299``P300`이 비슷해 보이는데 수불이 같다?**
- 이전에는 팩·LOT 전체 이력을 함께 가져왔습니다.
- 수정 후 **낱장 단위**는 `…-P299-Sxxxxx` 형태로 조회하거나, 팩 단위는 P299/P300 각각 조회해야 판매 이력이 갈립니다.
2. **LOT 6자리와 접두 `OQXCKH`가 다른 길이?**
- 발주 LOT는 6자리 규칙이지만, 시드·테스트 데이터에 6자 영문(`OQXCKH`)을 쓴 경우가 있습니다. DB `VARCHAR(50)`에 저장되며, 입고 코드 접두로 그대로 사용됩니다.
3. **품목코드 `10152`와 바코드 `OQXCKH-…`의 관계?**
- `10152`**종류·용량 마스터**.
- `OQXCKH-…`**물리 단위(팩/장) 추적용** 바코드입니다. 같은 품목이라도 LOT·입고 건마다 바코드 접두가 달라집니다.
---
*문서 갱신: 코드 변경 시 `createReceivingPackCodes`, `generateLotNo6`, `BagLotFlowBuilder`를 우선 확인하세요.*

41
e2e/manual.spec.js Normal file
View File

@@ -0,0 +1,41 @@
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers/auth');
/**
* 사용자 매뉴얼(bag/manual) E2E
* - 비로그인 차단(loginAuth)
* - 로그인 후 목차/본문 렌더
* - 목차 이동(표 렌더)
* - 미등록 slug 404
*/
test.describe('사용자 매뉴얼', () => {
test('비로그인 시 로그인으로 이동', async ({ page }) => {
await page.goto('/bag/manual');
await expect(page).toHaveURL(/\/login/);
});
test('로그인 후 매뉴얼 첫 페이지(개요) 렌더 + 목차 노출', async ({ page }) => {
await login(page, 'user');
await page.goto('/bag/manual');
// manual 첫 slug(overview)로 이동
await expect(page).toHaveURL(/\/bag\/manual\/overview/);
// 좌측 목차
await expect(page.locator('.manual-toc')).toBeVisible();
await expect(page.locator('.manual-toc a', { hasText: '핵심 업무 흐름' })).toBeVisible();
// 본문
await expect(page.locator('.manual-prose h1')).toContainText('시스템 개요');
});
test('목차에서 코드체계 페이지로 이동 → 표 렌더', async ({ page }) => {
await login(page, 'user');
await page.goto('/bag/manual/codes');
await expect(page.locator('.manual-prose table').first()).toBeVisible();
await expect(page.locator('.manual-prose')).toContainText('바코드');
});
test('미등록 slug 는 404', async ({ page }) => {
await login(page, 'user');
const res = await page.goto('/bag/manual/does-not-exist');
expect(res.status()).toBe(404);
});
});

View File

@@ -240,16 +240,27 @@ test.describe('P5-08: 반품/파기 현황', () => {
test('기간·입출고 조회 및 엑셀', async ({ page }) => { test('기간·입출고 조회 및 엑셀', async ({ page }) => {
await loginAsLocal(page); await loginAsLocal(page);
await page.goto('/bag/reports/returns?search=1&start_date=2026-01-01&end_date=2026-12-31&io_type=out'); await page.goto('/bag/reports/returns?search=1&start_date=2026-05-01&end_date=2026-05-31&io_type=out');
await expect(page.locator('table.data-table')).toBeVisible(); await expect(page.locator('table.data-table')).toBeVisible();
await expect(page.locator('table.data-table tbody tr').first()).not.toContainText('해당 자료가 없습니다');
await expect(page.getByText('반품').first()).toBeVisible();
await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible(); await expect(page.getByRole('link', { name: '엑셀저장' })).toBeVisible();
const res = await page.request.get( const res = await page.request.get(
'/bag/reports/returns/export?search=1&start_date=2026-01-01&end_date=2026-12-31&io_type=out' '/bag/reports/returns/export?search=1&start_date=2026-05-01&end_date=2026-05-31&io_type=out'
); );
expect(res.status()).toBe(200); expect(res.status()).toBe(200);
const ct = (res.headers()['content-type'] || '').toLowerCase(); const ct = (res.headers()['content-type'] || '').toLowerCase();
expect(ct).toContain('application/vnd.ms-excel'); expect(ct).toContain('application/vnd.ms-excel');
}); });
test('5월 입고(파기) 조회', async ({ page }) => {
await loginAsLocal(page);
await page.goto('/bag/reports/returns?search=1&start_date=2026-05-01&end_date=2026-05-31&io_type=in');
await expect(page.locator('table.data-table')).toBeVisible();
await expect(page.locator('table.data-table tbody tr').first()).not.toContainText('해당 자료가 없습니다');
await expect(page.getByText('물류창고')).toBeVisible();
await expect(page.getByText('파기').first()).toBeVisible();
});
}); });
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

37
e2e/number-lookup.spec.js Normal file
View File

@@ -0,0 +1,37 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const { login } = require('./helpers/auth');
test.describe('번호알기 (봉투번호확인)', () => {
test.beforeEach(async ({ page }) => {
await login(page, 'local');
});
test('페이지 로드 및 기본 UI', async ({ page }) => {
await page.goto('/bag/number-lookup');
await expect(page).toHaveURL(/\/bag\/number-lookup/);
await expect(page.locator('#numLookupTitle')).toHaveText(/봉투번호확인/);
await expect(page.locator('#codeInput')).toBeVisible();
await expect(page.locator('#barcodeOut')).toHaveText(/- - - -/);
await expect(page.locator('#printOut')).toHaveText(/- - -/);
await expect(page.locator('#recognitionOut')).toHaveText(/- -/);
});
test('LOT-팩-낱장 코드 조회', async ({ page }) => {
await page.goto('/bag/number-lookup');
await page.fill('#codeInput', 'OQXCKH-000008-P299-S00125');
await page.click('button.num-lookup-btn-primary');
await expect(page).toHaveURL(/code=OQXCKH-000008-P299-S00125/);
await expect(page.locator('#barcodeOut')).toContainText('OQXCKH');
await expect(page.locator('#barcodeOut')).toContainText('P299');
await expect(page.locator('#printOut')).toContainText('8');
await expect(page.locator('#recognitionOut')).toContainText('P299');
});
test('도움말에서 번호알기 링크', async ({ page }) => {
await page.goto('/bag/help');
await page.click('a[href*="bag/number-lookup"]');
await expect(page).toHaveURL(/\/bag\/number-lookup/);
});
});

View File

@@ -163,6 +163,13 @@
> 최신 항목이 위에 옵니다. > 최신 항목이 위에 옵니다.
### 2026-06-05
- **DOC-03** 사용자 매뉴얼(설명서) 마크다운 기반 in-app 문서 시스템 추가
- `league/commonmark` 도입, `bag/manual` 라우트(loginAuth 보호), `ManualRenderer` 라이브러리 + `Config\Manual` manifest
- 콘텐츠 8종(`app/Docs/manual/*.md`): 개요/업무흐름/발주입고/재고실사/판매불출/판매현황·수불·통계/코드체계/FAQ
- `bag/help`에 매뉴얼 링크 추가, E2E 4건(`e2e/manual.spec.js`) 통과
### 2026-03-25 ### 2026-03-25
- **P5** Phase 5 판매대장/일계표/기간별현황/수불현황 리포트 (`f451f0f`) - **P5** Phase 5 판매대장/일계표/기간별현황/수불현황 리포트 (`f451f0f`)

View File

@@ -0,0 +1,107 @@
-- ============================================
-- 봉투 파기(입고분 폐기) + 5월 테스트 시드
-- 반품/파기 현황: 입출고=입고 시 bag_dispose 조회
-- ============================================
CREATE TABLE IF NOT EXISTS `bag_dispose` (
`bd_idx` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`bd_lg_idx` INT UNSIGNED NOT NULL,
`bd_dispose_date` DATE NOT NULL COMMENT '파기일',
`bd_location` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '파기처(창고·처리장 등)',
`bd_bag_code` VARCHAR(50) NOT NULL,
`bd_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
`bd_qty` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '파기수량(매)',
`bd_reason` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '사유',
`bd_regdate` DATETIME NOT NULL,
PRIMARY KEY (`bd_idx`),
KEY `idx_bd_lg_date` (`bd_lg_idx`, `bd_dispose_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='봉투 파기(입고분)';
-- 지정판매소 반품 스캔(없으면 생성) — designated-return 과 동일 구조
CREATE TABLE IF NOT EXISTS `bag_return_scan_code` (
`brsc_idx` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`brsc_lg_idx` INT UNSIGNED NOT NULL,
`brsc_so_idx` INT UNSIGNED NOT NULL,
`brsc_ds_idx` INT UNSIGNED NOT NULL,
`brsc_bag_code` VARCHAR(50) NOT NULL DEFAULT '',
`brsc_bag_name` VARCHAR(100) NOT NULL DEFAULT '',
`brsc_code` VARCHAR(120) NOT NULL,
`brsc_unit` VARCHAR(10) NOT NULL DEFAULT '',
`brsc_qty` INT UNSIGNED NOT NULL DEFAULT 0,
`brsc_unit_price` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`brsc_amount` DECIMAL(14,2) NOT NULL DEFAULT 0.00,
`brsc_return_date` DATE NOT NULL,
`brsc_state` VARCHAR(20) NOT NULL DEFAULT 'returned',
`brsc_regdate` DATETIME NOT NULL,
PRIMARY KEY (`brsc_idx`),
KEY `idx_brsc_lg_return_date` (`brsc_lg_idx`, `brsc_return_date`),
KEY `idx_brsc_ds_idx` (`brsc_ds_idx`),
KEY `idx_brsc_state` (`brsc_state`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='지정판매소 반품 스캔 코드';
SET @LG_IDX = (SELECT lg_idx FROM local_government WHERE lg_code = '110204' LIMIT 1);
SET @DS1 = COALESCE(
(SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = @LG_IDX AND ds_name = 'CU 대명점' LIMIT 1),
(SELECT MIN(ds_idx) FROM designated_shop WHERE ds_lg_idx = @LG_IDX)
);
SET @DS2 = COALESCE(
(SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = @LG_IDX AND ds_name = 'GS25 앞산점' LIMIT 1),
(SELECT MIN(ds_idx) + 1 FROM designated_shop WHERE ds_lg_idx = @LG_IDX)
);
SET @DS4 = COALESCE(
(SELECT ds_idx FROM designated_shop WHERE ds_lg_idx = @LG_IDX AND ds_name = '봉덕슈퍼' LIMIT 1),
(SELECT MAX(ds_idx) FROM designated_shop WHERE ds_lg_idx = @LG_IDX)
);
SET @SO1 = (SELECT so_idx FROM shop_order WHERE so_lg_idx = @LG_IDX ORDER BY so_idx ASC LIMIT 1);
DELETE FROM `bag_dispose`
WHERE `bd_lg_idx` = @LG_IDX AND `bd_dispose_date` BETWEEN '2026-05-01' AND '2026-05-31';
DELETE FROM `bag_return_scan_code`
WHERE `brsc_lg_idx` = @LG_IDX AND `brsc_code` LIKE 'RET-202605%';
-- 2026년 5월 파기(입고) — 반품/파기 현황 입고 조회용
INSERT INTO `bag_dispose` (`bd_lg_idx`, `bd_dispose_date`, `bd_location`, `bd_bag_code`, `bd_bag_name`, `bd_qty`, `bd_reason`, `bd_regdate`) VALUES
(@LG_IDX, '2026-05-05', '남구 종량제 물류창고', '10152', '일반용 20L', 120, '유통기한 경과', '2026-05-05 10:00:00'),
(@LG_IDX, '2026-05-12', '남구 종량제 물류창고', '60102', '음식물 2L', 80, '오염·훼손', '2026-05-12 11:30:00'),
(@LG_IDX, '2026-05-19', '남구 종량제 물류창고', '10172', '일반용 50L', 45, '재고 정리', '2026-05-19 09:15:00');
-- 2026년 5월 지정판매소 반품(designated-return) — 출고 조회용
INSERT INTO `bag_return_scan_code`
(`brsc_lg_idx`, `brsc_so_idx`, `brsc_ds_idx`, `brsc_bag_code`, `brsc_bag_name`, `brsc_code`, `brsc_unit`, `brsc_qty`, `brsc_unit_price`, `brsc_amount`, `brsc_return_date`, `brsc_state`, `brsc_regdate`)
SELECT @LG_IDX, IFNULL(@SO1, 0), @DS1, '10152', '일반용 20L', 'RET-20260508-DS1-001', '', 25, 670.00, 16750.00, '2026-05-08', 'returned', '2026-05-08 14:20:00'
WHERE @DS1 IS NOT NULL
UNION ALL SELECT @LG_IDX, IFNULL(@SO1, 0), @DS1, '10152', '일반용 20L', 'RET-20260508-DS1-002', '', 10, 670.00, 6700.00, '2026-05-08', 'returned', '2026-05-08 14:21:00'
WHERE @DS1 IS NOT NULL
UNION ALL SELECT @LG_IDX, IFNULL(@SO1, 0), @DS2, '10132', '일반용 10L', 'RET-20260515-DS2-001', '', 40, 340.00, 13600.00, '2026-05-15', 'returned', '2026-05-15 16:00:00'
WHERE @DS2 IS NOT NULL
UNION ALL SELECT @LG_IDX, IFNULL(@SO1, 0), @DS4, '10162', '일반용 30L', 'RET-20260522-DS4-001', '', 15, 1080.00, 16200.00, '2026-05-22', 'returned', '2026-05-22 11:45:00'
WHERE @DS4 IS NOT NULL;
-- E2E·북구(lg_idx=1) 등 designated_shop 이 있는 지자체
SET @LG_T = 1;
SET @DS_T1 = (SELECT MIN(ds_idx) FROM designated_shop WHERE ds_lg_idx = @LG_T);
SET @DS_T2 = (SELECT MIN(ds_idx) + 1 FROM designated_shop WHERE ds_lg_idx = @LG_T);
SET @SO_T = (SELECT so_idx FROM shop_order WHERE so_lg_idx = @LG_T ORDER BY so_idx ASC LIMIT 1);
DELETE FROM `bag_dispose`
WHERE `bd_lg_idx` = @LG_T AND `bd_dispose_date` BETWEEN '2026-05-01' AND '2026-05-31';
DELETE FROM `bag_return_scan_code`
WHERE `brsc_lg_idx` = @LG_T AND `brsc_code` LIKE 'RET-202605-LG1-%';
INSERT INTO `bag_dispose` (`bd_lg_idx`, `bd_dispose_date`, `bd_location`, `bd_bag_code`, `bd_bag_name`, `bd_qty`, `bd_reason`, `bd_regdate`) VALUES
(@LG_T, '2026-05-07', '북구 종량제 물류창고', '10152', '일반용 20L', 60, '유통기한 경과', '2026-05-07 10:00:00'),
(@LG_T, '2026-05-14', '북구 종량제 물류창고', '10132', '일반용 10L', 35, '오염·훼손', '2026-05-14 11:00:00');
INSERT INTO `bag_return_scan_code`
(`brsc_lg_idx`, `brsc_so_idx`, `brsc_ds_idx`, `brsc_bag_code`, `brsc_bag_name`, `brsc_code`, `brsc_unit`, `brsc_qty`, `brsc_unit_price`, `brsc_amount`, `brsc_return_date`, `brsc_state`, `brsc_regdate`)
SELECT @LG_T, IFNULL(@SO_T, 0), @DS_T1, '10152', '일반용 20L', 'RET-202605-LG1-001', '', 20, 670.00, 13400.00, '2026-05-09', 'returned', '2026-05-09 14:00:00'
WHERE @DS_T1 IS NOT NULL
UNION ALL SELECT @LG_T, IFNULL(@SO_T, 0), @DS_T2, '10132', '일반용 10L', 'RET-202605-LG1-002', '', 30, 340.00, 10200.00, '2026-05-16', 'returned', '2026-05-16 15:00:00'
WHERE @DS_T2 IS NOT NULL;
-- 테스터 지자체관리자(mb_lg_idx=10 등) 환경: 파기만 시드(판매소 없을 수 있음)
SET @LG_T = 10;
DELETE FROM `bag_dispose`
WHERE `bd_lg_idx` = @LG_T AND `bd_dispose_date` BETWEEN '2026-05-01' AND '2026-05-31';
INSERT INTO `bag_dispose` (`bd_lg_idx`, `bd_dispose_date`, `bd_location`, `bd_bag_code`, `bd_bag_name`, `bd_qty`, `bd_reason`, `bd_regdate`) VALUES
(@LG_T, '2026-05-06', '중구 종량제 물류창고', '10152', '일반용 20L', 50, '유통기한 경과', '2026-05-06 10:00:00');

View File

@@ -0,0 +1,10 @@
-- 도움말 > 번호알기 메뉴를 전용 화면으로 연결
-- mysql --default-character-set=utf8mb4 -u ... -p DBNAME < writable/database/menu_link_number_lookup.sql
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/number-lookup'
WHERE m.mm_name IN ('번호알기', '번호 알기')
AND (TRIM(COALESCE(m.mm_link, '')) = '' OR m.mm_link = 'bag/help');

View File

@@ -64,7 +64,7 @@ SET m.mm_link = CASE m.mm_name
WHEN '도움말 항목' THEN 'bag/help' WHEN '도움말 항목' THEN 'bag/help'
WHEN '원격 요청' THEN 'bag/help' WHEN '원격 요청' THEN 'bag/help'
WHEN 'pda 리셋' THEN 'bag/help' WHEN 'pda 리셋' THEN 'bag/help'
WHEN '번호알기' THEN 'bag/help' WHEN '번호알기' THEN 'bag/number-lookup'
WHEN 'Data Backup' THEN 'bag/help' WHEN 'Data Backup' THEN 'bag/help'
WHEN '컴포트 설정' THEN 'bag/help' WHEN '컴포트 설정' THEN 'bag/help'
WHEN 'Version 정보' THEN 'bag/help' WHEN 'Version 정보' THEN 'bag/help'

View File

@@ -349,7 +349,7 @@ SELECT @mt_site, 1, t.mm_name,
WHEN '도움말 항목' THEN 'bag/help' WHEN '도움말 항목' THEN 'bag/help'
WHEN '원격 요청' THEN 'bag/help' WHEN '원격 요청' THEN 'bag/help'
WHEN 'pda 리셋' THEN 'bag/help' WHEN 'pda 리셋' THEN 'bag/help'
WHEN '번호알기' THEN 'bag/help' WHEN '번호알기' THEN 'bag/number-lookup'
WHEN 'Data Backup' THEN 'bag/help' WHEN 'Data Backup' THEN 'bag/help'
WHEN '컴포트 설정' THEN 'bag/help' WHEN '컴포트 설정' THEN 'bag/help'
WHEN 'Version 정보' THEN 'bag/help' WHEN 'Version 정보' THEN 'bag/help'