|
|
|
@@ -1,89 +1,179 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { Check, Download, X, Eye } from "lucide-react";
|
|
|
|
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
|
|
|
|
import { Check, Download, X, RefreshCcw, Truck, AlertCircle, Package } from "lucide-react";
|
|
|
|
|
import Swal from "sweetalert2";
|
|
|
|
|
|
|
|
|
|
interface Order {
|
|
|
|
|
OBJID: string; ORDER_NO: string; ORDER_DATE: string;
|
|
|
|
|
COMPANY_NAME: string; EMAIL: string; STATUS: string;
|
|
|
|
|
TOTAL_TAXFREE: number; TOTAL_TAXABLE: number; TOTAL_AMOUNT: number;
|
|
|
|
|
TOTAL_TAXFREE: number; TOTAL_TAXABLE: number;
|
|
|
|
|
TOTAL_SUPPLY: number; TOTAL_VAT: number; TOTAL_AMOUNT: number;
|
|
|
|
|
}
|
|
|
|
|
interface DetailOrder extends Order {
|
|
|
|
|
CEO_NAME?: string; BIZ_NO?: string; PHONE?: string; ADDRESS?: string;
|
|
|
|
|
MEMO?: string; APPROVE_DATE?: string;
|
|
|
|
|
}
|
|
|
|
|
interface DetailLine {
|
|
|
|
|
SEQ: number; ITEM_NAME: string; UNIT_PRICE: number; QTY: number;
|
|
|
|
|
SEQ: number; ITEM_NAME: string; UNIT: string; UNIT_PRICE: number; QTY: number;
|
|
|
|
|
IS_TAX_FREE: string; SUPPLY_AMOUNT: number; VAT_AMOUNT: number; TOTAL_AMOUNT: number;
|
|
|
|
|
STOCK_QTY: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fmt = (n: number) => Number(n || 0).toLocaleString("ko-KR");
|
|
|
|
|
const fmt = (n: number | string | undefined | null) =>
|
|
|
|
|
Number(n || 0).toLocaleString("ko-KR");
|
|
|
|
|
|
|
|
|
|
const STATUS_LABEL: Record<string, string> = {
|
|
|
|
|
REQUESTED: "출고요청", APPROVED: "출고완료", SHIPPED: "출고완료",
|
|
|
|
|
PAID: "입금완료", INVOICED: "계산서발행", CANCELLED: "취소",
|
|
|
|
|
REQUESTED: "출고요청",
|
|
|
|
|
APPROVED: "출고완료",
|
|
|
|
|
SHIPPED: "출고완료",
|
|
|
|
|
PAID: "입금완료",
|
|
|
|
|
INVOICED: "계산서발행",
|
|
|
|
|
CANCELLED: "취소",
|
|
|
|
|
};
|
|
|
|
|
const STATUS_COLOR: Record<string, string> = {
|
|
|
|
|
REQUESTED: "bg-amber-100 text-amber-700",
|
|
|
|
|
APPROVED: "bg-blue-100 text-blue-700",
|
|
|
|
|
SHIPPED: "bg-cyan-100 text-cyan-700",
|
|
|
|
|
INVOICED: "bg-violet-100 text-violet-700",
|
|
|
|
|
PAID: "bg-emerald-100 text-emerald-700",
|
|
|
|
|
CANCELLED: "bg-slate-100 text-slate-500",
|
|
|
|
|
REQUESTED: "bg-amber-100 text-amber-700 border-amber-200",
|
|
|
|
|
APPROVED: "bg-blue-100 text-blue-700 border-blue-200",
|
|
|
|
|
SHIPPED: "bg-cyan-100 text-cyan-700 border-cyan-200",
|
|
|
|
|
PAID: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
|
|
|
|
INVOICED: "bg-violet-100 text-violet-700 border-violet-200",
|
|
|
|
|
CANCELLED: "bg-slate-100 text-slate-500 border-slate-200",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function AdminOrdersPage() {
|
|
|
|
|
const [orders, setOrders] = useState<Order[]>([]);
|
|
|
|
|
const [status, setStatus] = useState("");
|
|
|
|
|
const [detail, setDetail] = useState<{ order: Order; items: DetailLine[] } | null>(null);
|
|
|
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
|
const [activeId, setActiveId] = useState<string>("");
|
|
|
|
|
const [detail, setDetail] = useState<{ order: DetailOrder; items: DetailLine[] } | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
const res = await fetch("/api/m/orders/list", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ status: status || undefined }),
|
|
|
|
|
});
|
|
|
|
|
setOrders((await res.json()).RESULTLIST ?? []);
|
|
|
|
|
};
|
|
|
|
|
useEffect(() => { load(); }, []); // eslint-disable-line
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/m/orders/list", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ status: status || undefined }),
|
|
|
|
|
});
|
|
|
|
|
const j = await res.json();
|
|
|
|
|
const list: Order[] = j.RESULTLIST ?? [];
|
|
|
|
|
setOrders(list);
|
|
|
|
|
setSelected((prev) => new Set(Array.from(prev).filter((id) => list.some((o) => o.OBJID === id))));
|
|
|
|
|
if (list.length && !list.some((o) => o.OBJID === activeId)) {
|
|
|
|
|
setActiveId(list[0].OBJID);
|
|
|
|
|
} else if (!list.length) {
|
|
|
|
|
setActiveId("");
|
|
|
|
|
setDetail(null);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [status, activeId]);
|
|
|
|
|
|
|
|
|
|
const view = async (o: Order) => {
|
|
|
|
|
const res = await fetch("/api/m/orders/detail", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ objid: o.OBJID }),
|
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
|
|
|
|
|
|
// 활성 행 변경 시 상세 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!activeId) { setDetail(null); return; }
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
(async () => {
|
|
|
|
|
const res = await fetch("/api/m/orders/detail", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ objid: activeId }),
|
|
|
|
|
});
|
|
|
|
|
const j = await res.json();
|
|
|
|
|
if (!cancelled && j.success) {
|
|
|
|
|
setDetail({ order: j.order as DetailOrder, items: j.items as DetailLine[] });
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
}, [activeId]);
|
|
|
|
|
|
|
|
|
|
const requestedSelectedIds = useMemo(
|
|
|
|
|
() => Array.from(selected).filter((id) => orders.find((o) => o.OBJID === id)?.STATUS === "REQUESTED"),
|
|
|
|
|
[selected, orders]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const toggleOne = (o: Order) => {
|
|
|
|
|
if (o.STATUS !== "REQUESTED") return;
|
|
|
|
|
setSelected((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(o.OBJID)) next.delete(o.OBJID); else next.add(o.OBJID);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
const toggleAllRequested = () => {
|
|
|
|
|
const reqIds = orders.filter((o) => o.STATUS === "REQUESTED").map((o) => o.OBJID);
|
|
|
|
|
setSelected((prev) => {
|
|
|
|
|
const allOn = reqIds.length > 0 && reqIds.every((id) => prev.has(id));
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (allOn) reqIds.forEach((id) => next.delete(id));
|
|
|
|
|
else reqIds.forEach((id) => next.add(id));
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
const j = await res.json();
|
|
|
|
|
if (j.success) setDetail({ order: o, items: j.items });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const approve = async (o: Order) => {
|
|
|
|
|
const bulkShip = async () => {
|
|
|
|
|
const ids = requestedSelectedIds;
|
|
|
|
|
if (ids.length === 0) {
|
|
|
|
|
Swal.fire({ icon: "warning", title: "출고 처리할 발주를 선택하세요.", text: "체크박스로 출고요청 상태의 발주를 선택해주세요." });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const ok = await Swal.fire({
|
|
|
|
|
icon: "question",
|
|
|
|
|
title: "발주를 승인하시겠습니까?",
|
|
|
|
|
html: `<b>${o.COMPANY_NAME}</b><br>합계 ₩${fmt(o.TOTAL_AMOUNT)}<br><br>승인 시 재고가 차감되고<br><b>${o.EMAIL}</b>로 거래명세표 메일이 발송됩니다.`,
|
|
|
|
|
showCancelButton: true, confirmButtonText: "승인", cancelButtonText: "취소",
|
|
|
|
|
title: `${ids.length}건 출고 처리하시겠습니까?`,
|
|
|
|
|
html: `선택된 발주의 재고가 차감되고,<br>각 거래처 이메일로 거래명세표가 발송됩니다.`,
|
|
|
|
|
showCancelButton: true,
|
|
|
|
|
confirmButtonText: "출고",
|
|
|
|
|
cancelButtonText: "취소",
|
|
|
|
|
confirmButtonColor: "#0f766e",
|
|
|
|
|
});
|
|
|
|
|
if (!ok.isConfirmed) return;
|
|
|
|
|
|
|
|
|
|
setBusy(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/m/orders/approve", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ objid: o.OBJID }),
|
|
|
|
|
});
|
|
|
|
|
const j = await res.json();
|
|
|
|
|
if (j.success) {
|
|
|
|
|
Swal.fire({
|
|
|
|
|
icon: j.mailSent ? "success" : "warning",
|
|
|
|
|
title: "승인 완료",
|
|
|
|
|
text: j.mailSent ? "거래명세표 메일이 발송되었습니다." : `메일 발송 실패: ${j.mailError ?? "SMTP 미설정"}`,
|
|
|
|
|
let ok_cnt = 0;
|
|
|
|
|
let fail_cnt = 0;
|
|
|
|
|
let mailFail = 0;
|
|
|
|
|
const errors: string[] = [];
|
|
|
|
|
for (const objid of ids) {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/api/m/orders/approve", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ objid }),
|
|
|
|
|
});
|
|
|
|
|
load();
|
|
|
|
|
setDetail(null);
|
|
|
|
|
} else {
|
|
|
|
|
Swal.fire({ icon: "error", title: "승인 실패", text: j.message });
|
|
|
|
|
const j = await res.json();
|
|
|
|
|
if (j.success) {
|
|
|
|
|
ok_cnt++;
|
|
|
|
|
if (!j.mailSent) mailFail++;
|
|
|
|
|
} else {
|
|
|
|
|
fail_cnt++;
|
|
|
|
|
const o = orders.find((x) => x.OBJID === objid);
|
|
|
|
|
errors.push(`${o?.ORDER_NO ?? objid}: ${j.message ?? "실패"}`);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
fail_cnt++;
|
|
|
|
|
}
|
|
|
|
|
} finally { setBusy(false); }
|
|
|
|
|
}
|
|
|
|
|
setBusy(false);
|
|
|
|
|
setSelected(new Set());
|
|
|
|
|
await load();
|
|
|
|
|
|
|
|
|
|
Swal.fire({
|
|
|
|
|
icon: fail_cnt === 0 ? "success" : "warning",
|
|
|
|
|
title: `출고 처리 완료 (성공 ${ok_cnt} / 실패 ${fail_cnt})`,
|
|
|
|
|
html: [
|
|
|
|
|
ok_cnt > 0 ? `· 거래명세표 메일 ${ok_cnt - mailFail}건 발송${mailFail > 0 ? ` <span style="color:#dc2626">(${mailFail}건 메일 실패)</span>` : ""}` : "",
|
|
|
|
|
errors.length > 0 ? `<br><br><b>실패 내역:</b><br>${errors.join("<br>")}` : "",
|
|
|
|
|
].filter(Boolean).join(""),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cancel = async (o: Order) => {
|
|
|
|
|
const ok = await Swal.fire({ icon: "warning", title: "발주를 취소하시겠습니까?", showCancelButton: true, confirmButtonColor: "#dc2626" });
|
|
|
|
|
const cancelOne = async (o: Order) => {
|
|
|
|
|
const ok = await Swal.fire({
|
|
|
|
|
icon: "warning", title: "발주를 취소(반려)하시겠습니까?",
|
|
|
|
|
text: o.ORDER_NO, showCancelButton: true, confirmButtonColor: "#dc2626",
|
|
|
|
|
});
|
|
|
|
|
if (!ok.isConfirmed) return;
|
|
|
|
|
const res = await fetch("/api/m/orders/cancel", {
|
|
|
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
|
|
@@ -92,135 +182,256 @@ export default function AdminOrdersPage() {
|
|
|
|
|
if ((await res.json()).success) load();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const allRequestedCount = orders.filter((o) => o.STATUS === "REQUESTED").length;
|
|
|
|
|
const allRequestedChecked = allRequestedCount > 0 && allRequestedCount === requestedSelectedIds.length;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold">발주서 관리</h1>
|
|
|
|
|
<p className="text-sm text-slate-500 mt-1">대리점에서 들어온 발주를 검토·승인하세요. 승인 시 재고 차감과 메일 발송이 자동 처리됩니다.</p>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="flex items-end justify-between flex-wrap gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-xl font-bold text-slate-900">발주서 관리 · 출고처리</h1>
|
|
|
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
|
|
|
왼쪽에서 발주를 선택하면 오른쪽에 거래명세표가 미리보기로 표시됩니다. 체크박스로 다중 선택 후 [출고]를 누르면 일괄 처리됩니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2 items-center">
|
|
|
|
|
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-9 px-3 rounded-lg border border-slate-200 text-sm bg-white">
|
|
|
|
|
<option value="">전체 상태</option>
|
|
|
|
|
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<button onClick={load} disabled={loading} className="h-9 px-3 rounded-lg border border-slate-200 bg-white text-slate-700 text-sm font-semibold inline-flex items-center gap-1.5 hover:bg-slate-50 disabled:opacity-50">
|
|
|
|
|
<RefreshCcw size={14} className={loading ? "animate-spin" : ""} /> 조회
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={bulkShip}
|
|
|
|
|
disabled={busy || requestedSelectedIds.length === 0}
|
|
|
|
|
className="h-9 px-4 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center gap-1.5 shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<Truck size={14} /> 출고{requestedSelectedIds.length > 0 ? ` (${requestedSelectedIds.length})` : ""}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<select value={status} onChange={(e) => setStatus(e.target.value)} className="h-10 px-3 rounded-lg border border-slate-200 text-sm">
|
|
|
|
|
<option value="">전체 상태</option>
|
|
|
|
|
{Object.entries(STATUS_LABEL).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<button onClick={load} className="h-10 px-4 rounded-lg bg-slate-800 text-white text-sm font-semibold">조회</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead className="bg-slate-50 text-slate-600">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="text-left px-3 py-3">발주번호</th>
|
|
|
|
|
<th className="text-left px-3 py-3">발주일</th>
|
|
|
|
|
<th className="text-left px-3 py-3">업체</th>
|
|
|
|
|
<th className="text-right px-3 py-3">면세</th>
|
|
|
|
|
<th className="text-right px-3 py-3">과세</th>
|
|
|
|
|
<th className="text-right px-3 py-3">합계</th>
|
|
|
|
|
<th className="text-center px-3 py-3">상태</th>
|
|
|
|
|
<th className="text-right px-3 py-3">작업</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{orders.length === 0 ? (
|
|
|
|
|
<tr><td colSpan={8} className="text-center py-12 text-slate-400">발주가 없습니다.</td></tr>
|
|
|
|
|
) : orders.map((o) => (
|
|
|
|
|
<tr key={o.OBJID} className="border-t border-slate-100">
|
|
|
|
|
<td className="px-3 py-3 font-semibold">{o.ORDER_NO}</td>
|
|
|
|
|
<td className="px-3 py-3">{o.ORDER_DATE}</td>
|
|
|
|
|
<td className="px-3 py-3">{o.COMPANY_NAME}</td>
|
|
|
|
|
<td className="px-3 py-3 text-right tabular-nums text-violet-700">{fmt(o.TOTAL_TAXFREE)}</td>
|
|
|
|
|
<td className="px-3 py-3 text-right tabular-nums text-rose-700">{fmt(o.TOTAL_TAXABLE)}</td>
|
|
|
|
|
<td className="px-3 py-3 text-right tabular-nums font-bold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
|
|
|
|
<td className="px-3 py-3 text-center">
|
|
|
|
|
<span className={`px-2 py-1 rounded text-xs font-semibold ${STATUS_COLOR[o.STATUS]}`}>
|
|
|
|
|
{STATUS_LABEL[o.STATUS]}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-3 text-right">
|
|
|
|
|
<button onClick={() => view(o)} className="px-2.5 h-8 rounded-md text-xs text-slate-700 hover:bg-slate-100 inline-flex items-center gap-1">
|
|
|
|
|
<Eye size={12} /> 상세
|
|
|
|
|
</button>
|
|
|
|
|
{o.STATUS === "REQUESTED" && (
|
|
|
|
|
<>
|
|
|
|
|
<button disabled={busy} onClick={() => approve(o)} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-emerald-700 text-white hover:bg-emerald-800 inline-flex items-center gap-1 disabled:opacity-50">
|
|
|
|
|
<Check size={12} /> 승인
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => cancel(o)} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-rose-50 text-rose-700 hover:bg-rose-100 inline-flex items-center gap-1">
|
|
|
|
|
<X size={12} /> 반려
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{(o.STATUS === "APPROVED" || o.STATUS === "SHIPPED" || o.STATUS === "INVOICED" || o.STATUS === "PAID") && (
|
|
|
|
|
<a href={`/api/m/orders/statement/${o.OBJID}`} className="ml-1 px-2.5 h-8 rounded-md text-xs bg-slate-50 text-slate-700 hover:bg-slate-100 inline-flex items-center gap-1">
|
|
|
|
|
<Download size={12} /> 명세서
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 상세 모달 */}
|
|
|
|
|
{detail && (
|
|
|
|
|
<div className="fixed inset-0 bg-slate-900/60 z-50 flex items-center justify-center p-4" onClick={() => setDetail(null)}>
|
|
|
|
|
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl max-w-3xl w-full p-6 max-h-[90vh] overflow-y-auto">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="font-bold text-lg">발주 상세 — {detail.order.ORDER_NO}</h3>
|
|
|
|
|
<button onClick={() => setDetail(null)} className="text-slate-400 hover:text-slate-700"><X size={20} /></button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
|
|
|
|
|
<Info label="업체명" value={detail.order.COMPANY_NAME} />
|
|
|
|
|
<Info label="이메일" value={detail.order.EMAIL} />
|
|
|
|
|
<Info label="발주일" value={detail.order.ORDER_DATE} />
|
|
|
|
|
<Info label="상태" value={STATUS_LABEL[detail.order.STATUS]} />
|
|
|
|
|
</div>
|
|
|
|
|
<table className="w-full text-sm border border-slate-200">
|
|
|
|
|
<thead className="bg-slate-50">
|
|
|
|
|
{/* 2분할 레이아웃 */}
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" style={{ minHeight: "calc(100vh - 200px)" }}>
|
|
|
|
|
{/* 좌측: 발주 리스트 */}
|
|
|
|
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
|
|
|
|
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
|
|
|
|
<span>발주 리스트 ({orders.length}건)</span>
|
|
|
|
|
<span className="text-slate-400 font-normal">선택 {selected.size}건 / 출고가능 {allRequestedCount}건</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
<thead className="bg-slate-50 text-slate-600 sticky top-0 z-10">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="text-left px-3 py-2 text-xs">품명</th>
|
|
|
|
|
<th className="text-center px-2 py-2 text-xs w-16">구분</th>
|
|
|
|
|
<th className="text-right px-2 py-2 text-xs w-16">수량</th>
|
|
|
|
|
<th className="text-right px-2 py-2 text-xs">단가</th>
|
|
|
|
|
<th className="text-right px-2 py-2 text-xs">공급가</th>
|
|
|
|
|
<th className="text-right px-2 py-2 text-xs">세액</th>
|
|
|
|
|
<th className="text-right px-2 py-2 text-xs">합계</th>
|
|
|
|
|
<th className="w-8 px-2 py-2">
|
|
|
|
|
<input type="checkbox" checked={allRequestedChecked} onChange={toggleAllRequested}
|
|
|
|
|
disabled={allRequestedCount === 0}
|
|
|
|
|
className="accent-emerald-600 cursor-pointer disabled:opacity-30" />
|
|
|
|
|
</th>
|
|
|
|
|
<th className="text-left px-2 py-2">발주번호</th>
|
|
|
|
|
<th className="text-left px-2 py-2">발주일</th>
|
|
|
|
|
<th className="text-left px-2 py-2">업체</th>
|
|
|
|
|
<th className="text-right px-2 py-2">합계</th>
|
|
|
|
|
<th className="text-center px-2 py-2">상태</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{detail.items.map((it) => (
|
|
|
|
|
<tr key={it.SEQ} className="border-t border-slate-100">
|
|
|
|
|
<td className="px-3 py-2">{it.ITEM_NAME}</td>
|
|
|
|
|
<td className="text-center px-2 py-2">{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}</td>
|
|
|
|
|
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.QTY)}</td>
|
|
|
|
|
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.UNIT_PRICE)}</td>
|
|
|
|
|
<td className="text-right px-2 py-2 tabular-nums">{fmt(it.SUPPLY_AMOUNT)}</td>
|
|
|
|
|
<td className="text-right px-2 py-2 tabular-nums">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
|
|
|
|
|
<td className="text-right px-2 py-2 tabular-nums font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
{orders.length === 0 ? (
|
|
|
|
|
<tr><td colSpan={6} className="text-center py-12 text-slate-400">{loading ? "조회 중..." : "발주가 없습니다."}</td></tr>
|
|
|
|
|
) : orders.map((o) => {
|
|
|
|
|
const checked = selected.has(o.OBJID);
|
|
|
|
|
const active = o.OBJID === activeId;
|
|
|
|
|
return (
|
|
|
|
|
<tr
|
|
|
|
|
key={o.OBJID}
|
|
|
|
|
onClick={() => setActiveId(o.OBJID)}
|
|
|
|
|
className={`border-t border-slate-100 cursor-pointer ${active ? "bg-emerald-50/60" : "hover:bg-slate-50"}`}
|
|
|
|
|
>
|
|
|
|
|
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={checked}
|
|
|
|
|
onChange={() => toggleOne(o)}
|
|
|
|
|
disabled={o.STATUS !== "REQUESTED"}
|
|
|
|
|
className="accent-emerald-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
|
|
|
|
title={o.STATUS !== "REQUESTED" ? "출고요청 상태만 선택할 수 있습니다." : ""}
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-2 py-2 font-semibold text-slate-800">{o.ORDER_NO}</td>
|
|
|
|
|
<td className="px-2 py-2 text-slate-600">{o.ORDER_DATE}</td>
|
|
|
|
|
<td className="px-2 py-2 truncate max-w-[140px]" title={o.COMPANY_NAME}>{o.COMPANY_NAME}</td>
|
|
|
|
|
<td className="px-2 py-2 text-right tabular-nums font-semibold">₩{fmt(o.TOTAL_AMOUNT)}</td>
|
|
|
|
|
<td className="px-2 py-2 text-center">
|
|
|
|
|
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold border ${STATUS_COLOR[o.STATUS] ?? "bg-slate-100 text-slate-500 border-slate-200"}`}>
|
|
|
|
|
{STATUS_LABEL[o.STATUS] ?? o.STATUS}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</tbody>
|
|
|
|
|
<tfoot className="bg-slate-50">
|
|
|
|
|
<tr><td colSpan={6} className="px-3 py-2 text-right font-bold">총 합계 (VAT포함)</td><td className="px-2 py-2 text-right font-bold tabular-nums text-emerald-700">₩{fmt(detail.order.TOTAL_AMOUNT)}</td></tr>
|
|
|
|
|
</tfoot>
|
|
|
|
|
</table>
|
|
|
|
|
{detail.order.STATUS === "REQUESTED" && (
|
|
|
|
|
<div className="flex gap-2 justify-end mt-5 pt-4 border-t border-slate-100">
|
|
|
|
|
<button onClick={() => cancel(detail.order)} className="px-4 h-10 rounded-lg border border-rose-200 text-rose-700 text-sm font-semibold hover:bg-rose-50">반려</button>
|
|
|
|
|
<button disabled={busy} onClick={() => approve(detail.order)} className="px-5 h-10 rounded-lg bg-emerald-700 text-white text-sm font-bold hover:bg-emerald-800 disabled:opacity-50">
|
|
|
|
|
승인 + 메일 발송
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 우측: 거래명세표 미리보기 */}
|
|
|
|
|
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden flex flex-col">
|
|
|
|
|
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200 text-xs font-semibold text-slate-600 flex items-center justify-between">
|
|
|
|
|
<span>거래명세표 미리보기</span>
|
|
|
|
|
{detail && (
|
|
|
|
|
<a
|
|
|
|
|
href={`/api/m/orders/statement/${detail.order.OBJID}`}
|
|
|
|
|
className="text-xs text-emerald-700 font-semibold inline-flex items-center gap-1 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
<Download size={12} /> 엑셀 다운로드
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 overflow-auto p-4">
|
|
|
|
|
{!detail ? (
|
|
|
|
|
<div className="h-full flex flex-col items-center justify-center text-slate-400">
|
|
|
|
|
<Package size={48} className="mb-3 opacity-50" />
|
|
|
|
|
<div className="text-sm">왼쪽에서 발주를 선택하세요.</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<StatementPreview order={detail.order} items={detail.items} onCancel={cancelOne} busy={busy} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatementPreview({
|
|
|
|
|
order,
|
|
|
|
|
items,
|
|
|
|
|
onCancel,
|
|
|
|
|
busy,
|
|
|
|
|
}: {
|
|
|
|
|
order: DetailOrder;
|
|
|
|
|
items: DetailLine[];
|
|
|
|
|
onCancel: (o: Order) => void;
|
|
|
|
|
busy: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
const lowStock = items.filter((it) => Number(it.STOCK_QTY) < Number(it.QTY));
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-[12px] text-slate-800 space-y-3">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<h2 className="text-xl font-bold tracking-[0.3em] text-slate-900">거 래 명 세 표</h2>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-[11px]">
|
|
|
|
|
<div>
|
|
|
|
|
<div><b>발주번호</b> · {order.ORDER_NO}</div>
|
|
|
|
|
<div><b>발주일자</b> · {order.ORDER_DATE}</div>
|
|
|
|
|
<div><b>현재상태</b> · <span className="font-semibold">{STATUS_LABEL[order.STATUS] ?? order.STATUS}</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-right">
|
|
|
|
|
<div><b>공급자</b> · 모모유통</div>
|
|
|
|
|
<div className="text-slate-500">대표: 한신숙</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="border border-slate-200 rounded p-2 bg-slate-50/60">
|
|
|
|
|
<div className="font-semibold text-slate-900">{order.COMPANY_NAME} <span className="text-slate-500 font-normal">귀하</span></div>
|
|
|
|
|
<div className="text-[11px] text-slate-600 mt-0.5 leading-relaxed">
|
|
|
|
|
{order.CEO_NAME && <>대표: {order.CEO_NAME} · </>}
|
|
|
|
|
{order.BIZ_NO && <>사업자번호: {order.BIZ_NO} · </>}
|
|
|
|
|
{order.PHONE && <>전화: {order.PHONE} · </>}
|
|
|
|
|
{order.EMAIL && <>이메일: {order.EMAIL}</>}
|
|
|
|
|
{order.ADDRESS && <div>주소: {order.ADDRESS}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{lowStock.length > 0 && (
|
|
|
|
|
<div className="border border-rose-200 bg-rose-50 rounded p-2 text-[11px] text-rose-700 flex items-start gap-2">
|
|
|
|
|
<AlertCircle size={14} className="mt-0.5 flex-shrink-0" />
|
|
|
|
|
<div>
|
|
|
|
|
<b>재고 부족 {lowStock.length}건</b> — 출고 시 거부됩니다:
|
|
|
|
|
<ul className="mt-1 ml-4 list-disc">
|
|
|
|
|
{lowStock.map((it) => (
|
|
|
|
|
<li key={it.SEQ}>{it.ITEM_NAME} (요청 {fmt(it.QTY)} / 현재고 {fmt(it.STOCK_QTY)})</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<table className="w-full text-[11px] border border-slate-300">
|
|
|
|
|
<thead className="bg-slate-100">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5 w-8">#</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5 text-left">품명</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5 w-12">구분</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5 w-14">현재고</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5 w-12">수량</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5 w-16">단가</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5">공급가</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5">세액</th>
|
|
|
|
|
<th className="border border-slate-300 px-1.5 py-1.5">합계</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="tabular-nums">
|
|
|
|
|
{items.map((it) => {
|
|
|
|
|
const lack = Number(it.STOCK_QTY) < Number(it.QTY);
|
|
|
|
|
return (
|
|
|
|
|
<tr key={it.SEQ}>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1 text-center">{it.SEQ}</td>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1">{it.ITEM_NAME}</td>
|
|
|
|
|
<td className={`border border-slate-300 px-1.5 py-1 text-center ${it.IS_TAX_FREE === "Y" ? "text-violet-700" : "text-rose-700"}`}>
|
|
|
|
|
{it.IS_TAX_FREE === "Y" ? "면세" : "과세"}
|
|
|
|
|
</td>
|
|
|
|
|
<td className={`border border-slate-300 px-1.5 py-1 text-right ${lack ? "text-rose-700 font-bold" : "text-slate-600"}`}>
|
|
|
|
|
{fmt(it.STOCK_QTY)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.QTY)}</td>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.UNIT_PRICE)}</td>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1 text-right">{fmt(it.SUPPLY_AMOUNT)}</td>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1 text-right">{it.IS_TAX_FREE === "Y" ? "-" : fmt(it.VAT_AMOUNT)}</td>
|
|
|
|
|
<td className="border border-slate-300 px-1.5 py-1 text-right font-semibold">{fmt(it.TOTAL_AMOUNT)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{items.length === 0 && (
|
|
|
|
|
<tr><td colSpan={9} className="border border-slate-300 px-2 py-6 text-center text-slate-400">품목이 없습니다.</td></tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
<table className="ml-auto text-[12px] tabular-nums">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr><td className="px-3 py-1 text-violet-700">면세 합계</td><td className="px-3 py-1 text-right min-w-[120px]">₩ {fmt(order.TOTAL_TAXFREE)}</td></tr>
|
|
|
|
|
<tr><td className="px-3 py-1 text-rose-700">과세 공급가</td><td className="px-3 py-1 text-right">₩ {fmt(order.TOTAL_TAXABLE)}</td></tr>
|
|
|
|
|
<tr><td className="px-3 py-1">세액 합계</td><td className="px-3 py-1 text-right">₩ {fmt(order.TOTAL_VAT)}</td></tr>
|
|
|
|
|
<tr className="border-t-2 border-slate-900 font-bold">
|
|
|
|
|
<td className="px-3 py-1.5">총 합계 (VAT포함)</td>
|
|
|
|
|
<td className="px-3 py-1.5 text-right text-emerald-700">₩ {fmt(order.TOTAL_AMOUNT)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
|
|
{order.STATUS === "REQUESTED" && (
|
|
|
|
|
<div className="flex justify-end gap-2 pt-3 border-t border-slate-200">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onCancel(order)}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
className="px-3 h-9 rounded-lg border border-rose-200 text-rose-700 text-xs font-semibold hover:bg-rose-50 disabled:opacity-50 inline-flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
<X size={12} /> 반려
|
|
|
|
|
</button>
|
|
|
|
|
<span className="text-[11px] text-slate-400 self-center">
|
|
|
|
|
※ 출고는 왼쪽 리스트에서 체크박스 선택 후 상단 [출고] 버튼으로 처리하세요.
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(order.STATUS === "APPROVED" || order.STATUS === "SHIPPED") && (
|
|
|
|
|
<div className="text-[11px] text-emerald-700 pt-2 border-t border-slate-200 inline-flex items-center gap-1">
|
|
|
|
|
<Check size={12} /> 출고 완료 — {order.APPROVE_DATE}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Info({ label, value }: { label: string; value: string }) {
|
|
|
|
|
return <div><div className="text-xs text-slate-500">{label}</div><div className="font-semibold">{value}</div></div>;
|
|
|
|
|
}
|
|
|
|
|