Files
dbx-main/app/modules/dispatch/templates/dispatch/batches.html
T
king 718b5e6d8f feat(dispatch): 받는 사람 이름도 검색 + 검색창 컴팩트 드롭다운
- search_parcels: recipient_name ILIKE 추가(이름·주문·패키지·송장 4개 필드)
- batches.html: 큰 카드 → 한 줄 입력창(🔎+clear), 결과는 절대위치
  드롭다운으로 본문 안 밀리게. 바깥 클릭·Esc 닫기, 이름 매칭 강조

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:11:45 +09:00

368 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
/* 배치 목록 가독성: 표 글자 약간 키움 */
.dsp-batches.erp-table td { font-size:15px; }
.dsp-batches.erp-table th { font-size:13px; }
/* 플랫폼 로고: 아이콘마다 여백이 달라 시각 크기를 맞춘다 */
.dsp-pf-logo { height:26px;width:auto;max-width:120px;vertical-align:middle;object-fit:contain; }
.dsp-pf-logo[src*="shopee"] { height:34px; }
/* 좌(리스트) / 우(달력) 2단 */
.dsp-layout { display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap; }
.dsp-list { flex:1 1 620px;min-width:320px; }
.dsp-calwrap { flex:0 1 340px;min-width:280px; }
/* 달력 */
.dsp-cal-head { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px; }
.dsp-cal-grid { display:grid;grid-template-columns:repeat(7,1fr);gap:4px; }
.dsp-cal-wd { text-align:center;font-size:12px;color:#94a3b8;padding:2px 0;font-weight:600; }
.dsp-cal-day { text-align:center;padding:8px 0;border-radius:8px;font-size:14px;color:#cbd5e1; }
.dsp-cal-day.has { color:#0f172a;background:#eef2ff;font-weight:700;cursor:pointer;border:1px solid #c7d2fe; }
.dsp-cal-day.has:hover { background:#e0e7ff; }
.dsp-cal-day.sel { background:#16a34a !important;border-color:#16a34a !important;color:#fff !important; }
/* 검색: 한 줄 입력 + 결과는 절대위치 드롭다운(본문 안 밀림) */
.dsp-search { position:relative;margin:0 0 12px;max-width:520px; }
.dsp-search-box { position:relative; }
.dsp-search-box .dsp-q-ico { position:absolute;left:11px;top:50%;transform:translateY(-50%);font-size:14px;color:#94a3b8;pointer-events:none; }
.dsp-search-box input { width:100%;padding:9px 30px 9px 32px;border:1px solid #cbd5e1;border-radius:8px;font-size:14px;box-sizing:border-box; }
.dsp-search-box input:focus { outline:none;border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,.15); }
.dsp-q-clear { position:absolute;right:8px;top:50%;transform:translateY(-50%);border:0;background:transparent;color:#94a3b8;font-size:18px;cursor:pointer;line-height:1;padding:0 4px;display:none; }
.dsp-search-results { position:absolute;left:0;right:0;top:calc(100% + 4px);z-index:40;background:#fff;border:1px solid #e2e8f0;border-radius:10px;box-shadow:0 8px 24px rgba(15,23,42,.12);max-height:64vh;overflow:auto;display:none;padding:6px; }
.dsp-search-results.open { display:block; }
.dsp-hit { border-radius:8px;padding:9px 10px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start; }
.dsp-hit + .dsp-hit { border-top:1px solid #f1f5f9; }
.dsp-hit:hover { background:#f8fafc; }
.dsp-hit-name { font-weight:700;font-size:14px;color:#0f172a; }
.dsp-hit-meta { font-size:12.5px;color:#475569;margin-top:2px;line-height:1.5; }
.dsp-hit-meta code { background:#f1f5f9;padding:1px 5px;border-radius:4px;font-size:12px; }
.dsp-hit mark { background:#fde68a;padding:0 1px;border-radius:2px; }
.dsp-hit-batch { font-size:11.5px;color:#64748b;margin-top:3px; }
.dsp-hit-go { white-space:nowrap;flex:0 0 auto;align-self:center; }
.dsp-hit-empty { color:#94a3b8;font-size:13.5px;padding:10px; }
</style>
{% endblock %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="dsp-search" id="dsp-search">
<div class="dsp-search-box">
<span class="dsp-q-ico">🔎</span>
<input type="text" id="dsp-q" autocomplete="off"
data-ko-ph="이름 · 주문번호 · 패키지번호 · 송장번호로 받는 사람 찾기"
data-en-ph="Find recipient by name / order / package / tracking no."
placeholder="이름 · 주문번호 · 패키지번호 · 송장번호로 받는 사람 찾기" />
<button type="button" class="dsp-q-clear" id="dsp-q-clear" aria-label="clear">×</button>
</div>
<div class="dsp-search-results" id="dsp-search-results"></div>
</div>
<div class="dsp-layout">
<div class="erp-card dsp-list">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2><span data-ko="출고 배치" data-en="Batches">출고 배치</span> ({{ batches|length }})</h2>
<a class="erp-btn erp-btn-primary" href="/dispatch/batches/new" data-ko=" 새 업로드" data-en="+ New Upload"> 새 업로드</a>
</div>
<div class="erp-table-wrap">
<table class="erp-table dsp-batches">
<thead>
<tr>
<th data-ko="날짜" data-en="Date">날짜</th>
<th data-ko="플랫폼" data-en="Platform">플랫폼</th>
<th data-ko="배치명" data-en="Batch">배치명</th>
<th style="text-align:right" data-ko="박스" data-en="Boxes">박스</th>
<th style="text-align:right" data-ko="완료" data-en="Done">완료</th>
<th data-ko="생성시각" data-en="Created">생성시각</th><th></th>
</tr>
</thead>
<tbody>
{% for b in batches %}
<tr>
<td style="white-space:nowrap;">{{ b.dispatch_date }}</td>
<td>
{% set pf = (b.platform or '')|lower %}
{% if pf in ['tiktok', 'shopee'] %}
<img class="dsp-pf-logo" src="/static/dispatch/courier/{{ pf }}.png" alt="{{ b.platform }}" title="{{ b.platform }}" />
{% else %}{{ b.platform }}{% endif %}
</td>
<td><a href="/dispatch/batches/{{ b.id }}" style="font-weight:600;" class="dsp-bname"
data-raw="{{ b.batch_name or '(이름없음)' }}">{{ b.batch_name or '(이름없음)' }}</a></td>
<td style="text-align:right;">{{ b.parcel_count }}</td>
<td style="text-align:right;">
{% if b.parcel_count and b.done_count == b.parcel_count %}
<span style="color:#16a34a;font-weight:600;">{{ b.done_count }} ✓</span>
{% else %}{{ b.done_count }}{% endif %}
</td>
<td class="erp-muted" style="white-space:nowrap;">{{ (b.created_at[:19].replace('T',' ')) if b.created_at else '' }}</td>
<td style="text-align:right;white-space:nowrap;">
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}" data-ko="작업" data-en="Work">작업</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/picking" data-ko="피킹" data-en="Picking">피킹</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/handover" data-ko="전달" data-en="Handover">전달</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/download" data-ko="다운로드" data-en="Download">다운로드</a>
{% if is_super %}
<form method="post" action="/dispatch/batches/{{ b.id }}/delete" style="display:inline;"
onsubmit="return confirm('배치 「{{ b.batch_name or '(이름없음)' }}」 와 모든 박스/상품/로그를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?');">
<button type="submit" class="erp-btn erp-btn-outline" style="color:#dc2626;border-color:#fca5a5;" data-ko="삭제" data-en="Delete">삭제</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
{% if not batches %}
<tr><td colspan="7" class="erp-muted" data-ko="아직 출고 배치가 없습니다. 위 + 새 업로드로 시작하세요." data-en="No batches yet. Start with + New Upload above.">아직 출고 배치가 없습니다. 위 + 새 업로드로 시작하세요.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="erp-card dsp-calwrap">
<div class="cpg-card-head"><h3 style="margin:0;" data-ko="사방넷 출고 다운로드" data-en="Sabangnet Outbound Download">사방넷 출고 다운로드</h3></div>
<p class="erp-muted" style="margin:4px 0 10px;font-size:13px;"
data-ko="날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 출고 엑셀을 받습니다. 사방넷에 올리면 출고로 잡혀 재고가 차감됩니다."
data-en="Pick a date for the Sabangnet outbound Excel (combos split into singles). Upload it to Sabangnet to register the shipment and deduct stock.">날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 출고 엑셀을 받습니다. 사방넷에 올리면 출고로 잡혀 재고가 차감됩니다.</p>
<div class="dsp-cal" id="dsp-cal">
<div class="dsp-cal-head">
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-prev"></button>
<span id="dsp-cal-title" style="font-weight:700;"></span>
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-next"></button>
</div>
<div class="dsp-cal-grid" id="dsp-cal-grid"></div>
</div>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px;">
<div class="erp-muted" style="font-size:13px;">
<span data-ko="선택한 날짜" data-en="Selected">선택한 날짜</span>:
<b id="dsp-cal-sel"></b>
</div>
<a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#"
data-ko="📥 사방넷 출고 엑셀 다운로드" data-en="📥 Download Sabangnet Outbound Excel"
style="pointer-events:none;opacity:.5;">📥 사방넷 출고 엑셀 다운로드</a>
<button id="dsp-cal-deduct" type="button" class="erp-btn erp-btn-outline" onclick="dspDeduct()"
data-ko="📦 말레이시아 재고 차감" data-en="📦 Deduct Malaysia Stock"
style="pointer-events:none;opacity:.5;">📦 말레이시아 재고 차감</button>
<div id="dsp-cal-deduct-note" class="erp-muted" style="font-size:12px;"></div>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
// 배치가 있는 날짜(YYYY-MM-DD)
var BATCH_DATES = {{ batch_dates|tojson }};
var DEDUCTED = {};
({{ deducted_dates|tojson }}).forEach(function (d) { DEDUCTED[d] = true; });
var dateSet = {};
BATCH_DATES.forEach(function (d) { dateSet[d] = true; });
var grid = document.getElementById('dsp-cal-grid');
var title = document.getElementById('dsp-cal-title');
var selLabel = document.getElementById('dsp-cal-sel');
var dlBtn = document.getElementById('dsp-cal-dl');
var dedBtn = document.getElementById('dsp-cal-deduct');
var dedNote = document.getElementById('dsp-cal-deduct-note');
var selected = null;
// 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘)
var base = BATCH_DATES.length ? new Date(BATCH_DATES[BATCH_DATES.length - 1] + 'T00:00:00') : new Date();
var viewY = base.getFullYear(), viewM = base.getMonth();
var WD = { ko: ['일','월','화','수','목','금','토'], en: ['Su','Mo','Tu','We','Th','Fr','Sa'] };
function lang() { return (window.dspLang && window.dspLang() === 'en') ? 'en' : 'ko'; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function render() {
var l = lang();
title.textContent = viewY + '.' + pad(viewM + 1);
grid.innerHTML = '';
WD[l].forEach(function (w) {
var c = document.createElement('div');
c.className = 'dsp-cal-wd'; c.textContent = w; grid.appendChild(c);
});
var first = new Date(viewY, viewM, 1).getDay();
var days = new Date(viewY, viewM + 1, 0).getDate();
for (var i = 0; i < first; i++) grid.appendChild(document.createElement('div'));
for (var d = 1; d <= days; d++) {
var iso = viewY + '-' + pad(viewM + 1) + '-' + pad(d);
var cell = document.createElement('div');
cell.className = 'dsp-cal-day';
cell.textContent = d;
if (dateSet[iso]) {
cell.classList.add('has');
cell.setAttribute('data-iso', iso);
cell.onclick = function () { pick(this.getAttribute('data-iso')); };
}
if (iso === selected) cell.classList.add('sel');
grid.appendChild(cell);
}
}
function pick(iso) {
selected = iso;
selLabel.textContent = iso;
dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso);
dlBtn.style.pointerEvents = '';
dlBtn.style.opacity = '';
updateDeductBtn();
render();
}
function updateDeductBtn() {
var en = (window.dspLang && window.dspLang() === 'en');
if (!selected) {
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = '';
return;
}
if (DEDUCTED[selected]) {
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = en ? 'Already deducted (cannot run again).' : '이미 차감됨 (재실행 불가).';
} else {
dedBtn.style.pointerEvents = ''; dedBtn.style.opacity = '';
dedNote.textContent = '';
}
}
window.dspDeduct = function () {
if (!selected || DEDUCTED[selected]) return;
var en = (window.dspLang && window.dspLang() === 'en');
var msg = en
? ('Deduct Malaysia stock for ' + selected + '? This runs ONCE only and cannot be undone.')
: (selected + ' 출고를 말레이시아 재고에서 차감할까요?\n한 번만 실행되며 되돌릴 수 없습니다.');
if (!confirm(msg)) return;
dedBtn.style.pointerEvents = 'none'; dedBtn.style.opacity = '.5';
dedNote.textContent = en ? 'Processing…' : '처리 중…';
var body = new URLSearchParams(); body.set('date', selected);
fetch('/dispatch/stock-deduct', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok) throw new Error((res.j && res.j.detail) || '실패');
DEDUCTED[selected] = true;
updateDeductBtn();
alert((en ? 'Done. singles ' : '완료. 낱개 ') + res.j.singles + (en ? ', combos ' : '종, 콤보 ') + res.j.combos + (en ? '' : '종 반영'));
})
.catch(function (e) {
dedNote.textContent = (e && e.message) || '실패';
updateDeductBtn();
alert((en ? 'Failed: ' : '실패: ') + ((e && e.message) || ''));
});
};
document.getElementById('dsp-cal-prev').onclick = function () {
viewM--; if (viewM < 0) { viewM = 11; viewY--; } render();
};
document.getElementById('dsp-cal-next').onclick = function () {
viewM++; if (viewM > 11) { viewM = 0; viewY++; } render();
};
document.addEventListener('dsp:lang', function () { render(); updateDeductBtn(); });
render();
})();
// ── 받는 사람 찾기(이름/주문/패키지/송장 번호 부분 일치) ──
(function () {
var wrap = document.getElementById('dsp-search');
var input = document.getElementById('dsp-q');
var box = document.getElementById('dsp-search-results');
var clearBtn = document.getElementById('dsp-q-clear');
if (!input || !box) return;
var timer = null, lastQ = '';
function open() { box.classList.add('open'); }
function close() { box.classList.remove('open'); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 매칭된 부분만 강조(이미 escape 된 문자열에 대해 안전하게 처리).
function hi(value, q) {
var s = esc(value);
if (!q) return s;
var i = s.toLowerCase().indexOf(q.toLowerCase());
if (i < 0) return s;
return s.slice(0, i) + '<mark>' + s.slice(i, i + q.length) + '</mark>' + s.slice(i + q.length);
}
function en() { return (window.dspLang && window.dspLang() === 'en'); }
function row(r, q) {
var ids = [];
if (r.order_id) ids.push('Order <code>' + hi(r.order_id, q) + '</code>');
if (r.package_id) ids.push('Pkg <code>' + hi(r.package_id, q) + '</code>');
if (r.tracking_id) ids.push((en() ? 'Track ' : '송장 ') + '<code>' + hi(r.tracking_id, q) + '</code>');
var name = r.recipient_name
? hi(r.recipient_name, q)
: esc(en() ? '(no recipient name)' : '(받는 사람 정보 없음)');
var contact = [];
if (r.recipient_phone) contact.push(esc(r.recipient_phone));
if (r.recipient_address) contact.push(esc(r.recipient_address));
var done = r.label_attached && r.handed_to_kagayaku;
var batchLine = esc(r.dispatch_date) + ' · ' + esc(r.platform) + ' · ' + esc(r.batch_name || '')
+ ' · ' + (en() ? 'Box #' : '박스 #') + esc(r.seq)
+ (done ? ' · <span style="color:#16a34a;font-weight:600;">✓</span>' : '');
return '<div class="dsp-hit">'
+ '<div>'
+ '<div class="dsp-hit-name">' + name + '</div>'
+ (contact.length ? '<div class="dsp-hit-meta">' + contact.join(' · ') + '</div>' : '')
+ '<div class="dsp-hit-meta">' + ids.join(' &nbsp; ') + '</div>'
+ '<div class="dsp-hit-batch">' + batchLine + '</div>'
+ '</div>'
+ '<a class="erp-btn erp-btn-outline dsp-hit-go" href="/dispatch/batches/' + encodeURIComponent(r.batch_id) + '">'
+ (en() ? 'Open' : '작업 열기') + '</a>'
+ '</div>';
}
function run(q) {
fetch('/dispatch/api/search?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.query !== input.value.trim()) return; // 더 최신 입력이 있으면 무시
var rs = j.results || [];
if (!rs.length) {
box.innerHTML = '<div class="dsp-hit-empty">'
+ (en() ? 'No match.' : '일치하는 박스가 없습니다.') + '</div>';
} else {
box.innerHTML = rs.map(function (r) { return row(r, q); }).join('');
}
open();
})
.catch(function () {
box.innerHTML = '<div class="dsp-hit-empty">'
+ (en() ? 'Search failed.' : '검색 실패.') + '</div>';
open();
});
}
function reset() {
box.innerHTML = ''; close(); lastQ = '';
if (clearBtn) clearBtn.style.display = 'none';
}
input.addEventListener('input', function () {
var q = input.value.trim();
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
if (timer) clearTimeout(timer);
if (q.length < 2) { box.innerHTML = ''; close(); lastQ = ''; return; }
if (q === lastQ) { open(); return; }
lastQ = q;
timer = setTimeout(function () { run(q); }, 250);
});
// 결과가 있으면 포커스 시 다시 펼친다.
input.addEventListener('focus', function () { if (box.innerHTML) open(); });
if (clearBtn) clearBtn.addEventListener('click', function () { input.value = ''; reset(); input.focus(); });
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { close(); input.blur(); } });
// 바깥 클릭 시 닫기.
document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
})();
</script>
{% endblock %}