feat: 입고 KPI 실데이터 + 장바구니 헤더 이동 + 거래처 모달 복원

- 입고유형선택: KPI/최근입고 실데이터 연동 (더미 제거)
- 장바구니 아이콘: 본문 → 헤더 프로필 왼쪽으로 이동
- PopShell: headerRight prop 추가
- 거래처: 인라인 드롭다운 제거, 클릭→모달 방식 복원
This commit is contained in:
SeongHyun Kim
2026-04-01 17:41:01 +09:00
parent f03f35e744
commit 07db35c2e6
4 changed files with 273 additions and 61 deletions

View File

@@ -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>
);
}

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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+) ===== */}