merge: POP 재고관리 전면 구현 (feature→staging)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user