feat: 입고 KPI 실데이터 + 장바구니 헤더 이동 + 거래처 모달 복원
- 입고유형선택: KPI/최근입고 실데이터 연동 (더미 제거) - 장바구니 아이콘: 본문 → 헤더 프로필 왼쪽으로 이동 - PopShell: headerRight prop 추가 - 거래처: 인라인 드롭다운 제거, 클릭→모달 방식 복원
This commit is contained in:
@@ -1,12 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { PurchaseInbound } from "@/components/pop/hardcoded/inbound";
|
||||
|
||||
export default function PurchaseInboundPage() {
|
||||
const [cartCount, setCartCount] = useState(0);
|
||||
const [openCart, setOpenCart] = useState(false);
|
||||
|
||||
return (
|
||||
<PopShell showBanner={false} title="구매입고">
|
||||
<PurchaseInbound />
|
||||
<PopShell
|
||||
showBanner={false}
|
||||
title="구매입고"
|
||||
headerRight={
|
||||
<button
|
||||
onClick={() => setOpenCart(true)}
|
||||
className="relative w-11 h-11 rounded-xl bg-white/10 flex items-center justify-center text-white hover:bg-white/20 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="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
|
||||
</svg>
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-[10px] font-bold text-white flex items-center justify-center">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PurchaseInbound
|
||||
onCartCountChange={setCartCount}
|
||||
externalCartOpen={openCart}
|
||||
onExternalCartClose={() => setOpenCart(false)}
|
||||
/>
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ interface PopShellProps {
|
||||
showBanner?: boolean;
|
||||
title?: string;
|
||||
showBack?: boolean;
|
||||
headerRight?: ReactNode;
|
||||
}
|
||||
|
||||
export function PopShell({ children, showBanner = true, title, showBack = false }: PopShellProps) {
|
||||
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) {
|
||||
const router = useRouter();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [hours, setHours] = useState("00");
|
||||
@@ -160,6 +161,9 @@ export function PopShell({ children, showBanner = true, title, showBack = false
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom header right content (e.g. cart icon) */}
|
||||
{headerRight}
|
||||
|
||||
<div className="hidden sm:block h-5 w-px bg-white/20" />
|
||||
|
||||
{/* Profile */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -27,6 +28,36 @@ interface RecentInboundItem {
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
interface KpiData {
|
||||
todayTotal: number;
|
||||
todayWaiting: number;
|
||||
todayCompleted: number;
|
||||
purchaseCount: number;
|
||||
outsourcingCount: number;
|
||||
otherCount: number;
|
||||
todayQty: number;
|
||||
todayQtyUnit: string;
|
||||
defectCount: number;
|
||||
}
|
||||
|
||||
interface InboundRow {
|
||||
id: number;
|
||||
inbound_number: string;
|
||||
inbound_type: string | null;
|
||||
inbound_date: string | null;
|
||||
inbound_status: string | null;
|
||||
inbound_qty: number | string | null;
|
||||
item_name: string | null;
|
||||
item_number: string | null;
|
||||
supplier_name: string | null;
|
||||
unit: string | null;
|
||||
created_date: string | null;
|
||||
inspection_status: string | null;
|
||||
detail_id: number | null;
|
||||
warehouse_name: string | null;
|
||||
reference_number: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Data */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -158,13 +189,47 @@ const INTERNAL_ITEMS: InboundMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const DUMMY_RECENT: RecentInboundItem[] = [
|
||||
{ id: "1", time: "14:32", type: "구매입고", itemName: "STS304 2T 판재", qty: "500 EA", supplier: "(주)한국철강", statusColor: "text-green-600 bg-green-50", statusLabel: "완료" },
|
||||
{ id: "2", time: "13:15", type: "구매입고", itemName: "알루미늄 프로파일 A100", qty: "200 EA", supplier: "(주)대한알루미늄", statusColor: "text-blue-600 bg-blue-50", statusLabel: "검수중" },
|
||||
{ id: "3", time: "11:48", type: "외주입고", itemName: "파이프 조인트 D", qty: "120 EA", supplier: "삼성기공", statusColor: "text-green-600 bg-green-50", statusLabel: "완료" },
|
||||
{ id: "4", time: "10:22", type: "생산입고", itemName: "씰링 패키지 SP-200", qty: "80 SET", supplier: "자체 생산", statusColor: "text-amber-600 bg-amber-50", statusLabel: "진행중" },
|
||||
{ id: "5", time: "09:05", type: "구매입고", itemName: "볼트 M10x30", qty: "1000 EA", supplier: "(주)금강볼트", statusColor: "text-green-600 bg-green-50", statusLabel: "완료" },
|
||||
];
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function getStatusStyle(status: string | null): { color: string; label: string } {
|
||||
switch (status) {
|
||||
case "완료":
|
||||
case "입고완료":
|
||||
return { color: "text-green-600 bg-green-50", label: "완료" };
|
||||
case "검수중":
|
||||
case "검수대기":
|
||||
return { color: "text-blue-600 bg-blue-50", label: "검수중" };
|
||||
case "진행중":
|
||||
case "입고중":
|
||||
return { color: "text-amber-600 bg-amber-50", label: "진행중" };
|
||||
default:
|
||||
return { color: "text-gray-600 bg-gray-50", label: status || "대기" };
|
||||
}
|
||||
}
|
||||
|
||||
function shortenType(type: string | null): string {
|
||||
if (!type) return "기타";
|
||||
if (type.includes("구매")) return "구매입고";
|
||||
if (type.includes("외주")) return "외주입고";
|
||||
if (type.includes("생산")) return "생산입고";
|
||||
if (type.includes("반품")) return "반품입고";
|
||||
if (type.includes("반납")) return "반납입고";
|
||||
return type;
|
||||
}
|
||||
|
||||
function isCompleted(status: string | null): boolean {
|
||||
return status === "완료" || status === "입고완료";
|
||||
}
|
||||
|
||||
function isPurchase(type: string | null): boolean {
|
||||
return !!type && type.includes("구매");
|
||||
}
|
||||
|
||||
function isOutsourcing(type: string | null): boolean {
|
||||
return !!type && type.includes("외주");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
@@ -177,6 +242,15 @@ export function InboundTypeSelect() {
|
||||
const [kpiIdx, setKpiIdx] = useState(0);
|
||||
const kpiTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
/* Data state */
|
||||
const [kpi, setKpi] = useState<KpiData>({
|
||||
todayTotal: 0, todayWaiting: 0, todayCompleted: 0,
|
||||
purchaseCount: 0, outsourcingCount: 0, otherCount: 0,
|
||||
todayQty: 0, todayQtyUnit: "EA", defectCount: 0,
|
||||
});
|
||||
const [recentItems, setRecentItems] = useState<RecentInboundItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const startKpiAuto = useCallback(() => {
|
||||
if (kpiTimerRef.current) clearInterval(kpiTimerRef.current);
|
||||
kpiTimerRef.current = setInterval(() => setKpiIdx((p) => (p + 1) % 3), 4000);
|
||||
@@ -187,6 +261,96 @@ export function InboundTypeSelect() {
|
||||
return () => { if (kpiTimerRef.current) clearInterval(kpiTimerRef.current); };
|
||||
}, [startKpiAuto]);
|
||||
|
||||
/* Fetch real data from API */
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 1) 금일 입고 (KPI용)
|
||||
// 2) 최근 입고 전체 (최근 리스트용)
|
||||
const [todayRes, allRes] = await Promise.all([
|
||||
apiClient.get("/receiving/list", {
|
||||
params: { date_from: today, date_to: today },
|
||||
}),
|
||||
apiClient.get("/receiving/list"),
|
||||
]);
|
||||
|
||||
const todayRows: InboundRow[] = todayRes.data?.data ?? [];
|
||||
const allRows: InboundRow[] = allRes.data?.data ?? [];
|
||||
|
||||
// --- KPI 계산 ---
|
||||
const todayTotal = todayRows.length;
|
||||
const todayCompleted = todayRows.filter((r) => isCompleted(r.inbound_status)).length;
|
||||
const todayWaiting = todayTotal - todayCompleted;
|
||||
const purchaseCount = todayRows.filter((r) => isPurchase(r.inbound_type)).length;
|
||||
const outsourcingCount = todayRows.filter((r) => isOutsourcing(r.inbound_type)).length;
|
||||
const otherCount = todayTotal - purchaseCount - outsourcingCount;
|
||||
const todayQty = todayRows.reduce((sum, r) => {
|
||||
const q = typeof r.inbound_qty === "number" ? r.inbound_qty : Number(r.inbound_qty) || 0;
|
||||
return sum + q;
|
||||
}, 0);
|
||||
const defectCount = todayRows.filter(
|
||||
(r) => r.inspection_status === "불합격" || r.inspection_status === "불량"
|
||||
).length;
|
||||
|
||||
setKpi({
|
||||
todayTotal, todayWaiting, todayCompleted,
|
||||
purchaseCount, outsourcingCount, otherCount,
|
||||
todayQty, todayQtyUnit: "EA", defectCount,
|
||||
});
|
||||
|
||||
// --- 최근 입고 5건 ---
|
||||
const sorted = [...allRows].sort((a, b) => {
|
||||
const da = new Date(a.created_date || "").getTime() || 0;
|
||||
const db = new Date(b.created_date || "").getTime() || 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
// 동일 inbound_number로 그룹핑 (헤더-디테일 JOIN이므로 중복 가능)
|
||||
const seen = new Set<string>();
|
||||
const unique: InboundRow[] = [];
|
||||
for (const row of sorted) {
|
||||
const key = row.inbound_number;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(row);
|
||||
}
|
||||
if (unique.length >= 5) break;
|
||||
}
|
||||
|
||||
const mapped: RecentInboundItem[] = unique.map((row, idx) => {
|
||||
const dateObj = row.created_date ? new Date(row.created_date) : null;
|
||||
const time = dateObj
|
||||
? `${String(dateObj.getHours()).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart(2, "0")}`
|
||||
: "--:--";
|
||||
const qtyNum = typeof row.inbound_qty === "number" ? row.inbound_qty : Number(row.inbound_qty) || 0;
|
||||
const statusInfo = getStatusStyle(row.inbound_status);
|
||||
|
||||
return {
|
||||
id: String(row.id || idx),
|
||||
time,
|
||||
type: shortenType(row.inbound_type),
|
||||
itemName: row.item_name || row.item_number || row.inbound_number || "-",
|
||||
qty: `${qtyNum.toLocaleString()} ${row.unit || "EA"}`,
|
||||
supplier: row.supplier_name || "-",
|
||||
statusColor: statusInfo.color,
|
||||
statusLabel: statusInfo.label,
|
||||
};
|
||||
});
|
||||
|
||||
setRecentItems(mapped);
|
||||
} catch {
|
||||
// 실패 시 0/빈 배열 유지
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = (item: InboundMenuItem) => {
|
||||
if (item.href === "#") {
|
||||
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||
@@ -219,33 +383,33 @@ export function InboundTypeSelect() {
|
||||
className="flex select-none transition-transform duration-400"
|
||||
style={{ transform: `translateX(-${kpiIdx * 100}%)`, transition: "transform 0.4s cubic-bezier(.25,.46,.45,.94)" }}
|
||||
>
|
||||
{/* Slide 1 */}
|
||||
{/* Slide 1 — 금일 입고 현황 */}
|
||||
<div className="min-w-full shrink-0">
|
||||
<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="12" label="금일 입고" color="text-blue-600" />
|
||||
<KpiCell value="3" label="검수 대기" color="text-amber-600" />
|
||||
<KpiCell value="9" label="완료" color="text-green-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.todayTotal.toLocaleString()} label="금일 입고" color="text-blue-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.todayWaiting.toLocaleString()} label="검수 대기" color="text-amber-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.todayCompleted.toLocaleString()} label="완료" color="text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Slide 2 */}
|
||||
{/* Slide 2 — 유형별 건수 */}
|
||||
<div className="min-w-full shrink-0">
|
||||
<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="8" label="구매입고" color="text-blue-600" />
|
||||
<KpiCell value="2" label="외주입고" color="text-purple-600" />
|
||||
<KpiCell value="2" label="기타입고" color="text-gray-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.purchaseCount.toLocaleString()} label="구매입고" color="text-blue-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.outsourcingCount.toLocaleString()} label="외주입고" color="text-purple-600" />
|
||||
<KpiCell value={loading ? "-" : kpi.otherCount.toLocaleString()} label="기타입고" color="text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Slide 3 */}
|
||||
{/* Slide 3 — 수량/품질 */}
|
||||
<div className="min-w-full shrink-0">
|
||||
<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="4,250" label="금일 수량" color="text-blue-600" unit="EA" />
|
||||
<KpiCell value="97.8" label="합격률" color="text-green-600" unit="%" />
|
||||
<KpiCell value="1" label="불량" color="text-red-600" unit="건" />
|
||||
<KpiCell value={loading ? "-" : kpi.todayQty.toLocaleString()} label="금일 수량" color="text-blue-600" unit={kpi.todayQtyUnit} />
|
||||
<KpiCell value={loading ? "-" : (kpi.todayTotal > 0 ? ((1 - kpi.defectCount / kpi.todayTotal) * 100).toFixed(1) : "0")} label="합격률" color="text-green-600" unit="%" />
|
||||
<KpiCell value={loading ? "-" : kpi.defectCount.toLocaleString()} label="불량" color="text-red-600" unit="건" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,33 +464,51 @@ export function InboundTypeSelect() {
|
||||
<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">오늘</span>
|
||||
<span className="text-xs text-gray-400">최근 5건</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{DUMMY_RECENT.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-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>
|
||||
{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 className="text-xs text-gray-400 mt-0.5 truncate">
|
||||
{item.type} | {item.supplier} | {item.qty}
|
||||
))}
|
||||
</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-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.supplier} | {item.qty}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -84,14 +84,27 @@ const DUMMY_ORDERS: PurchaseOrder[] = [
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function PurchaseInbound() {
|
||||
interface PurchaseInboundProps {
|
||||
onCartCountChange?: (count: number) => void;
|
||||
externalCartOpen?: boolean;
|
||||
onExternalCartClose?: () => void;
|
||||
}
|
||||
|
||||
export function PurchaseInbound({ onCartCountChange, externalCartOpen, onExternalCartClose }: PurchaseInboundProps = {}) {
|
||||
const router = useRouter();
|
||||
|
||||
/* State */
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
|
||||
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
||||
const [_cartOpen, _setCartOpen] = useState(false);
|
||||
const cartOpen = externalCartOpen || _cartOpen;
|
||||
const setCartOpen = (v: boolean) => { _setCartOpen(v); if (!v && onExternalCartClose) onExternalCartClose(); };
|
||||
const [cartItems, _setCartItems] = useState<CartItem[]>([]);
|
||||
const setCartItems: typeof _setCartItems = (val) => {
|
||||
_setCartItems(val);
|
||||
const next = typeof val === 'function' ? val(cartItems) : val;
|
||||
onCartCountChange?.(Array.isArray(next) ? next.length : 0);
|
||||
};
|
||||
const [orders, setOrders] = useState<PurchaseOrder[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -323,20 +336,7 @@ export function PurchaseInbound() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cart button */}
|
||||
<button
|
||||
onClick={() => setCartOpen(true)}
|
||||
className="relative w-11 h-11 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-600 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="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
|
||||
</svg>
|
||||
{cartItems.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[20px] h-5 px-1.5 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{cartItems.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{/* Cart button moved to header via headerRight prop */}
|
||||
</div>
|
||||
|
||||
{/* ===== Search area (2 columns on tablet+) ===== */}
|
||||
|
||||
Reference in New Issue
Block a user