Files
vexplor/frontend/components/pop/hardcoded/inventory/InOutHistory.tsx
SeongHyun Kim 8c23f48996 feat: POP 재고관리 전면 구현 — 재고조정/재고이동/다중품목 공정
재고조정:
- fullBleed 좌우 분할, 숫자키패드 모달, 위치불일치 QR스캔+모달
- 임시저장 cart_items 상태관리 (saved/cancelled/confirmed)
- 조정이력 별도 페이지, DateRangePicker 통일
- popInventoryController 11개 API (adjust-batch, stock-detail, locations 등)

재고이동:
- 창고 탭: 탭 버튼 패턴 + flat 리스트 (아코디언 제거)
- 공정 탭: 공정명/설비 필터 모달 (작업지시번호 탭 제거)
- move-batch API: 창고→창고 + 공정→창고 (source_type 확장)
- 품목 이력 바텀시트 (transaction_type별 색상)

다중품목 공정실행:
- syncWorkInstructions LIMIT 1 제거 → detail 전체 순회
- batch_id 기반 품목별 공정 분리
- WorkOrderList/ProcessWork 품목 구분 표시

기타:
- PopShell fullBleed 모드 추가
- alert() → 토스트 메시지 교체
- MonitoringSettings import 수정
2026-04-10 17:17:23 +09:00

594 lines
20 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "./DateRangePicker";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface HistoryItem {
id: string;
direction: "입고" | "출고";
docNumber: string;
type: string;
itemName: string;
itemCode: string;
spec: string;
qty: number;
unit: string;
unitPrice: number;
totalAmount: number;
warehouse: string;
warehouseCode: string;
locationCode: string;
lotNumber: string;
partnerName: string;
referenceNumber: string;
writer: string;
memo: string;
status: string;
statusColor: string;
statusLabel: string;
time: string;
date: string;
fullDate: string;
}
interface KpiData {
inbound: number;
outbound: number;
transfer: number;
total: number;
}
type TabKey = "all" | "inbound" | "outbound" | "transfer";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getStatusStyle(status: string | null): {
color: string;
label: string;
} {
switch (status) {
case "완료":
case "입고완료":
case "출고완료":
return { color: "text-green-600 bg-green-50", label: "완료" };
case "대기":
return { color: "text-amber-600 bg-amber-50", label: "대기" };
case "진행중":
case "부분입고":
case "부분출고":
return { color: "text-blue-600 bg-blue-50", label: "진행중" };
default:
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InOutHistory() {
const router = useRouter();
/* Filter state */
const [dateFrom, setDateFrom] = useState(() =>
new Date().toISOString().slice(0, 10),
);
const [dateTo, setDateTo] = useState(() =>
new Date().toISOString().slice(0, 10),
);
const [keyword, setKeyword] = useState("");
const [warehouse, setWarehouse] = useState("전체");
const [warehouses, setWarehouses] = useState<
{ code: string; name: string }[]
>([]);
/* Data state */
const [items, setItems] = useState<HistoryItem[]>([]);
const [kpi, setKpi] = useState<KpiData>({
inbound: 0,
outbound: 0,
transfer: 0,
total: 0,
});
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [selectedItem, setSelectedItem] = useState<HistoryItem | null>(null);
/* Fetch warehouses */
useEffect(() => {
apiClient
.get("/outbound/warehouses")
.then((res) => {
const data = res.data?.data ?? [];
setWarehouses(
data.map((w: any) => ({
code: w.warehouse_code || "",
name: w.warehouse_name || "",
})),
);
})
.catch(() => {});
}, []);
/* Fetch data */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (dateFrom) params.date_from = dateFrom;
if (dateTo) params.date_to = dateTo;
const [inRes, outRes] = await Promise.all([
apiClient.get("/receiving/list", { params }),
apiClient.get("/outbound/list", { params }),
]);
const inRows: any[] = inRes.data?.data ?? [];
const outRows: any[] = outRes.data?.data ?? [];
const combined: HistoryItem[] = [
...inRows.map((r: any, idx: number) => {
const st = getStatusStyle(r.inbound_status);
return {
id: `in-${r.detail_id || r.id}-${idx}`,
direction: "입고" as const,
docNumber: r.inbound_number || "-",
type: r.inbound_type || "입고",
itemName: r.item_name || "-",
itemCode: r.item_number || "",
spec: r.specification || r.spec || "",
qty: Number(r.inbound_qty || 0),
unit: r.unit || "EA",
unitPrice: Number(r.unit_price || 0),
totalAmount: Number(r.total_amount || 0),
warehouse: r.warehouse_name || "-",
warehouseCode: r.warehouse_code || "",
locationCode: r.location_code || "",
lotNumber: r.lot_number || "",
partnerName: r.supplier_name || "-",
referenceNumber: r.reference_number || "",
writer: r.writer || r.created_by || "",
memo: r.memo || "",
status: r.inbound_status || "",
statusColor: st.color,
statusLabel: st.label,
time: r.created_date
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
})
: "--:--",
date: r.inbound_date || r.created_date?.slice(0, 10) || "",
fullDate: r.created_date
? new Date(r.created_date).toLocaleString("ko-KR")
: "-",
};
}),
...outRows.map((r: any, idx: number) => {
const st = getStatusStyle(r.outbound_status);
return {
id: `out-${r.id}-${idx}`,
direction: "출고" as const,
docNumber: r.outbound_number || "-",
type: r.outbound_type || "출고",
itemName: r.item_name || "-",
itemCode: r.item_code || "",
spec: r.specification || r.spec || "",
qty: Number(r.outbound_qty || 0),
unit: r.unit || "EA",
unitPrice: Number(r.unit_price || 0),
totalAmount: Number(r.total_amount || 0),
warehouse: r.warehouse_name || "-",
warehouseCode: r.warehouse_code || "",
locationCode: r.location_code || "",
lotNumber: r.lot_number || "",
partnerName: r.customer_name || "-",
referenceNumber: r.reference_number || "",
writer: r.writer || r.created_by || "",
memo: r.memo || "",
status: r.outbound_status || "",
statusColor: st.color,
statusLabel: st.label,
time: r.created_date
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
})
: "--:--",
date: r.outbound_date || r.created_date?.slice(0, 10) || "",
fullDate: r.created_date
? new Date(r.created_date).toLocaleString("ko-KR")
: "-",
};
}),
].sort((a, b) => b.time.localeCompare(a.time));
setItems(combined);
setKpi({
inbound: inRows.length,
outbound: outRows.length,
transfer: 0,
total: inRows.length + outRows.length,
});
} catch {
setItems([]);
setKpi({ inbound: 0, outbound: 0, transfer: 0, total: 0 });
} finally {
setLoading(false);
}
}, [dateFrom, dateTo]);
useEffect(() => {
fetchData();
}, [fetchData]);
/* Filter by tab + keyword + warehouse */
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 (keyword) {
const kw = keyword.toLowerCase();
if (
!item.itemName.toLowerCase().includes(kw) &&
!item.itemCode.toLowerCase().includes(kw)
)
return false;
}
if (warehouse !== "전체" && item.warehouse !== warehouse) return false;
return true;
});
const TABS: {
key: TabKey;
label: string;
count: number;
disabled?: boolean;
}[] = [
{ key: "all", label: "전체", count: kpi.total },
{ key: "inbound", label: "입고", count: kpi.inbound },
{ key: "outbound", label: "출고", count: kpi.outbound },
{ key: "transfer", label: "이동", count: kpi.transfer, disabled: true },
];
return (
<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"
>
<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 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
from={dateFrom}
to={dateTo}
onChange={(f, t) => {
setDateFrom(f);
setDateTo(t);
}}
/>
<div>
<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-3 py-3 border border-gray-200 rounded-xl text-base focus:outline-none focus:border-cyan-400"
/>
</div>
<div>
<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-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>
))}
</select>
</div>
</div>
<div className="flex gap-1.5 shrink-0">
<button
onClick={fetchData}
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)" }}
>
</button>
<button
onClick={() => {
setDateFrom(new Date().toISOString().slice(0, 10));
setDateTo(new Date().toISOString().slice(0, 10));
setKeyword("");
setWarehouse("전체");
}}
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>
</div>
</div>
</div>
{/* 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>
{/* List */}
<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">
{[1, 2, 3, 4].map((i) => (
<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-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>
</div>
))}
</div>
) : filtered.length === 0 ? (
<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>
) : (
<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-12 h-12 rounded-xl flex items-center justify-center text-white text-xl shrink-0"
style={{
background:
item.direction === "입고"
? "linear-gradient(135deg,#3b82f6,#1d4ed8)"
: "linear-gradient(135deg,#22c55e,#15803d)",
}}
>
{item.direction === "입고" ? "📥" : "📤"}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-lg font-bold text-gray-900 truncate">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</span>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
{item.statusLabel}
</span>
</div>
<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-xl font-bold text-gray-900" style={{ fontVariantNumeric: "tabular-nums" }}>
{item.qty.toLocaleString()}{" "}
<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>
</button>
))}
</div>
)}
</div>
{/* Detail Bottom Sheet */}
{selectedItem && (
<div
className="fixed inset-0 z-50 flex items-end justify-center"
onClick={() => setSelectedItem(null)}
>
<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()}
>
<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>
<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}
</h3>
<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>
<div className="px-5 py-4 space-y-5">
<div className="grid grid-cols-2 gap-4">
<DetailField label="전표번호" value={selectedItem.docNumber} />
<DetailField label="구분" value={selectedItem.type} />
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField label="일시" value={selectedItem.fullDate} />
<div>
<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" />
<div>
<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>
) : null}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<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-base font-normal text-gray-400">{selectedItem.unit}</span>
</p>
</div>
<DetailField label="LOT번호" value={selectedItem.lotNumber || "-"} />
</div>
<div className="border-t border-gray-100" />
<div className="grid grid-cols-2 gap-4">
<div>
<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-sm text-gray-400">{selectedItem.locationCode}</p>
)}
</div>
<DetailField label="거래처" value={selectedItem.partnerName} />
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField label="작업자" value={selectedItem.writer || "-"} />
<DetailField label="비고" value={selectedItem.memo || "-"} />
</div>
{(selectedItem.referenceNumber || selectedItem.totalAmount > 0) && (
<div className="grid grid-cols-2 gap-4">
{selectedItem.referenceNumber ? (
<DetailField label="참조번호" value={selectedItem.referenceNumber} />
) : <div />}
{selectedItem.totalAmount > 0 ? (
<DetailField label="금액" value={`${selectedItem.totalAmount.toLocaleString()}`} />
) : <div />}
</div>
)}
</div>
<div className="px-5 py-4 border-t border-gray-100">
<button
onClick={() => setSelectedItem(null)}
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>
</div>
</div>
</div>
)}
<style jsx>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<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>
);
}
function KpiCell({
icon,
value,
label,
color,
}: {
icon: string;
value: string;
label: string;
color: string;
}) {
return (
<div className="flex flex-col items-center py-3">
<span className="text-xl mb-0.5">{icon}</span>
<span
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</span>
<span className="text-xs font-semibold text-gray-400 mt-1">{label}</span>
</div>
);
}