Files
jongryangje/app/Views/bag/manual.php
taekyoungc acce968f9c feat: 전화접수 관리·주문 개선 + 매뉴얼 화면 바로가기
- 전화접수 관리: 배달일 기준 조회, 컬럼 보강(구분/포장 O·X/결제/수령/입금/총금액/취소),
  헤더 정렬, 배달일 수정 저장, 포장 명세·포장량 수정, 입금자 영수증 출력
- 전화 주문 접수: 행추가 → 일괄표(카테고리 그룹·포장 헤더)
- 매뉴얼 소제목에 '화면 열기' 바로가기(실제 GET 라우트만 연결)
- shop_order_item.soi_packed_qty 컬럼 추가(SQL)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:31:21 +09:00

249 lines
14 KiB
PHP

<?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;
$searchQ = (string) (service('request')->getGet('q') ?? '');
?>
<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: #1a2b4b; 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: #1a2b4b; color: #fff; font-weight: 700; }
/* 소제목 옆 "화면 열기" 바로가기 */
.manual-jump { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; vertical-align: middle; font-size: 0.72rem; font-weight: 700; color: #1d4ed8; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 9999px; padding: 2px 9px; text-decoration: none; cursor: pointer; white-space: nowrap; }
.manual-jump:hover { background: #1d4ed8; color: #fff; border-color: #1d4ed8; }
.manual-jump i { font-size: 0.65rem; }
/* "이 화면 설명"으로 들어왔을 때 해당 소제목 강조 */
.manual-prose h2.hl-flash, .manual-prose h3.hl-flash { background: #fef9c3; box-shadow: -6px 0 0 #fef9c3, 6px 0 0 #fef9c3; border-radius: 4px; animation: hlFlash 2.6s ease-out 1; }
@keyframes hlFlash { 0%, 45% { background: #fde047; box-shadow: -6px 0 0 #fde047, 6px 0 0 #fde047; } 100% { background: transparent; box-shadow: -6px 0 0 transparent, 6px 0 0 transparent; } }
@media print {
.manual-toc, .manual-actions, .manual-nav, .manual-jump { 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>
<!-- 매뉴얼 전체 검색 -->
<div class="manual-search" style="position:relative;padding:6px;border:1px solid #d1d5db;border-top:0;background:#fff;">
<div style="position:relative;">
<input id="manualSearchInput" type="search" autocomplete="off" placeholder="매뉴얼 검색 (예: LOT, 불출)"
value="<?= esc($searchQ, 'attr') ?>"
class="w-full border border-gray-300 rounded px-2 py-1.5 pr-7 text-xs focus:outline-none focus:ring-2 focus:ring-[#243a5e]/30"/>
<i class="fa-solid fa-magnifying-glass" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:11px;"></i>
</div>
<ul id="manualSearchResults" class="hidden" style="position:absolute;left:6px;right:6px;top:100%;z-index:40;max-height:300px;overflow-y:auto;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.12);margin:2px 0 0;padding:0;list-style:none;"></ul>
</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>
<script>
(function () {
// 현재 출처 기준 경로(앱 baseURL 호스트와 접속 호스트가 달라도 동일 출처로 요청)
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
// 이 매뉴얼 페이지가 설명하는 화면 바로가기 목록 [{hint, url}, ...]
var JUMPS = <?= json_encode($jumps ?? [], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
// "이 화면 설명"으로 들어온 경우: 해당 소제목으로 스크롤 + 강조.
// 드로어를 껐다 다시 켜도(같은 URL이라 iframe 재로드 안 됨) 부모가 보낸 메시지로 다시 강조한다.
var hlFlashTimer = null;
function runHl() {
var hl = '';
try { hl = new URLSearchParams(location.search).get('hl') || ''; } catch (e) {}
hl = hl.trim();
if (!hl) return;
var norm = function (s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); };
var needle = norm(hl);
var prose = document.querySelector('.manual-prose');
if (!prose) return;
var heads = prose.querySelectorAll('h2, h3'), target = null;
for (var i = 0; i < heads.length; i++) {
if (norm(heads[i].textContent).indexOf(needle) >= 0) { target = heads[i]; break; }
}
if (!target) return;
clearTimeout(hlFlashTimer);
target.classList.remove('hl-flash');
void target.offsetWidth; // 리플로우 → 애니메이션 재시작
target.classList.add('hl-flash');
try { target.scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (e) { target.scrollIntoView(); }
hlFlashTimer = setTimeout(function () { target.classList.remove('hl-flash'); }, 2700);
}
runHl();
window.addEventListener('message', function (e) {
if (e.origin !== location.origin) return;
if (e.data && e.data.type === 'manual-hl') runHl();
});
// 해당 화면으로 이동: 워크스페이스(탭) 안이면 새 탭으로, 아니면 최상위 창을 이동.
function openScreen(url, title) {
try { if (window.top && typeof window.top.wsOpenTab === 'function') { window.top.wsOpenTab(url, title); return; } } catch (e) {}
try { if (window.parent && typeof window.parent.wsOpenTab === 'function') { window.parent.wsOpenTab(url, title); return; } } catch (e) {}
try { window.top.location.href = url; } catch (e) { window.location.href = url; }
}
// 각 소제목(h2/h3) 옆에 "화면 열기" 바로가기 버튼을 붙인다(hint 매칭).
(function attachJumps() {
if (!JUMPS || !JUMPS.length) return;
var prose = document.querySelector('.manual-prose');
if (!prose) return;
var norm = function (s) { return String(s || '').replace(/\s+/g, ' ').trim().toLowerCase(); };
var heads = prose.querySelectorAll('h2, h3');
JUMPS.forEach(function (j) {
var needle = norm(j.hint);
if (!needle || !j.url) return;
for (var i = 0; i < heads.length; i++) {
var h = heads[i];
if (h.getAttribute('data-jump')) continue; // 한 제목엔 하나만
if (norm(h.textContent).indexOf(needle) < 0) continue;
var label = (h.textContent || '화면').trim(); // 버튼 추가 전 제목 확보
var url = j.url;
var a = document.createElement('a');
a.className = 'manual-jump';
a.href = url;
a.title = '이 화면으로 이동';
a.innerHTML = '<i class="fa-solid fa-arrow-up-right-from-square"></i> 화면 열기';
a.addEventListener('click', function (ev) { ev.preventDefault(); openScreen(url, label); });
h.appendChild(a);
h.setAttribute('data-jump', '1');
break;
}
});
})();
var input = document.getElementById('manualSearchInput');
var box = document.getElementById('manualSearchResults');
function esc(s) { return String(s || '').replace(/[&<>]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
function doSearch(q) {
q = (q || '').trim();
if (!q) { box.classList.add('hidden'); box.innerHTML = ''; return; }
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
var res = (d && d.results) || [];
if (!res.length) {
box.innerHTML = '<li style="padding:7px 10px;color:#9ca3af;font-size:12px;">검색 결과가 없습니다.</li>';
box.classList.remove('hidden'); return;
}
box.innerHTML = res.map(function (m) {
return '<li><a href="' + BASE + encodeURIComponent(m.slug) + '?q=' + encodeURIComponent(q) +
'" style="display:block;padding:7px 10px;border-bottom:1px solid #f1f5f9;text-decoration:none;">' +
'<span style="font-size:12px;font-weight:700;color:#1a2b4b;">' + esc(m.title) +
' <span style="color:#94a3b8;font-weight:400;">(' + (m.hits || 1) + ')</span></span>' +
'<span style="display:block;font-size:11px;color:#64748b;margin-top:2px;line-height:1.4;">' + esc(m.snippet) + '</span></a></li>';
}).join('');
box.classList.remove('hidden');
})
.catch(function () {});
}
if (input) {
var timer = null;
input.addEventListener('input', function () { clearTimeout(timer); var v = input.value; timer = setTimeout(function () { doSearch(v); }, 250); });
input.addEventListener('focus', function () { if (input.value.trim()) doSearch(input.value); });
document.addEventListener('click', function (e) { if (!e.target.closest('.manual-search')) box.classList.add('hidden'); });
}
// 현재 페이지 본문에서 검색어 하이라이트
if (Q0 && Q0.trim()) {
var prose = document.querySelector('.manual-prose');
if (prose) {
var needle = Q0.trim().toLowerCase();
var walker = document.createTreeWalker(prose, NodeFilter.SHOW_TEXT, null);
var nodes = [], first = null;
while (walker.nextNode()) { nodes.push(walker.currentNode); }
nodes.forEach(function (node) {
var t = node.nodeValue, lt = t.toLowerCase(), idx = lt.indexOf(needle);
if (idx < 0) return;
var frag = document.createDocumentFragment(), last = 0;
while (idx >= 0) {
frag.appendChild(document.createTextNode(t.slice(last, idx)));
var mark = document.createElement('mark');
mark.textContent = t.slice(idx, idx + needle.length);
mark.style.background = '#fde68a';
frag.appendChild(mark);
if (!first) first = mark;
last = idx + needle.length;
idx = lt.indexOf(needle, last);
}
frag.appendChild(document.createTextNode(t.slice(last)));
node.parentNode.replaceChild(frag, node);
});
if (first) { try { first.scrollIntoView({ block: 'center' }); } catch (e) {} }
}
}
})();
</script>