552 lines
23 KiB
TypeScript
552 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
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 gap-4">
|
|
{/* Back + Title */}
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => router.push("/pop/inventory")}
|
|
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>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
|
<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-[10px] font-semibold text-gray-400 mb-1 block">품목검색</label>
|
|
<input
|
|
type="text"
|
|
value={keyword}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
placeholder="품목명 / 코드 검색"
|
|
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">창고</label>
|
|
<select
|
|
value={warehouse}
|
|
onChange={(e) => setWarehouse(e.target.value)}
|
|
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm 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 pb-[1px]">
|
|
<button
|
|
onClick={fetchData}
|
|
className="h-[42px] px-4 rounded-lg text-sm font-semibold 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="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
|
|
>
|
|
↻
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI */}
|
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
|
|
<div className="grid grid-cols-4 gap-0">
|
|
<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>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-2 overflow-x-auto">
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => !tab.disabled && setActiveTab(tab.key)}
|
|
disabled={tab.disabled}
|
|
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
|
|
tab.disabled
|
|
? "text-gray-300 bg-gray-50 cursor-not-allowed"
|
|
: activeTab === tab.key
|
|
? "text-white shadow-sm"
|
|
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
|
|
}`}
|
|
style={
|
|
!tab.disabled && activeTab === tab.key
|
|
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
|
|
: undefined
|
|
}
|
|
>
|
|
{tab.label} {tab.count}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center justify-between px-1">
|
|
<span className="text-xs font-semibold text-gray-500">입출고 내역</span>
|
|
<span className="text-xs text-gray-400">총 {filtered.length}건</span>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex flex-col gap-3 py-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 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 className="h-5 w-12 bg-gray-100 rounded-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
|
<svg className="w-16 h-16 mb-4 opacity-20" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
|
</svg>
|
|
<p className="text-sm font-medium text-gray-500 mb-1">입출고 내역이 없습니다</p>
|
|
<p className="text-xs text-gray-400">검색 조건을 변경해보세요</p>
|
|
</div>
|
|
) : (
|
|
filtered.map((item) => (
|
|
<div key={item.id} onClick={() => setSelectedItem(item)} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]">
|
|
<div className="flex items-center gap-3">
|
|
{/* Direction icon */}
|
|
<div
|
|
className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0 ${
|
|
item.direction === "입고" ? "" : ""
|
|
}`}
|
|
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-sm font-bold text-gray-900 truncate">
|
|
{item.itemName}
|
|
{item.itemCode ? ` (${item.itemCode})` : ""}
|
|
</span>
|
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
|
|
{item.statusLabel}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-gray-400 mt-1">
|
|
{item.type} · {item.warehouse}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Qty + Time */}
|
|
<div className="text-right shrink-0">
|
|
<p className="text-base font-bold text-gray-900" style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
{item.qty.toLocaleString()} <span className="text-xs font-normal text-gray-400">{item.unit}</span>
|
|
</p>
|
|
<p className="text-[10px] text-gray-400 mt-0.5">{item.time}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Detail Bottom Sheet */}
|
|
{selectedItem && (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center" onClick={() => setSelectedItem(null)}>
|
|
{/* Overlay */}
|
|
<div className="absolute inset-0 bg-black/40 transition-opacity" />
|
|
{/* Sheet */}
|
|
<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()}
|
|
>
|
|
{/* Handle bar */}
|
|
<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>
|
|
|
|
{/* Header */}
|
|
<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-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-5 py-4 space-y-5">
|
|
{/* Row 1: 전표번호 + 구분 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<DetailField label="전표번호" value={selectedItem.docNumber} />
|
|
<DetailField label="구분" value={selectedItem.type} />
|
|
</div>
|
|
|
|
{/* Row 2: 일시 + 상태 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<DetailField label="일시" value={selectedItem.fullDate} />
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-cyan-600 mb-1">상태</p>
|
|
<span className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${selectedItem.statusColor}`}>
|
|
{selectedItem.statusLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-gray-100" />
|
|
|
|
{/* Row 3: 품목 */}
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-cyan-600 mb-1">품목</p>
|
|
<p className="text-base 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>
|
|
|
|
{/* Row 4: 수량 + LOT */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-cyan-600 mb-1">수량</p>
|
|
<p className="text-xl font-bold text-cyan-600" style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
{selectedItem.qty.toLocaleString()} <span className="text-sm font-normal text-gray-400">{selectedItem.unit}</span>
|
|
</p>
|
|
</div>
|
|
<DetailField label="LOT번호" value={selectedItem.lotNumber || "-"} />
|
|
</div>
|
|
|
|
<div className="border-t border-gray-100" />
|
|
|
|
{/* Row 5: 창고/위치 + 거래처 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold text-cyan-600 mb-1">창고 / 위치</p>
|
|
<p className="text-sm font-bold text-gray-900">{selectedItem.warehouse}</p>
|
|
{selectedItem.locationCode && <p className="text-xs text-gray-400">{selectedItem.locationCode}</p>}
|
|
</div>
|
|
<DetailField label="거래처" value={selectedItem.partnerName} />
|
|
</div>
|
|
|
|
{/* Row 6: 작업자 + 비고 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<DetailField label="작업자" value={selectedItem.writer || "-"} />
|
|
<DetailField label="비고" value={selectedItem.memo || "-"} />
|
|
</div>
|
|
|
|
{/* Row 7: 참조번호 + 금액 (있을 때만) */}
|
|
{(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>
|
|
|
|
{/* Footer */}
|
|
<div className="px-5 py-4 border-t border-gray-100">
|
|
<button
|
|
onClick={() => setSelectedItem(null)}
|
|
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 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-[11px] font-semibold text-cyan-600 mb-1">{label}</p>
|
|
<p className="text-sm 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-2">
|
|
<span className="text-lg mb-0.5">{icon}</span>
|
|
<span
|
|
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
|
|
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
>
|
|
{value}
|
|
</span>
|
|
<span className="text-[10px] font-medium text-gray-400 mt-1">{label}</span>
|
|
</div>
|
|
);
|
|
}
|