718b5e6d8f
- search_parcels: recipient_name ILIKE 추가(이름·주문·패키지·송장 4개 필드) - batches.html: 큰 카드 → 한 줄 입력창(🔎+clear), 결과는 절대위치 드롭다운으로 본문 안 밀리게. 바깥 클릭·Esc 닫기, 이름 매칭 강조 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
368 lines
19 KiB
HTML
368 lines
19 KiB
HTML
{% 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
// 매칭된 부분만 강조(이미 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(' ') + '</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 %}
|