사용자 매뉴얼·번호알기·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

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>
<button type="submit" class="bg-btn-search text-white px-4 py-1 rounded-sm">조회</button>
</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>
<?php if ($error !== ''): ?>

View File

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

View File

@@ -38,6 +38,7 @@ $printExtra = [
<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-xs text-gray-500 px-2 py-1 border-b">(단위: 매, 원) · <strong>판매(sale)</strong> 수량·금액만 집계합니다(반품·취소 제외). 증감은 전년·당해 판매량(또는 금액) 차이입니다.</p>
<table class="w-full data-table text-xs">
<thead>
<tr>

View File

@@ -9,8 +9,19 @@ $rows = is_array($rows ?? null) ? $rows : [];
$bagProducts = is_array($bagProducts ?? null) ? $bagProducts : [];
$bagKindOptions = is_array($bagKindOptions ?? null) ? $bagKindOptions : [];
$agencies = is_array($agencies ?? null) ? $agencies : [];
$exportQuery = (string) ($exportQuery ?? 'search=1');
$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);
$printExtraLines = [];
@@ -18,6 +29,13 @@ if ($queried) {
$aggLabel = $aggMode === 'daily' ? '일자별' : '기간별';
$printExtraLines[] = '조회기간: ' . $startDate . ' ~ ' . $endDate . ' (' . $aggLabel . ')';
}
$tipPage = "조회 기간 동안 봉투 품목별 입고·출고·잔량을 집계하는 수불표입니다.\n"
. "· 집계방식: 일자별(날짜마다) / 기간별(기간 합계)\n"
. "· 전일재고: 조회 시작일 전날 기준 재고(입고·반품·기타 출고 누적)\n"
. "· 입고: 입고·반품·기타 / 출고: 판매·일반·무료불출·반품·기타\n"
. "· 대행소 선택 시 판매 열만 해당 대행소 소속 판매소 기준\n"
. "조회 후 표·엑셀·인쇄에 반영됩니다.";
?>
<div class="flow-print-sheet">
<?= 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">
<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">
<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>
<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>
@@ -103,7 +129,6 @@ if ($queried) {
<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>
</form>
<p class="text-xs text-gray-500 mt-1">전일재고 = 조회 시작일 전날 기준 품목별 재고(입고·반품·기타 출고 누적). 대행소 선택 시 <strong>판매</strong>만 해당 대행소 소속 판매소 기준입니다.</p>
</section>
<?php if (! $queried): ?>
@@ -186,6 +211,26 @@ if ($queried) {
</div>
<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; }
@media screen {

View File

@@ -2,6 +2,12 @@
<h2 class="text-lg font-bold text-gray-700 mb-4">도움말</h2>
<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>
<h3 class="font-bold text-gray-700 mb-1">시스템 개요</h3>
<p>종량제 시스템은 지자체 종량제 쓰레기봉투의 발주, 입고, 재고, 판매, 불출 전체 물류 프로세스를 관리합니다.</p>
@@ -23,6 +29,12 @@
</table>
</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>
<h3 class="font-bold text-gray-700 mb-1">문의</h3>
<p>시스템 사용 문의사항은 소속 지자체 담당자에게 연락하시기 바랍니다.</p>

View File

@@ -173,6 +173,15 @@ $lowStock = [
<p class="text-[11px] text-gray-500 truncate">업무별 사용 방법 안내</p>
</div>
</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">
<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>

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>