[신규 화면] - 설비허브 + 설비관리 + 설비점검 - 재고조정 + 재고이동 [버그 수정] - 창고 NULL status 누락 - 작업지시 sync detail fallback - InspectionModal API 경로 - 검사결과 DB 저장 - seq_no 비순차 대응 - 출고 재고 부족 검증 - 자동 창고 매칭 - 내 접수 목록 필터 [UI 개선] - 사이드바 카드형 - 자재투입 컴팩트 - 커스텀 모달 - 불필요 버튼 제거
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useRouter } from "next/navigation";
|
|
import React, { useEffect, useState } from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
interface RecentItem {
|
|
id: string;
|
|
time: string;
|
|
direction: "입고" | "출고";
|
|
type: string;
|
|
itemName: string;
|
|
qty: string;
|
|
partnerName: string;
|
|
statusColor: string;
|
|
statusLabel: string;
|
|
}
|
|
|
|
interface KpiData {
|
|
todayInbound: number;
|
|
todayOutbound: number;
|
|
todayTotal: number;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 "진행중":
|
|
return { color: "text-blue-600 bg-blue-50", label: "진행중" };
|
|
default:
|
|
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Menu Items */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const MENU_ITEMS = [
|
|
{
|
|
id: "history",
|
|
title: "입출고관리",
|
|
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
|
|
shadowColor: "rgba(59,130,246,.3)",
|
|
icon: (
|
|
<svg
|
|
className="w-7 h-7 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M7.5 7.5h-.75A2.25 2.25 0 004.5 9.75v7.5a2.25 2.25 0 002.25 2.25h7.5a2.25 2.25 0 002.25-2.25v-7.5a2.25 2.25 0 00-2.25-2.25h-.75m-6 3.75l3 3m0 0l3-3m-3 3V1.5m6 9h.75a2.25 2.25 0 012.25 2.25v7.5a2.25 2.25 0 01-2.25 2.25h-7.5a2.25 2.25 0 01-2.25-2.25v-7.5a2.25 2.25 0 012.25-2.25H12"
|
|
/>
|
|
</svg>
|
|
),
|
|
href: "/pop/inventory/history",
|
|
},
|
|
{
|
|
id: "adjust",
|
|
title: "재고조정",
|
|
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
|
|
shadowColor: "rgba(245,158,11,.3)",
|
|
icon: (
|
|
<svg
|
|
className="w-7 h-7 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M10.5 6h9.75M10.5 6a1.5 1.5 0 11-3 0m3 0a1.5 1.5 0 10-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m-9.75 0h9.75"
|
|
/>
|
|
</svg>
|
|
),
|
|
href: "/pop/inventory/transfer",
|
|
},
|
|
{
|
|
id: "move",
|
|
title: "재고이동",
|
|
gradient: "linear-gradient(135deg,#10b981,#059669)",
|
|
shadowColor: "rgba(16,185,129,.3)",
|
|
icon: (
|
|
<svg
|
|
className="w-7 h-7 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
|
/>
|
|
</svg>
|
|
),
|
|
href: "/pop/inventory/move",
|
|
},
|
|
];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Component */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function InventoryHome() {
|
|
const router = useRouter();
|
|
|
|
const [kpi, setKpi] = useState<KpiData>({
|
|
todayInbound: 0,
|
|
todayOutbound: 0,
|
|
todayTotal: 0,
|
|
});
|
|
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
|
|
const [inRes, outRes] = await Promise.all([
|
|
apiClient.get("/receiving/list", {
|
|
params: { date_from: today, date_to: today },
|
|
}),
|
|
apiClient.get("/outbound/list", {
|
|
params: { date_from: today, date_to: today },
|
|
}),
|
|
]);
|
|
|
|
const inRows: any[] = inRes.data?.data ?? [];
|
|
const outRows: any[] = outRes.data?.data ?? [];
|
|
|
|
setKpi({
|
|
todayInbound: inRows.length,
|
|
todayOutbound: outRows.length,
|
|
todayTotal: inRows.length + outRows.length,
|
|
});
|
|
|
|
const combined: RecentItem[] = [
|
|
...inRows.map((r: any, idx: number) => {
|
|
const st = getStatusStyle(r.inbound_status);
|
|
return {
|
|
id: `in-${r.detail_id || r.id}-${idx}`,
|
|
time: r.created_date
|
|
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: "--:--",
|
|
direction: "입고" as const,
|
|
type: r.inbound_type || "입고",
|
|
itemName: r.item_name || r.item_number || "-",
|
|
qty: `${Number(r.inbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`,
|
|
partnerName: r.supplier_name || "-",
|
|
statusColor: st.color,
|
|
statusLabel: st.label,
|
|
};
|
|
}),
|
|
...outRows.map((r: any, idx: number) => {
|
|
const st = getStatusStyle(r.outbound_status);
|
|
return {
|
|
id: `out-${r.id}-${idx}`,
|
|
time: r.created_date
|
|
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
: "--:--",
|
|
direction: "출고" as const,
|
|
type: r.outbound_type || "출고",
|
|
itemName: r.item_name || r.item_code || "-",
|
|
qty: `${Number(r.outbound_qty || 0).toLocaleString()} ${r.unit || "EA"}`,
|
|
partnerName: r.customer_name || "-",
|
|
statusColor: st.color,
|
|
statusLabel: st.label,
|
|
};
|
|
}),
|
|
]
|
|
.sort((a, b) => b.time.localeCompare(a.time))
|
|
.slice(0, 5);
|
|
|
|
setRecentItems(combined);
|
|
} catch {
|
|
// keep empty
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
const handleMenuClick = (item: (typeof MENU_ITEMS)[number]) => {
|
|
if (item.href === "#") {
|
|
alert(`${item.title} 화면은 준비 중입니다.`);
|
|
} else {
|
|
router.push(item.href);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
{/* Back + Title */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => router.push("/pop/home")}
|
|
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"
|
|
>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
|
<div className="grid grid-cols-3 gap-0">
|
|
<KpiCell
|
|
value={loading ? "-" : kpi.todayInbound.toLocaleString()}
|
|
label="금일 입고"
|
|
color="text-blue-600"
|
|
/>
|
|
<KpiCell
|
|
value={loading ? "-" : kpi.todayOutbound.toLocaleString()}
|
|
label="금일 출고"
|
|
color="text-green-600"
|
|
/>
|
|
<KpiCell
|
|
value={loading ? "-" : kpi.todayTotal.toLocaleString()}
|
|
label="전체"
|
|
color="text-gray-900"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Menu Icons */}
|
|
<section>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="w-1 h-5 rounded-full bg-cyan-500" />
|
|
<h2 className="text-base sm:text-lg font-bold text-gray-900">
|
|
재고 관리
|
|
</h2>
|
|
</div>
|
|
<div className="flex flex-wrap justify-start gap-x-5 gap-y-4 sm:gap-x-6 sm:gap-y-5">
|
|
{MENU_ITEMS.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex flex-col items-center gap-2 w-16 sm:w-[72px] cursor-pointer group"
|
|
style={{ WebkitTapHighlightColor: "transparent" }}
|
|
onClick={() => handleMenuClick(item)}
|
|
>
|
|
<div
|
|
className="w-14 h-14 sm:w-16 sm:h-16 rounded-2xl flex items-center justify-center transition-transform duration-150 group-hover:scale-105 group-active:scale-[0.93]"
|
|
style={{
|
|
background: item.gradient,
|
|
boxShadow: `0 4px 12px ${item.shadowColor}`,
|
|
}}
|
|
>
|
|
{item.icon}
|
|
</div>
|
|
<span className="text-[11px] sm:text-xs font-semibold text-gray-700 text-center leading-tight">
|
|
{item.title}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Recent Activity */}
|
|
<section>
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
|
|
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-100">
|
|
<h3 className="text-base sm:text-lg font-bold text-gray-900">
|
|
최근 입출고
|
|
</h3>
|
|
<span className="text-xs text-gray-400">최근 5건</span>
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{loading ? (
|
|
<div className="flex flex-col gap-3 py-2">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3">
|
|
<div className="w-[44px] h-4 bg-gray-100 rounded animate-pulse" />
|
|
<div className="flex-1 flex flex-col gap-1.5">
|
|
<div className="h-4 bg-gray-100 rounded w-3/4 animate-pulse" />
|
|
<div className="h-3 bg-gray-50 rounded w-1/2 animate-pulse" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : recentItems.length === 0 ? (
|
|
<div className="text-center py-8 text-sm text-gray-400">
|
|
금일 입출고 내역이 없습니다
|
|
</div>
|
|
) : (
|
|
recentItems.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center gap-3 p-3 rounded-xl hover:bg-gray-50 transition-colors"
|
|
>
|
|
<span
|
|
className="text-xs font-semibold text-gray-400 min-w-[44px] text-right"
|
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{item.time}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.direction === "입고" ? "text-blue-600 bg-blue-50" : "text-green-600 bg-green-50"}`}
|
|
>
|
|
{item.direction}
|
|
</span>
|
|
<span className="text-sm font-semibold text-gray-900 truncate">
|
|
{item.itemName}
|
|
</span>
|
|
<span
|
|
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
|
|
>
|
|
{item.statusLabel}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-400 mt-0.5 truncate">
|
|
{item.type} | {item.partnerName} | {item.qty}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Sub-components */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function KpiCell({
|
|
value,
|
|
label,
|
|
color,
|
|
}: {
|
|
value: string;
|
|
label: string;
|
|
color: string;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center py-2">
|
|
<span
|
|
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
|
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.02em" }}
|
|
>
|
|
{value}
|
|
</span>
|
|
<span className="text-[11px] font-medium text-gray-400 mt-1">
|
|
{label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|