사용자 매뉴얼·번호알기·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:
168
app/Views/bag/_dev_all_sales_panel.php
Normal file
168
app/Views/bag/_dev_all_sales_panel.php
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
@@ -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 !== ''): ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">사용자 매뉴얼 열기 →</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>
|
||||
|
||||
@@ -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
92
app/Views/bag/manual.php
Normal 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">← <?= 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']) ?> →</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
307
app/Views/bag/number_lookup.php
Normal file
307
app/Views/bag/number_lookup.php
Normal 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>
|
||||
Reference in New Issue
Block a user