From 07db35c2e6b49aadd912ab4530e7eb60218e02aa Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 1 Apr 2026 17:41:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=85=EA=B3=A0=20KPI=20=EC=8B=A4?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20+=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=ED=97=A4=EB=8D=94=20=EC=9D=B4=EB=8F=99=20+=20?= =?UTF-8?q?=EA=B1=B0=EB=9E=98=EC=B2=98=20=EB=AA=A8=EB=8B=AC=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 입고유형선택: KPI/최근입고 실데이터 연동 (더미 제거) - 장바구니 아이콘: 본문 → 헤더 프로필 왼쪽으로 이동 - PopShell: headerRight prop 추가 - 거래처: 인라인 드롭다운 제거, 클릭→모달 방식 복원 --- .../app/(pop)/pop/inbound/purchase/page.tsx | 30 +- .../components/pop/hardcoded/PopShell.tsx | 6 +- .../hardcoded/inbound/InboundTypeSelect.tsx | 264 +++++++++++++++--- .../pop/hardcoded/inbound/PurchaseInbound.tsx | 34 +-- 4 files changed, 273 insertions(+), 61 deletions(-) diff --git a/frontend/app/(pop)/pop/inbound/purchase/page.tsx b/frontend/app/(pop)/pop/inbound/purchase/page.tsx index 0fe8a116..de8295d6 100644 --- a/frontend/app/(pop)/pop/inbound/purchase/page.tsx +++ b/frontend/app/(pop)/pop/inbound/purchase/page.tsx @@ -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 ( - - + 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" + > + + + + {cartCount > 0 && ( + + {cartCount} + + )} + + } + > + setOpenCart(false)} + /> ); } diff --git a/frontend/components/pop/hardcoded/PopShell.tsx b/frontend/components/pop/hardcoded/PopShell.tsx index 2573f24f..e99728ed 100644 --- a/frontend/components/pop/hardcoded/PopShell.tsx +++ b/frontend/components/pop/hardcoded/PopShell.tsx @@ -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 )} + {/* Custom header right content (e.g. cart icon) */} + {headerRight} +
{/* Profile */} diff --git a/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx b/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx index f35e60e1..4a828c86 100644 --- a/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx +++ b/frontend/components/pop/hardcoded/inbound/InboundTypeSelect.tsx @@ -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 | null>(null); + /* Data state */ + const [kpi, setKpi] = useState({ + todayTotal: 0, todayWaiting: 0, todayCompleted: 0, + purchaseCount: 0, outsourcingCount: 0, otherCount: 0, + todayQty: 0, todayQtyUnit: "EA", defectCount: 0, + }); + const [recentItems, setRecentItems] = useState([]); + 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(); + 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 — 금일 입고 현황 */}
- - - + + +
- {/* Slide 2 */} + {/* Slide 2 — 유형별 건수 */}
- - - + + +
- {/* Slide 3 */} + {/* Slide 3 — 수량/품질 */}
- - - + + 0 ? ((1 - kpi.defectCount / kpi.todayTotal) * 100).toFixed(1) : "0")} label="합격률" color="text-green-600" unit="%" /> +
@@ -300,33 +464,51 @@ export function InboundTypeSelect() {

최근 입고

- 오늘 + 최근 5건
- {DUMMY_RECENT.map((item) => ( -
- - {item.time} - -
-
- {item.itemName} - - {item.statusLabel} - + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
-
- {item.type} | {item.supplier} | {item.qty} + ))} +
+ ) : recentItems.length === 0 ? ( +
+ 최근 입고 내역이 없습니다 +
+ ) : ( + recentItems.map((item) => ( +
+ + {item.time} + +
+
+ {item.itemName} + + {item.statusLabel} + +
+
+ {item.type} | {item.supplier} | {item.qty} +
-
- ))} + )) + )}
diff --git a/frontend/components/pop/hardcoded/inbound/PurchaseInbound.tsx b/frontend/components/pop/hardcoded/inbound/PurchaseInbound.tsx index dec5e947..c5637fcb 100644 --- a/frontend/components/pop/hardcoded/inbound/PurchaseInbound.tsx +++ b/frontend/components/pop/hardcoded/inbound/PurchaseInbound.tsx @@ -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(null); const [supplierModalOpen, setSupplierModalOpen] = useState(false); - const [cartOpen, setCartOpen] = useState(false); - const [cartItems, setCartItems] = useState([]); + const [_cartOpen, _setCartOpen] = useState(false); + const cartOpen = externalCartOpen || _cartOpen; + const setCartOpen = (v: boolean) => { _setCartOpen(v); if (!v && onExternalCartClose) onExternalCartClose(); }; + const [cartItems, _setCartItems] = useState([]); + 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([]); const [loading, setLoading] = useState(false); const [keyword, setKeyword] = useState(""); @@ -323,20 +336,7 @@ export function PurchaseInbound() {
- {/* Cart button */} - + {/* Cart button moved to header via headerRight prop */}
{/* ===== Search area (2 columns on tablet+) ===== */}