merge: POP 재고관리 전면 구현 (feature→staging)

This commit is contained in:
SeongHyun Kim
2026-04-10 18:48:52 +09:00
17 changed files with 4181 additions and 706 deletions
@@ -11,9 +11,10 @@ interface PopShellProps {
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) {
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
const { user, logout } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
@@ -319,7 +320,10 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className="max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto">
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>
@@ -0,0 +1,150 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "./DateRangePicker";
export function AdjustHistory() {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [dateFrom, setDateFrom] = useState(() => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
});
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10));
const [keyword, setKeyword] = useState("");
const fetchHistory = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (dateFrom) params.date_from = dateFrom;
if (dateTo) params.date_to = dateTo;
if (keyword) params.item_code = keyword;
const res = await apiClient.get("/pop/inventory/adjust-history", { params });
setItems(res.data?.data || []);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [dateFrom, dateTo, keyword]);
useEffect(() => { fetchHistory(); }, [fetchHistory]);
const filtered = items;
return (
<div className="flex flex-col h-full bg-white">
{/* 필터 */}
<div className="bg-white border-b border-gray-200 px-4 py-3 shrink-0">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-2">
<DateRangePicker
from={dateFrom}
to={dateTo}
onChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목코드 검색"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-amber-400"
/>
</div>
</div>
<button
onClick={fetchHistory}
className="px-5 py-2.5 rounded-lg bg-amber-500 text-white text-sm font-bold active:bg-amber-600 shrink-0"
>
</button>
</div>
</div>
{/* KPI */}
<div className="bg-white border-b border-gray-200 shrink-0">
<div className="grid grid-cols-3 divide-x divide-gray-100">
<div className="flex flex-col items-center py-3">
<span className="text-xs font-bold text-gray-400"></span>
<span className="text-2xl font-extrabold text-gray-900">{filtered.length}</span>
</div>
<div className="flex flex-col items-center py-3">
<span className="text-xs font-bold text-gray-400"></span>
<span className="text-2xl font-extrabold text-green-600">
{filtered.filter((h: any) => h.transaction_type === "조정확인").length}
</span>
</div>
<div className="flex flex-col items-center py-3">
<span className="text-xs font-bold text-gray-400"></span>
<span className="text-2xl font-extrabold text-amber-600">
{filtered.filter((h: any) => h.transaction_type === "조정").length}
</span>
</div>
</div>
</div>
{/* 리스트 */}
<div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<span className="text-sm font-bold text-gray-600"> </span>
<span className="text-sm text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="w-10 h-10 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-5xl mb-3">📋</span>
<p className="text-lg font-semibold"> </p>
</div>
) : (
<div className="divide-y divide-gray-100">
{filtered.map((h: any) => {
const isConfirm = h.transaction_type === "조정확인";
const qty = parseFloat(h.quantity || "0");
return (
<div key={h.id} className={`flex items-center gap-3 px-4 py-4 ${isConfirm ? "bg-white" : "bg-amber-50/50"}`}>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl shrink-0 ${
isConfirm ? "bg-green-500" : "bg-amber-500"
}`}>
{isConfirm ? "✓" : "~"}
</div>
<div className="flex-1 min-w-0">
<p className="text-lg font-bold text-gray-900">{h.item_code}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{h.reason && (
<span className={`text-sm px-2.5 py-0.5 rounded-lg font-bold ${
isConfirm ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
}`}>{h.reason}</span>
)}
<span className="text-sm text-gray-400">{h.warehouse_code}</span>
<span className="text-sm text-gray-400">{h.manager_name || h.writer}</span>
</div>
</div>
<div className="text-right shrink-0">
<p className={`text-xl font-bold ${qty === 0 ? "text-green-600" : qty > 0 ? "text-blue-600" : "text-red-600"}`}>
{qty === 0 ? "이상없음" : (qty > 0 ? `+${qty}` : qty)}
</p>
{h.system_qty != null && qty !== 0 && (
<p className="text-sm text-gray-400">{h.system_qty} {h.actual_qty ?? h.system_qty}</p>
)}
<p className="text-xs text-gray-400 mt-0.5">
{h.transaction_date ? new Date(h.transaction_date).toLocaleDateString() : ""}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -234,7 +234,7 @@ export function InOutHistory() {
const filtered = items.filter((item) => {
if (activeTab === "inbound" && item.direction !== "입고") return false;
if (activeTab === "outbound" && item.direction !== "출고") return false;
if (activeTab === "transfer") return false; // 준비 중
if (activeTab === "transfer") return false;
if (keyword) {
const kw = keyword.toLowerCase();
if (
@@ -260,39 +260,24 @@ export function InOutHistory() {
];
return (
<div className="flex flex-col gap-4">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/inventory")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
<div className="flex flex-col h-full bg-gray-100">
{/* Header bar */}
<div className="bg-white border-b-2 border-gray-200 px-4 py-2.5 shrink-0">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/inventory")}
className="w-11 h-11 rounded-xl bg-gray-100 flex items-center justify-center active:bg-gray-200"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
</h1>
<p className="text-xs text-gray-400 mt-0.5">
·
</p>
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h1 className="text-xl font-bold text-gray-900 flex-1"></h1>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="bg-white border-b border-gray-200 px-4 py-3 shrink-0">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
<DateRangePicker
@@ -304,39 +289,33 @@ export function InOutHistory() {
}}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<label className="text-xs font-bold text-gray-500 mb-1 block"></label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목명 / 코드 검색"
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400"
className="w-full px-3 py-3 border border-gray-200 rounded-xl text-base focus:outline-none focus:border-cyan-400"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<label className="text-xs font-bold text-gray-500 mb-1 block"></label>
<select
value={warehouse}
onChange={(e) => setWarehouse(e.target.value)}
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400 bg-white"
className="w-full px-3 py-3 border border-gray-200 rounded-xl text-base focus:outline-none focus:border-cyan-400 bg-white"
>
<option value="전체"></option>
{warehouses.map((w) => (
<option key={w.code} value={w.name}>
{w.name}
</option>
<option key={w.code} value={w.name}>{w.name}</option>
))}
</select>
</div>
</div>
<div className="flex gap-1.5 shrink-0 pb-[1px]">
<div className="flex gap-1.5 shrink-0">
<button
onClick={fetchData}
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
className="px-5 py-3 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
style={{ background: "linear-gradient(135deg,#06b6d4,#0e7490)" }}
>
@@ -348,7 +327,7 @@ export function InOutHistory() {
setKeyword("");
setWarehouse("전체");
}}
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
className="w-12 h-12 rounded-xl text-lg font-bold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
>
</button>
@@ -356,121 +335,77 @@ export function InOutHistory() {
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="grid grid-cols-4 gap-0">
<KpiCell
icon="📥"
value={loading ? "-" : kpi.inbound.toLocaleString()}
label="입고"
color="text-blue-600"
/>
<KpiCell
icon="📤"
value={loading ? "-" : kpi.outbound.toLocaleString()}
label="출고"
color="text-green-600"
/>
<KpiCell
icon="🔄"
value={loading ? "-" : kpi.transfer.toLocaleString()}
label="이동"
color="text-gray-400"
/>
<KpiCell
icon="📊"
value={loading ? "-" : kpi.total.toLocaleString()}
label="전체"
color="text-gray-900"
/>
{/* KPI + Tabs */}
<div className="bg-white border-b border-gray-200 shrink-0">
<div className="grid grid-cols-4 divide-x divide-gray-100">
<KpiCell icon="📥" value={loading ? "-" : kpi.inbound.toLocaleString()} label="입고" color="text-blue-600" />
<KpiCell icon="📤" value={loading ? "-" : kpi.outbound.toLocaleString()} label="출고" color="text-green-600" />
<KpiCell icon="🔄" value={loading ? "-" : kpi.transfer.toLocaleString()} label="이동" color="text-gray-400" />
<KpiCell icon="📊" value={loading ? "-" : kpi.total.toLocaleString()} label="전체" color="text-gray-900" />
</div>
<div className="flex gap-1.5 px-4 pb-3 pt-1">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
className={`px-5 py-2.5 rounded-xl text-base font-bold transition-all active:scale-[0.97] ${
tab.disabled
? "text-gray-300 bg-gray-50 cursor-not-allowed"
: activeTab === tab.key
? "text-white shadow-md"
: "text-gray-600 bg-gray-100"
}`}
style={
!tab.disabled && activeTab === tab.key
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
tab.disabled
? "text-gray-300 bg-gray-50 cursor-not-allowed"
: activeTab === tab.key
? "text-white shadow-sm"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
}`}
style={
!tab.disabled && activeTab === tab.key
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
{/* List */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-xs text-gray-400"> {filtered.length}</span>
<div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<span className="text-sm font-bold text-gray-600"> </span>
<span className="text-sm text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex flex-col gap-3 py-4">
<div className="flex flex-col">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse"
>
<div key={i} className="bg-white border-b border-gray-100 px-4 py-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gray-100" />
<div className="w-12 h-12 rounded-xl bg-gray-100" />
<div className="flex-1 flex flex-col gap-2">
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-3 bg-gray-50 rounded w-1/2" />
</div>
<div className="h-5 w-12 bg-gray-100 rounded-full" />
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg
className="w-16 h-16 mb-4 opacity-20"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
<p className="text-sm font-medium text-gray-500 mb-1">
</p>
<p className="text-xs text-gray-400"> </p>
<div className="flex flex-col items-center justify-center py-20 text-gray-400 bg-white">
<span className="text-5xl mb-3">📦</span>
<p className="text-lg font-semibold mb-1"> </p>
<p className="text-sm text-gray-400"> </p>
</div>
) : (
filtered.map((item) => (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]"
>
<div className="flex items-center gap-3">
<div className="divide-y divide-gray-100">
{filtered.map((item) => (
<button
key={item.id}
onClick={() => setSelectedItem(item)}
className="w-full bg-white px-4 py-4 flex items-center gap-3 text-left active:bg-gray-50 transition-colors"
>
{/* Direction icon */}
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0 ${
item.direction === "입고" ? "" : ""
}`}
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl shrink-0"
style={{
background:
item.direction === "입고"
@@ -484,39 +419,30 @@ export function InOutHistory() {
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-900 truncate">
<span className="text-lg font-bold text-gray-900 truncate">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
{item.statusLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">
<div className="text-sm text-gray-400 mt-0.5">
{item.type} · {item.warehouse}
</div>
</div>
{/* Qty + Time */}
<div className="text-right shrink-0">
<p
className="text-base font-bold text-gray-900"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<p className="text-xl font-bold text-gray-900" style={{ fontVariantNumeric: "tabular-nums" }}>
{item.qty.toLocaleString()}{" "}
<span className="text-xs font-normal text-gray-400">
{item.unit}
</span>
</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{item.time}
<span className="text-sm font-normal text-gray-400">{item.unit}</span>
</p>
<p className="text-xs text-gray-400 mt-0.5">{item.time}</p>
</div>
</div>
</div>
))
</button>
))}
</div>
)}
</div>
@@ -526,165 +452,87 @@ export function InOutHistory() {
className="fixed inset-0 z-50 flex items-end justify-center"
onClick={() => setSelectedItem(null)}
>
{/* Overlay */}
<div className="absolute inset-0 bg-black/40 transition-opacity" />
{/* Sheet */}
<div className="absolute inset-0 bg-black/40" />
<div
className="relative w-full max-w-lg bg-white rounded-t-3xl shadow-2xl max-h-[85vh] overflow-y-auto animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">
{selectedItem.direction === "입고" ? "입고" : "출고"} {" "}
{selectedItem.docNumber}
{selectedItem.direction === "입고" ? "입고" : "출고"} {selectedItem.docNumber}
</h3>
<button
onClick={() => setSelectedItem(null)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<button onClick={() => setSelectedItem(null)} className="w-10 h-10 rounded-xl flex items-center justify-center text-gray-400 bg-gray-100 active:bg-gray-200">
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-5">
{/* Row 1: 전표번호 + 구분 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="전표번호" value={selectedItem.docNumber} />
<DetailField label="구분" value={selectedItem.type} />
</div>
{/* Row 2: 일시 + 상태 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="일시" value={selectedItem.fullDate} />
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
</p>
<span
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${selectedItem.statusColor}`}
>
<p className="text-xs font-bold text-cyan-600 mb-1"></p>
<span className={`inline-block text-sm font-bold px-3 py-1 rounded-lg ${selectedItem.statusColor}`}>
{selectedItem.statusLabel}
</span>
</div>
</div>
<div className="border-t border-gray-100" />
{/* Row 3: 품목 */}
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
</p>
<p className="text-base font-bold text-gray-900">
<p className="text-xs font-bold text-cyan-600 mb-1"></p>
<p className="text-lg font-bold text-gray-900">
{selectedItem.itemName}
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
{selectedItem.spec ? (
<span className="text-sm font-normal text-gray-400 ml-2">
{selectedItem.spec}
</span>
<span className="text-sm font-normal text-gray-400 ml-2">{selectedItem.spec}</span>
) : null}
</p>
</div>
{/* Row 4: 수량 + LOT */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
</p>
<p
className="text-xl font-bold text-cyan-600"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<p className="text-xs font-bold text-cyan-600 mb-1"></p>
<p className="text-2xl font-bold text-cyan-600" style={{ fontVariantNumeric: "tabular-nums" }}>
{selectedItem.qty.toLocaleString()}{" "}
<span className="text-sm font-normal text-gray-400">
{selectedItem.unit}
</span>
<span className="text-base font-normal text-gray-400">{selectedItem.unit}</span>
</p>
</div>
<DetailField
label="LOT번호"
value={selectedItem.lotNumber || "-"}
/>
<DetailField label="LOT번호" value={selectedItem.lotNumber || "-"} />
</div>
<div className="border-t border-gray-100" />
{/* Row 5: 창고/위치 + 거래처 */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
/
</p>
<p className="text-sm font-bold text-gray-900">
{selectedItem.warehouse}
</p>
<p className="text-xs font-bold text-cyan-600 mb-1"> / </p>
<p className="text-base font-bold text-gray-900">{selectedItem.warehouse}</p>
{selectedItem.locationCode && (
<p className="text-xs text-gray-400">
{selectedItem.locationCode}
</p>
<p className="text-sm text-gray-400">{selectedItem.locationCode}</p>
)}
</div>
<DetailField label="거래처" value={selectedItem.partnerName} />
</div>
{/* Row 6: 작업자 + 비고 */}
<div className="grid grid-cols-2 gap-4">
<DetailField
label="작업자"
value={selectedItem.writer || "-"}
/>
<DetailField label="작업자" value={selectedItem.writer || "-"} />
<DetailField label="비고" value={selectedItem.memo || "-"} />
</div>
{/* Row 7: 참조번호 + 금액 (있을 때만) */}
{(selectedItem.referenceNumber ||
selectedItem.totalAmount > 0) && (
{(selectedItem.referenceNumber || selectedItem.totalAmount > 0) && (
<div className="grid grid-cols-2 gap-4">
{selectedItem.referenceNumber ? (
<DetailField
label="참조번호"
value={selectedItem.referenceNumber}
/>
) : (
<div />
)}
<DetailField label="참조번호" value={selectedItem.referenceNumber} />
) : <div />}
{selectedItem.totalAmount > 0 ? (
<DetailField
label="금액"
value={`${selectedItem.totalAmount.toLocaleString()}`}
/>
) : (
<div />
)}
<DetailField label="금액" value={`${selectedItem.totalAmount.toLocaleString()}`} />
) : <div />}
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-gray-100">
<button
onClick={() => setSelectedItem(null)}
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all"
className="w-full py-4 rounded-xl text-lg font-bold text-gray-600 bg-gray-100 active:bg-gray-200 active:scale-[0.98] transition-all"
>
</button>
@@ -694,14 +542,14 @@ export function InOutHistory() {
)}
<style jsx>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
</div>
);
}
@@ -713,8 +561,8 @@ export function InOutHistory() {
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">{label}</p>
<p className="text-sm font-semibold text-gray-900">{value}</p>
<p className="text-xs font-bold text-cyan-600 mb-1">{label}</p>
<p className="text-base font-semibold text-gray-900">{value}</p>
</div>
);
}
@@ -731,17 +579,15 @@ function KpiCell({
color: string;
}) {
return (
<div className="flex flex-col items-center py-2">
<span className="text-lg mb-0.5">{icon}</span>
<div className="flex flex-col items-center py-3">
<span className="text-xl mb-0.5">{icon}</span>
<span
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">
{label}
</span>
<span className="text-xs font-semibold text-gray-400 mt-1">{label}</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,2 +1,3 @@
export { AdjustHistory } from "./AdjustHistory";
export { InOutHistory } from "./InOutHistory";
export { InventoryHome } from "./InventoryHome";
@@ -51,6 +51,7 @@ interface ProcessData {
target_location_code: string | null;
is_rework: string;
routing_detail_id: string | null;
batch_id?: string | null;
}
interface WorkInstructionInfo {
@@ -403,6 +404,14 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
const [packageUnit, setPackageUnit] = useState<string>("");
const [inboundDone, setInboundDone] = useState(false);
/* ---- Batch Badge (단일/다중품목) ---- */
const [batchBadge, setBatchBadge] = useState<{
isMulti: boolean;
index: number;
total: number;
itemType: string;
} | null>(null);
/* ---- Batch History ---- */
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
@@ -451,6 +460,46 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
}
}
// batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원)
let batchItemType = "";
if (procData.batch_id) {
try {
const batchItemRes = await dataApi.getTableData("item_info", {
size: 1,
filters: { item_number: procData.batch_id },
});
const batchItem = batchItemRes.data?.[0] as Record<string, unknown> | undefined;
if (batchItem) {
itemName = String(batchItem.item_name || procData.batch_id);
itemCode = String(batchItem.item_number || procData.batch_id);
batchItemType = String(batchItem.type || "");
} else {
itemName = procData.batch_id;
itemCode = procData.batch_id;
}
} catch {
itemName = procData.batch_id;
itemCode = procData.batch_id;
}
}
// item_type이 없으면 WI의 item_number로 조회
if (!batchItemType && wi.item_number) {
try {
const wiItemRes = await dataApi.getTableData("item_info", {
size: 1,
filters: { item_number: String(wi.item_number) },
});
const wiItem = wiItemRes.data?.[0] as Record<string, unknown> | undefined;
if (wiItem) {
batchItemType = String(wiItem.type || "");
}
} catch {
/* non-critical */
}
}
// batchItemType을 임시 저장 (step 6에서 사용)
(procData as unknown as Record<string, unknown>)._itemType = batchItemType;
setWiInfo({
work_instruction_no: String(wi.work_instruction_no || ""),
item_name: itemName,
@@ -502,22 +551,40 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
size: 100,
filters: { wo_id: procData.wo_id },
});
const masters = ((plRes.data ?? []) as ProcessData[])
const allSiblings = (plRes.data ?? []) as ProcessData[];
const masters = allSiblings
.filter((p) => !p.parent_process_id)
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10))
.map((p) => ({
process_code: p.process_code,
process_name: p.process_name,
}));
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
// 중복 제거
const seen = new Set<string>();
setProcessList(
masters.filter((m) => {
masters.map((p) => ({
process_code: p.process_code,
process_name: p.process_name,
})).filter((m) => {
if (seen.has(m.process_code)) return false;
seen.add(m.process_code);
return true;
}),
);
// 다중품목 판단: 마스터 공정의 DISTINCT batch_id
const uniqueBatches: string[] = [];
for (const p of masters) {
const bid = p.batch_id || "";
if (bid && !uniqueBatches.includes(bid)) {
uniqueBatches.push(bid);
}
}
const currentBid = procData?.batch_id || "";
const isMultiBatch = uniqueBatches.length > 1;
const bIdx = currentBid ? uniqueBatches.indexOf(currentBid) + 1 : 1;
const fetchedItemType = String((procData as unknown as Record<string, unknown>)?._itemType || "");
setBatchBadge({
isMulti: isMultiBatch,
index: Math.max(bIdx, 1),
total: Math.max(uniqueBatches.length, 1),
itemType: fetchedItemType,
});
} catch {
setProcessList([]);
}
@@ -1091,6 +1158,19 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
</span>
</div>
)}
{batchBadge && (
<div className="flex items-center gap-1.5 shrink-0">
{batchBadge.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full">
{batchBadge.index}/{batchBadge.total}{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full">
{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""}
</span>
)}
</div>
)}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-white/40 text-sm"></span>
<span className="text-white font-medium text-base">
@@ -111,6 +111,8 @@ interface WorkOrderProcess {
accepted_by?: string;
accepted_at?: string | null;
created_date?: string;
batch_id?: string | null;
equipment_code?: string;
}
interface ProcessMng {
@@ -222,12 +224,16 @@ function FullscreenWorkModal({
processId,
myProcesses,
instructionMap,
itemNameMap,
multiBatchInfo,
onSwitch,
onClose,
}: {
processId: string;
myProcesses: WorkOrderProcess[];
instructionMap: Record<string, WorkInstruction>;
itemNameMap: Record<string, string>;
multiBatchInfo: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }>;
onSwitch: (id: string) => void;
onClose: () => void;
}) {
@@ -301,11 +307,24 @@ function FullscreenWorkModal({
<div className="text-base font-bold text-gray-900 mb-1">
{wi?.work_instruction_no || "작업지시"}
</div>
{(() => {
const bInfo = multiBatchInfo[proc.id];
if (!bInfo) return null;
const typeLabel = bInfo.itemType || "";
return bInfo.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full self-start mb-0.5">
{bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full self-start mb-0.5">
{typeLabel ? ` · ${typeLabel}` : ""}
</span>
);
})()}
<AutoScrollText className="text-sm text-gray-500 mb-0.5">
📦 {wi?.item_name || ""}
{wi?.item_code || wi?.item_number
? `(${wi?.item_code || wi?.item_number})`
: ""}
📦 {proc.batch_id
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
: `${wi?.item_name || ""}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
</AutoScrollText>
<div className="text-sm text-gray-600 mb-0.5">
{proc.process_name} · {proc.equipment_code || "미배정"}
@@ -463,7 +482,11 @@ function CompressedProcessSteps({
allProcesses?: WorkOrderProcess[];
}) {
const sorted = [...processes]
.filter((p) => !p.parent_process_id)
.filter((p) => !p.parent_process_id && (
// 같은 batch_id끼리만 표시 (다중 품목 구분)
(!batchId && !p.batch_id) ||
(batchId && p.batch_id === batchId)
))
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
if (sorted.length === 0) return null;
@@ -479,7 +502,7 @@ function CompressedProcessSteps({
if (batchId && allProcesses) {
const batchSplits = allProcesses.filter(
(p) =>
(p as Record<string, unknown>).batch_id === batchId &&
p.batch_id === batchId &&
p.parent_process_id &&
p.status === "completed",
);
@@ -963,6 +986,8 @@ export function WorkOrderList() {
const [allProcesses, setAllProcesses] = useState<WorkOrderProcess[]>([]);
const [processList, setProcessList] = useState<ProcessMng[]>([]);
const [equipmentList, setEquipmentList] = useState<EquipmentMng[]>([]);
const [itemNameMap, setItemNameMap] = useState<Record<string, string>>({});
const [itemTypeMap, setItemTypeMap] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<TabFilter>("acceptable");
@@ -1044,22 +1069,37 @@ export function WorkOrderList() {
wiRaw = wiRes.data;
}
// wi_id → id 매핑 + 중복 제거 (header+detail 조인이므로 첫 행만)
// item_number → item_name 매핑 구축 (다중 품목 표시용)
const seen = new Set<string>();
const wiData: WorkInstruction[] = [];
const newItemNameMap: Record<string, string> = {};
const newItemTypeMap: Record<string, string> = {};
for (const raw of wiRaw) {
const wiId = String(raw.wi_id || raw.id || "");
// item_number → item_name / item_type 매핑 (모든 행에서 수집)
const rawItemNumber = String(raw.item_number || "");
const rawItemName = String(raw.item_name || "");
const rawItemType = String(raw.item_type || "");
if (rawItemNumber && rawItemName) {
newItemNameMap[rawItemNumber] = rawItemName;
}
if (rawItemNumber && rawItemType) {
newItemTypeMap[rawItemNumber] = rawItemType;
}
if (!wiId || seen.has(wiId)) continue;
seen.add(wiId);
wiData.push({
...raw,
id: wiId,
item_name: String(raw.item_name || ""),
item_name: rawItemName,
item_code: String(raw.item_code || ""),
item_number: String(raw.item_number || ""),
item_number: rawItemNumber,
qty: parseInt(String(raw.total_qty || raw.qty || 0), 10),
} as unknown as WorkInstruction);
}
setInstructions(wiData);
setItemNameMap(newItemNameMap);
setItemTypeMap(newItemTypeMap);
const procRes = await dataApi.getTableData("work_order_process", {
size: 1000,
@@ -1124,6 +1164,44 @@ export function WorkOrderList() {
return map;
}, [allProcesses]);
/** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */
const multiBatchInfo = useMemo(() => {
// wo_id → 고유 batch_id 목록 (마스터 행 기준)
const woBatches: Record<string, string[]> = {};
for (const proc of allProcesses) {
if (proc.parent_process_id) continue; // 마스터만
if (!proc.wo_id) continue;
if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = [];
const bid = proc.batch_id || "";
if (bid && !woBatches[proc.wo_id].includes(bid)) {
woBatches[proc.wo_id].push(bid);
}
}
// proc.id → { isMulti, index, total, itemType }
const info: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }> = {};
for (const proc of allProcesses) {
if (!proc.wo_id) continue;
const batches = woBatches[proc.wo_id] || [];
const bid = proc.batch_id || "";
const isMulti = batches.length > 1;
const index = bid ? batches.indexOf(bid) + 1 : 1;
const total = Math.max(batches.length, 1);
// item_type: batch_id가 있으면 itemTypeMap에서, 없으면 WI의 item_number로
let itemType = "";
if (bid) {
itemType = itemTypeMap[bid] || "";
}
if (!itemType) {
const wi = instructionMap[proc.wo_id];
if (wi?.item_number) {
itemType = itemTypeMap[wi.item_number] || "";
}
}
info[proc.id] = { isMulti, index, total, itemType };
}
return info;
}, [allProcesses, itemTypeMap, instructionMap]);
const masterProcesses = useMemo(() => {
// 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거
const seen = new Set<string>();
@@ -1365,7 +1443,11 @@ export function WorkOrderList() {
const openDetailModal = (proc: WorkOrderProcess) => {
const wi = instructionMap[proc.wo_id];
const siblings = (processesByWo[proc.wo_id] || [])
.filter((p) => !p.parent_process_id)
.filter((p) => !p.parent_process_id && (
// 같은 batch_id끼리만 형제 (다중 품목 구분)
(!proc.batch_id && !p.batch_id) ||
(proc.batch_id && p.batch_id === proc.batch_id)
))
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
const totalQty = wi ? wi.qty : parseInt(proc.plan_qty || "0", 10);
@@ -1448,7 +1530,11 @@ export function WorkOrderList() {
/* ---- Helper: get previous process info ---- */
const getPrevProcessInfo = (proc: WorkOrderProcess) => {
const siblings = (processesByWo[proc.wo_id] || [])
.filter((p) => !p.parent_process_id)
.filter((p) => !p.parent_process_id && (
// 같은 batch_id끼리만 형제 (다중 품목 구분)
(!proc.batch_id && !p.batch_id) ||
(proc.batch_id && p.batch_id === proc.batch_id)
))
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
const currentIdx = siblings.findIndex((p) => p.id === proc.id);
@@ -1726,7 +1812,11 @@ export function WorkOrderList() {
const wi = instructionMap[proc.wo_id];
const badge = STATUS_BADGE[proc.status] || STATUS_BADGE.waiting;
const siblingProcesses = (processesByWo[proc.wo_id] || []).filter(
(p) => !p.parent_process_id,
(p) => !p.parent_process_id && (
// 같은 batch_id끼리만 형제 (다중 품목 구분)
(!proc.batch_id && !p.batch_id) ||
(proc.batch_id && p.batch_id === proc.batch_id)
),
);
const planQty = parseInt(proc.plan_qty || "0", 10);
const goodQty = parseInt(proc.good_qty || "0", 10);
@@ -1881,12 +1971,31 @@ export function WorkOrderList() {
</span>
</div>
{/* 단일/다중품목 뱃지 */}
{(() => {
const bInfo = multiBatchInfo[proc.id];
if (!bInfo) return null;
const typeLabel = bInfo.itemType || "";
return (
<div className="flex items-center gap-1.5 mb-1">
{bInfo.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full">
{bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full">
{typeLabel ? ` · ${typeLabel}` : ""}
</span>
)}
</div>
);
})()}
{/* Sub-info: item name + equipment */}
<AutoScrollText className="text-sm text-gray-500 mb-3">
📦 {wi?.item_name || "품목"}
{wi?.item_code || wi?.item_number
? `(${wi?.item_code || wi?.item_number})`
: ""}
📦 {proc.batch_id
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
: `${wi?.item_name || "품목"}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
{" · "}
{!isRework
? `⚙️ ${eqName}`
@@ -1901,9 +2010,7 @@ export function WorkOrderList() {
status={proc.status}
onClick={() => openDetailModal(proc)}
batchId={
(proc as Record<string, unknown>).batch_id as
| string
| undefined
proc.batch_id ?? undefined
}
allProcesses={allProcesses}
/>
@@ -1965,7 +2072,7 @@ export function WorkOrderList() {
proc.id,
proc.process_name,
proc.seq_no,
(proc as Record<string, unknown>)
(proc as unknown as Record<string, unknown>)
.rework_source_id as string | undefined,
)
}
@@ -2080,6 +2187,8 @@ export function WorkOrderList() {
p.status === "in_progress",
)}
instructionMap={instructionMap}
itemNameMap={itemNameMap}
multiBatchInfo={multiBatchInfo}
onSwitch={(id) => setWorkModalProcessId(id)}
onClose={() => {
setWorkModalProcessId(null);