Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-09 18:09:19 +09:00
25 changed files with 15614 additions and 10066 deletions
+205 -129
View File
@@ -1,143 +1,219 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import type React from "react";
interface MenuIconItem {
id: string;
title: string;
gradient: string;
shadowColor: string;
icon: React.ReactNode;
href: string;
id: string;
title: string;
gradient: string;
shadowColor: string;
icon: React.ReactNode;
href: string;
}
const MENU_ITEMS: MenuIconItem[] = [
{
id: "incoming",
title: "입고",
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
shadowColor: "rgba(59,130,246,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
href: "/pop/inbound",
},
{
id: "outgoing",
title: "출고",
gradient: "linear-gradient(135deg,#22c55e,#15803d)",
shadowColor: "rgba(34,197,94,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0H6.75m11.25 0h2.625c.621 0 1.125-.504 1.125-1.125v-4.875c0-.621-.504-1.125-1.125-1.125H17.25m-13.5-.375V6.375c0-.621.504-1.125 1.125-1.125h7.5c.621 0 1.125.504 1.125 1.125v7.125" />
</svg>
),
href: "/pop/outbound",
},
{
id: "production",
title: "생산",
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
shadowColor: "rgba(245,158,11,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
href: "/pop/production",
},
{
id: "quality",
title: "품질",
gradient: "linear-gradient(135deg,#ef4444,#b91c1c)",
shadowColor: "rgba(239,68,68,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
href: "/pop/screens/quality",
},
{
id: "equipment",
title: "설비",
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,246,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
href: "/pop/screens/equipment",
},
{
id: "inventory",
title: "재고",
gradient: "linear-gradient(135deg,#06b6d4,#0e7490)",
shadowColor: "rgba(6,182,212,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} 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>
),
href: "/pop/screens/inventory",
},
// 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동
{
id: "safety",
title: "안전관리",
gradient: "linear-gradient(135deg,#f97316,#c2410c)",
shadowColor: "rgba(249,115,22,.3)",
icon: (
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z" />
</svg>
),
href: "/pop/screens/safety",
},
{
id: "incoming",
title: "입고",
gradient: "linear-gradient(135deg,#3b82f6,#1d4ed8)",
shadowColor: "rgba(59,130,246,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
/>
</svg>
),
href: "/pop/inbound",
},
{
id: "outgoing",
title: "출고",
gradient: "linear-gradient(135deg,#22c55e,#15803d)",
shadowColor: "rgba(34,197,94,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0H6.75m11.25 0h2.625c.621 0 1.125-.504 1.125-1.125v-4.875c0-.621-.504-1.125-1.125-1.125H17.25m-13.5-.375V6.375c0-.621.504-1.125 1.125-1.125h7.5c.621 0 1.125.504 1.125 1.125v7.125"
/>
</svg>
),
href: "/pop/outbound",
},
{
id: "production",
title: "생산",
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
shadowColor: "rgba(245,158,11,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
href: "/pop/production",
},
{
id: "quality",
title: "품질",
gradient: "linear-gradient(135deg,#ef4444,#b91c1c)",
shadowColor: "rgba(239,68,68,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
href: "/pop/quality",
},
{
id: "equipment",
title: "설비",
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,246,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
href: "/pop/screens/equipment",
},
{
id: "inventory",
title: "재고",
gradient: "linear-gradient(135deg,#06b6d4,#0e7490)",
shadowColor: "rgba(6,182,212,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
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>
),
href: "/pop/inventory",
},
// 작업지시, 생산실적은 생산관리(/pop/production) 메뉴 안으로 이동
{
id: "safety",
title: "안전관리",
gradient: "linear-gradient(135deg,#f97316,#c2410c)",
shadowColor: "rgba(249,115,22,.3)",
icon: (
<svg
className="w-7 h-7 sm:w-8 sm:h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m0-10.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.57-.598-3.75h-.152c-3.196 0-6.1-1.249-8.25-3.286zm0 13.036h.008v.008H12v-.008z"
/>
</svg>
),
href: "/pop/screens/safety",
},
];
export function MenuIcons() {
const router = useRouter();
const router = useRouter();
const handleClick = (item: MenuIconItem) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(item.href);
}
};
const handleClick = (item: MenuIconItem) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(item.href);
}
};
return (
<section>
<h2 className="text-xl sm:text-[22px] font-bold text-gray-900 tracking-tight mb-4">
</h2>
<div className="flex flex-wrap justify-center gap-x-6 gap-y-5 sm:gap-x-8 sm:gap-y-6 lg:gap-x-10">
{MENU_ITEMS.map((item) => (
<div
key={item.id}
className="flex flex-col items-center gap-2 w-16 sm:w-20 cursor-pointer group"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => handleClick(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-110 group-active:scale-[0.93]"
style={{
background: item.gradient,
boxShadow: `0 4px 12px ${item.shadowColor}`,
}}
>
{item.icon}
</div>
<span className="text-xs sm:text-sm font-semibold text-gray-700">{item.title}</span>
</div>
))}
</div>
</section>
);
return (
<section>
<h2 className="text-xl sm:text-[22px] font-bold text-gray-900 tracking-tight mb-4">
</h2>
<div className="flex flex-wrap justify-center gap-x-6 gap-y-5 sm:gap-x-8 sm:gap-y-6 lg:gap-x-10">
{MENU_ITEMS.map((item) => (
<div
key={item.id}
className="flex flex-col items-center gap-2 w-16 sm:w-20 cursor-pointer group"
style={{ WebkitTapHighlightColor: "transparent" }}
onClick={() => handleClick(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-110 group-active:scale-[0.93]"
style={{
background: item.gradient,
boxShadow: `0 4px 12px ${item.shadowColor}`,
}}
>
{item.icon}
</div>
<span className="text-xs sm:text-sm font-semibold text-gray-700">
{item.title}
</span>
</div>
))}
</div>
</section>
);
}
@@ -0,0 +1,82 @@
"use client";
import React from "react";
export interface ConfirmModalProps {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
onCancel: () => void;
}
/**
* POP 공용 확인 모달 (native confirm() 대체)
* 모바일 친화 디자인, bottom-sheet 스타일
*/
export function ConfirmModal({
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!open) return null;
const confirmBg =
variant === "danger"
? "bg-gradient-to-b from-red-500 to-red-600 hover:from-red-600 hover:to-red-700"
: variant === "success"
? "bg-gradient-to-b from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700"
: "bg-gradient-to-b from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700";
return (
<div className="fixed inset-0 z-[100]" onClick={onCancel}>
{/* Overlay */}
<div className="absolute inset-0 bg-black/50" />
{/* Center modal */}
<div className="absolute inset-0 flex items-center justify-center p-6">
<div
className="w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Body */}
<div className="px-6 py-7 text-center">
{title && (
<h3 className="text-lg font-bold text-gray-900 mb-3">{title}</h3>
)}
<p className="text-base text-gray-700 whitespace-pre-line leading-relaxed">
{message}
</p>
</div>
{/* Buttons */}
<div className="flex border-t border-gray-100">
<button
type="button"
onClick={onCancel}
className="flex-1 py-4 text-base font-semibold text-gray-600 hover:bg-gray-50 active:bg-gray-100 transition-colors"
>
{cancelText}
</button>
<div className="w-px bg-gray-100" />
<button
type="button"
onClick={onConfirm}
className={`flex-1 py-4 text-base font-bold text-white transition-all active:scale-[0.98] ${confirmBg}`}
>
{confirmText}
</button>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -1,7 +1,25 @@
export { PopShell } from "./PopShell";
export {
InboundCart,
InboundTypeSelect,
PurchaseInbound,
SupplierModal,
} from "./inbound";
export { InOutHistory, InventoryHome } from "./inventory";
export { KpiCarousel } from "./KpiCarousel";
export { MenuIcons } from "./MenuIcons";
export {
CustomerModal,
OutboundCartPage,
OutboundTypeSelect,
SalesOutbound,
} from "./outbound";
export { PopShell } from "./PopShell";
export {
AcceptProcessModal,
DefectTypeModal,
ProcessTimer,
ProcessWork,
WorkOrderList,
} from "./production";
export { InspectionList, QualityHome } from "./quality";
export { RecentActivity } from "./RecentActivity";
export { InboundTypeSelect, PurchaseInbound, SupplierModal, InboundCart } from "./inbound";
export { OutboundTypeSelect, SalesOutbound, CustomerModal, OutboundCartPage } from "./outbound";
export { WorkOrderList, ProcessWork, ProcessTimer, DefectTypeModal, AcceptProcessModal } from "./production";
@@ -0,0 +1,348 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface DateRangePickerProps {
from: string; // YYYY-MM-DD
to: string; // YYYY-MM-DD
onChange: (from: string, to: string) => void;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function firstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay(); // 0=Sun
}
function fmt(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function fmtDisplay(dateStr: string): string {
if (!dateStr) return "";
const [y, m, d] = dateStr.split("-");
return `${y}.${m}.${d}`;
}
function isSame(a: string, b: string): boolean {
return a === b;
}
function isBetween(date: string, from: string, to: string): boolean {
return date >= from && date <= to;
}
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function DateRangePicker({ from, to, onChange }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const [selecting, setSelecting] = useState<"from" | "to" | null>(null);
const [tempFrom, setTempFrom] = useState(from);
const [tempTo, setTempTo] = useState(to);
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
const containerRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
if (open) document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const handleOpen = () => {
setTempFrom(from);
setTempTo(to);
setSelecting("from");
const d = from ? new Date(from) : new Date();
setViewYear(d.getFullYear());
setViewMonth(d.getMonth());
setOpen(true);
};
const handleDayClick = (dateStr: string) => {
if (selecting === "from") {
setTempFrom(dateStr);
setTempTo(dateStr); // 같은 날짜 = 당일
setSelecting("to");
} else {
// to 선택
if (dateStr < tempFrom) {
// 시작일보다 이전 선택 → 시작일로 교체
setTempFrom(dateStr);
setTempTo(dateStr);
setSelecting("to");
} else {
setTempTo(dateStr);
onChange(tempFrom, dateStr);
setOpen(false);
setSelecting(null);
}
}
};
const prevMonth = () => {
if (viewMonth === 0) {
setViewYear(viewYear - 1);
setViewMonth(11);
} else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 11) {
setViewYear(viewYear + 1);
setViewMonth(0);
} else setViewMonth(viewMonth + 1);
};
// Quick select presets
const today = fmt(new Date());
const presets = [
{ label: "오늘", from: today, to: today },
{
label: "이번주",
from: fmt(
new Date(
new Date().setDate(new Date().getDate() - new Date().getDay()),
),
),
to: today,
},
{
label: "이번달",
from: `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-01`,
to: today,
},
];
// Display text
const displayText =
from && to
? isSame(from, to)
? fmtDisplay(from)
: `${fmtDisplay(from)} ~ ${fmtDisplay(to)}`
: "기간 선택";
// Build calendar grid
const totalDays = daysInMonth(viewYear, viewMonth);
const startDay = firstDayOfMonth(viewYear, viewMonth);
const cells: (string | null)[] = [];
for (let i = 0; i < startDay; i++) cells.push(null);
for (let d = 1; d <= totalDays; d++) {
const dateStr = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
cells.push(dateStr);
}
return (
<div ref={containerRef} className="relative">
{/* Trigger Button */}
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<button
onClick={handleOpen}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-left focus:outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-100 bg-white flex items-center justify-between gap-2"
>
<span
className={from ? "text-gray-900 font-medium" : "text-gray-400"}
>
{displayText}
</span>
<svg
className="w-4 h-4 text-gray-400 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
</button>
</div>
{/* Calendar Popup */}
{open && (
<div className="absolute left-0 top-full mt-2 z-50 bg-white rounded-2xl shadow-xl border border-gray-200 p-4 w-[320px]">
{/* Header hint */}
<p className="text-[10px] text-center text-gray-400 mb-2">
{selecting === "from"
? "시작일을 선택하세요"
: "종료일을 선택하세요 (같은 날 = 당일)"}
</p>
{/* Quick Presets */}
<div className="flex gap-1.5 mb-3">
{presets.map((p) => (
<button
key={p.label}
onClick={() => {
onChange(p.from, p.to);
setOpen(false);
}}
className="flex-1 py-1.5 rounded-lg text-[11px] font-semibold text-cyan-700 bg-cyan-50 hover:bg-cyan-100 active:scale-95 transition-all"
>
{p.label}
</button>
))}
</div>
{/* Month Navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={prevMonth}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95"
>
<svg
className="w-4 h-4"
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>
<span className="text-sm font-bold text-gray-900">
{viewYear} {MONTH_NAMES[viewMonth]}
</span>
<button
onClick={nextMonth}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:bg-gray-100 active:scale-95"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
</div>
{/* Weekday Headers */}
<div className="grid grid-cols-7 gap-0 mb-1">
{WEEKDAYS.map((d, i) => (
<div
key={d}
className={`text-center text-[10px] font-semibold py-1 ${i === 0 ? "text-red-400" : i === 6 ? "text-blue-400" : "text-gray-400"}`}
>
{d}
</div>
))}
</div>
{/* Day Grid */}
<div className="grid grid-cols-7 gap-0">
{cells.map((dateStr, idx) => {
if (!dateStr)
return <div key={`empty-${idx}`} className="h-10" />;
const day = parseInt(dateStr.split("-")[2], 10);
const dayOfWeek = new Date(dateStr).getDay();
const isStart = isSame(dateStr, tempFrom);
const isEnd = isSame(dateStr, tempTo);
const isInRange =
tempFrom && tempTo && isBetween(dateStr, tempFrom, tempTo);
const isToday = isSame(dateStr, today);
let bgClass = "hover:bg-gray-100";
let textClass =
dayOfWeek === 0
? "text-red-500"
: dayOfWeek === 6
? "text-blue-500"
: "text-gray-700";
if (isStart || isEnd) {
bgClass = "bg-cyan-600 text-white";
textClass = "text-white";
} else if (isInRange) {
bgClass = "bg-cyan-50";
textClass = "text-cyan-700";
}
return (
<button
key={dateStr}
onClick={() => handleDayClick(dateStr)}
className={`h-10 flex items-center justify-center text-sm font-medium rounded-lg transition-all active:scale-90 ${bgClass} ${textClass}`}
>
<span className="relative">
{day}
{isToday && !isStart && !isEnd && (
<span className="absolute -bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-cyan-500" />
)}
</span>
</button>
);
})}
</div>
{/* Selected Range Display */}
{tempFrom && (
<div className="mt-3 pt-3 border-t border-gray-100 text-center">
<span className="text-xs text-gray-500">
{isSame(tempFrom, tempTo)
? fmtDisplay(tempFrom)
: `${fmtDisplay(tempFrom)} ~ ${fmtDisplay(tempTo)}`}
</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,747 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
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>
);
}
@@ -0,0 +1,383 @@
"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: "#",
},
];
/* ------------------------------------------------------------------ */
/* 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>
);
}
@@ -0,0 +1,2 @@
export { InOutHistory } from "./InOutHistory";
export { InventoryHome } from "./InventoryHome";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,745 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useCallback, useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "../inventory/DateRangePicker";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface InspectionRow {
id: string;
inspectionNumber: string;
itemCode: string;
itemName: string;
inspectionType: string;
totalQty: number;
goodQty: number;
badQty: number;
passRate: number;
overallJudgment: string;
defectDescription: string;
referenceTable: string;
referenceId: string;
memo: string;
inspector: string;
supplierCode: string;
supplierName: string;
isCompleted: string;
completedDate: string;
createdDate: string;
time: string;
date: string;
fullDate: string;
}
interface DetailRow {
inspectionItemName: string;
inspectionStandard: string;
passCriteria: string;
measuredValue: string;
judgment: string;
}
interface KpiData {
total: number;
pass: number;
fail: number;
waiting: number;
passRate: number;
}
type TabKey = "all" | "incoming" | "process" | "outgoing";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getJudgmentStyle(judgment: string): { color: string; label: string } {
if (judgment === "합격" || judgment === "pass")
return { color: "text-green-600 bg-green-50", label: "합격" };
if (judgment === "불합격" || judgment === "fail")
return { color: "text-red-600 bg-red-50", label: "불합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
}
function classifyTab(inspectionType: string): TabKey {
if (inspectionType?.includes("입고")) return "incoming";
if (inspectionType?.includes("공정") || inspectionType?.includes("생산"))
return "process";
if (inspectionType?.includes("출하") || inspectionType?.includes("출고"))
return "outgoing";
return "all";
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function InspectionList() {
const router = useRouter();
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 [judgmentFilter, setJudgmentFilter] = useState("전체");
const [items, setItems] = useState<InspectionRow[]>([]);
const [kpi, setKpi] = useState<KpiData>({
total: 0,
pass: 0,
fail: 0,
waiting: 0,
passRate: 0,
});
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const [selectedItem, setSelectedItem] = useState<InspectionRow | null>(null);
const [selectedDetails, setSelectedDetails] = useState<DetailRow[]>([]);
/* Fetch data — 마스터 (inspection_result_mng) */
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.post(
"/table-management/tables/inspection_result_mng/data",
{
page: 1,
pageSize: 500,
},
);
const rows: any[] =
res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const filtered = rows.filter((r: any) => {
const d = (r.inspection_date || r.created_date || "").slice(0, 10);
if (!d) return true;
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
return true;
});
const mapped: InspectionRow[] = filtered.map((r: any, idx: number) => {
const overall = r.overall_judgment || "";
const totalQ = Number(r.total_qty || 0);
const goodQ = Number(r.good_qty || 0);
const passRate = totalQ > 0 ? Math.round((goodQ / totalQ) * 100) : 0;
return {
id: `${r.id || idx}`,
inspectionNumber: r.inspection_number || "",
itemCode: r.item_code || "",
itemName: r.item_name || "-",
inspectionType: r.inspection_type || "",
totalQty: totalQ,
goodQty: goodQ,
badQty: Number(r.bad_qty || 0),
passRate,
overallJudgment: overall,
defectDescription: r.defect_description || "",
referenceTable: r.reference_table || "",
referenceId: r.reference_id || "",
memo: r.memo || "",
inspector: r.inspector || r.writer || "",
supplierCode: r.supplier_code || "",
supplierName: r.supplier_name || "",
isCompleted: r.is_completed || "N",
completedDate: r.completed_date || "",
createdDate: r.created_date || "",
time: r.inspection_date
? new Date(r.inspection_date).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
})
: "--:--",
date: (r.inspection_date || r.created_date || "").slice(0, 10),
fullDate: r.inspection_date
? new Date(r.inspection_date).toLocaleString("ko-KR")
: "-",
};
});
setItems(mapped);
const total = mapped.length;
const pass = mapped.filter((m) => m.overallJudgment === "합격").length;
const fail = mapped.filter((m) => m.overallJudgment === "불합격").length;
const waiting = total - pass - fail;
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
setKpi({ total, pass, fail, waiting, passRate });
} catch {
setItems([]);
setKpi({ total: 0, pass: 0, fail: 0, waiting: 0, passRate: 0 });
} finally {
setLoading(false);
}
}, [dateFrom, dateTo]);
/* Fetch detail when selected */
useEffect(() => {
if (!selectedItem) {
setSelectedDetails([]);
return;
}
apiClient
.post("/table-management/tables/inspection_result/data", {
page: 1,
pageSize: 100,
filters: { master_id: selectedItem.id },
})
.then((res) => {
const rows: any[] = res.data?.data?.data ?? res.data?.data ?? [];
const details: DetailRow[] = rows
.filter((r: any) => r.master_id === selectedItem.id)
.map((r: any) => ({
inspectionItemName: r.inspection_item_name || "-",
inspectionStandard: r.inspection_standard || r.pass_criteria || "-",
passCriteria: r.pass_criteria || "-",
measuredValue: r.measured_value || "-",
judgment: r.judgment || "",
}));
setSelectedDetails(details);
})
.catch(() => setSelectedDetails([]));
}, [selectedItem]);
useEffect(() => {
fetchData();
}, [fetchData]);
/* Filter */
const filtered = items.filter((item) => {
if (activeTab !== "all") {
const tab = classifyTab(item.inspectionType);
if (tab !== activeTab) return false;
}
if (keyword) {
const kw = keyword.toLowerCase();
if (
!item.itemName.toLowerCase().includes(kw) &&
!item.itemCode.toLowerCase().includes(kw)
)
return false;
}
if (judgmentFilter !== "전체") {
const j = item.overallJudgment;
if (judgmentFilter === "합격" && !(j === "합격" || j === "pass"))
return false;
if (judgmentFilter === "불합격" && !(j === "불합격" || j === "fail"))
return false;
if (
judgmentFilter === "대기" &&
(j === "합격" || j === "pass" || j === "불합격" || j === "fail")
)
return false;
}
return true;
});
// 탭별 카운트
const counts = {
all: items.length,
incoming: items.filter((i) => classifyTab(i.inspectionType) === "incoming")
.length,
process: items.filter((i) => classifyTab(i.inspectionType) === "process")
.length,
outgoing: items.filter((i) => classifyTab(i.inspectionType) === "outgoing")
.length,
};
const TABS: { key: TabKey; label: string; count: number }[] = [
{ key: "all", label: "전체", count: counts.all },
{ key: "incoming", label: "입고검사", count: counts.incoming },
{ key: "process", label: "공정검사", count: counts.process },
{ key: "outgoing", label: "출하검사", count: counts.outgoing },
];
return (
<div className="flex flex-col gap-4">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/quality")}
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-violet-400"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<select
value={judgmentFilter}
onChange={(e) => setJudgmentFilter(e.target.value)}
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-violet-400 bg-white"
>
<option value="전체"></option>
<option value="합격"></option>
<option value="불합격"></option>
<option value="대기"></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,#8b5cf6,#6d28d9)" }}
>
</button>
<button
onClick={() => {
setDateFrom(new Date().toISOString().slice(0, 10));
setDateTo(new Date().toISOString().slice(0, 10));
setKeyword("");
setJudgmentFilter("전체");
}}
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-5 gap-0">
<KpiCell
icon="📋"
value={loading ? "-" : kpi.total.toLocaleString()}
label="전체"
color="text-gray-900"
/>
<KpiCell
icon="✅"
value={loading ? "-" : kpi.pass.toLocaleString()}
label="합격"
color="text-green-600"
/>
<KpiCell
icon="❌"
value={loading ? "-" : kpi.fail.toLocaleString()}
label="불합격"
color="text-red-600"
/>
<KpiCell
icon="⏳"
value={loading ? "-" : kpi.waiting.toLocaleString()}
label="대기"
color="text-amber-600"
/>
<KpiCell
icon="📊"
value={loading ? "-" : `${kpi.passRate}%`}
label="합격률"
color="text-blue-600"
/>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
activeTab === tab.key
? "text-white shadow-sm"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
}`}
style={
activeTab === tab.key
? { background: "linear-gradient(135deg,#8b5cf6,#6d28d9)" }
: 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="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</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) => {
const js = getJudgmentStyle(item.overallJudgment);
return (
<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">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0"
style={{
background: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
}}
>
🔍
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[10px] font-semibold text-violet-600">
{item.inspectionNumber}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${js.color}`}
>
{js.label}
</span>
</div>
<div className="text-sm font-bold text-gray-900 truncate mt-0.5">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{item.inspectionType}
{item.supplierName ? ` · ${item.supplierName}` : ""}
</div>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold text-gray-700">
<span className="text-green-600">{item.goodQty}</span>
<span className="text-gray-300 mx-0.5">/</span>
<span className="text-red-600">{item.badQty}</span>
</p>
<p className="text-[10px] text-violet-600 font-semibold mt-0.5">
{item.passRate}%
</p>
<p className="text-[10px] text-gray-400">{item.time}</p>
</div>
</div>
</div>
);
})
)}
</div>
{/* Detail Bottom Sheet */}
{selectedItem && (
<div
className="fixed inset-0 z-50"
onClick={() => setSelectedItem(null)}
>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-lg bg-white rounded-t-3xl shadow-2xl overflow-y-auto z-10"
style={{ maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
<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>
<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.inspectionType} {" "}
{selectedItem.inspectionNumber}
</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>
<div className="px-5 py-4 space-y-5">
<div className="grid grid-cols-2 gap-4">
<DetailField
label="검사번호"
value={selectedItem.inspectionNumber}
/>
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<span className="inline-block text-xs font-bold px-2.5 py-1 rounded-full bg-violet-50 text-violet-700">
{selectedItem.inspectionType}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField label="검사일시" value={selectedItem.fullDate} />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<span
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${getJudgmentStyle(selectedItem.overallJudgment).color}`}
>
{getJudgmentStyle(selectedItem.overallJudgment).label}
</span>
</div>
</div>
<div className="border-t border-gray-100" />
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<p className="text-base font-bold text-gray-900">
{selectedItem.itemName}
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<DetailField
label="거래처"
value={selectedItem.supplierName || "-"}
/>
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<p className="text-lg font-bold text-violet-600">
{selectedItem.passRate}%
</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">
</p>
<p className="text-lg font-bold text-gray-900">
{selectedItem.totalQty.toLocaleString()}
</p>
</div>
<div>
<p className="text-[11px] font-semibold text-green-600 mb-1">
</p>
<p className="text-lg font-bold text-green-600">
{selectedItem.goodQty.toLocaleString()}
</p>
</div>
<div>
<p className="text-[11px] font-semibold text-red-600 mb-1">
</p>
<p className="text-lg font-bold text-red-600">
{selectedItem.badQty.toLocaleString()}
</p>
</div>
</div>
{selectedItem.defectDescription && (
<DetailField
label="불량내용"
value={selectedItem.defectDescription}
/>
)}
<DetailField
label="검사자"
value={selectedItem.inspector || "-"}
/>
{selectedItem.memo && (
<DetailField label="비고" value={selectedItem.memo} />
)}
{/* 검사 항목별 결과 (디테일) */}
{selectedDetails.length > 0 && (
<div>
<p className="text-sm font-bold text-gray-900 mb-2">
</p>
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
{selectedDetails.map((d, idx) => {
const dj = getJudgmentStyle(d.judgment);
return (
<div
key={idx}
className="bg-white rounded-lg p-3 border border-gray-100"
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-bold text-gray-900">
{d.inspectionItemName}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${dj.color}`}
>
{dj.label}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-400"></span>
<p className="text-gray-700">
{d.inspectionStandard}
</p>
</div>
<div>
<span className="text-gray-400"></span>
<p className="text-gray-700 font-semibold">
{d.measuredValue}
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
<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>
)}
</div>
);
}
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] font-semibold text-violet-600 mb-1">{label}</p>
<p className="text-sm font-semibold text-gray-900 break-all">{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>
);
}
@@ -0,0 +1,319 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
itemName: string;
itemCode: string;
inspectionType: string;
judgment: string;
judgmentColor: string;
judgmentLabel: string;
time: string;
}
interface KpiData {
todayTotal: number;
todayPass: number;
todayFail: number;
passRate: number;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getJudgmentStyle(j: string): { color: string; label: string } {
if (j === "합격" || j === "pass")
return { color: "text-green-600 bg-green-50", label: "합격" };
if (j === "불합격" || j === "fail")
return { color: "text-red-600 bg-red-50", label: "불합격" };
return { color: "text-amber-600 bg-amber-50", label: "대기" };
}
const MENU_ITEMS = [
{
id: "inspection",
title: "검사관리",
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,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="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z"
/>
</svg>
),
href: "/pop/quality/inspection",
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function QualityHome() {
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({
todayTotal: 0,
todayPass: 0,
todayFail: 0,
passRate: 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 res = await apiClient.post(
"/table-management/tables/inspection_result_mng/data",
{
page: 1,
pageSize: 500,
},
);
const rows: any[] =
res.data?.data?.data ?? res.data?.data ?? res.data?.rows ?? [];
const todayRows = rows.filter(
(r: any) => (r.created_date || "").slice(0, 10) === today,
);
const total = todayRows.length;
const pass = todayRows.filter(
(r: any) =>
r.overall_judgment === "합격" || r.overall_judgment === "pass",
).length;
const fail = todayRows.filter(
(r: any) =>
r.overall_judgment === "불합격" || r.overall_judgment === "fail",
).length;
const passRate = total > 0 ? Math.round((pass / total) * 100) : 0;
setKpi({
todayTotal: total,
todayPass: pass,
todayFail: fail,
passRate,
});
// 최근 5건
const sorted = [...rows].sort((a: any, b: any) =>
(b.created_date || "").localeCompare(a.created_date || ""),
);
const top5 = sorted.slice(0, 5).map((r: any, idx: number) => {
const js = getJudgmentStyle(r.overall_judgment || r.judgment || "");
return {
id: `${r.id || idx}`,
itemName: r.item_name || "-",
itemCode: r.item_code || "",
inspectionType: r.inspection_type || "",
judgment: r.overall_judgment || "",
judgmentColor: js.color,
judgmentLabel: js.label,
time: r.created_date
? new Date(r.created_date).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
})
: "--:--",
};
});
setRecentItems(top5);
} catch {
// empty
} finally {
setLoading(false);
}
};
fetchData();
}, []);
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-4 gap-0">
<KpiCell
value={loading ? "-" : kpi.todayTotal.toLocaleString()}
label="금일 검사"
color="text-gray-900"
/>
<KpiCell
value={loading ? "-" : kpi.todayPass.toLocaleString()}
label="합격"
color="text-green-600"
/>
<KpiCell
value={loading ? "-" : kpi.todayFail.toLocaleString()}
label="불합격"
color="text-red-600"
/>
<KpiCell
value={loading ? "-" : `${kpi.passRate}%`}
label="합격률"
color="text-violet-600"
/>
</div>
</div>
{/* Menu Icons */}
<section>
<div className="flex items-center gap-2 mb-3">
<div className="w-1 h-5 rounded-full bg-violet-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={() => router.push(item.href)}
>
<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="text-center py-8 text-sm text-gray-400">
...
</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}
{item.itemCode ? ` (${item.itemCode})` : ""}
</span>
<span
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full shrink-0 ${item.judgmentColor}`}
>
{item.judgmentLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5 truncate">
{item.inspectionType}
</div>
</div>
</div>
))
)}
</div>
</div>
</section>
</div>
);
}
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>
);
}
@@ -0,0 +1,2 @@
export { InspectionList } from "./InspectionList";
export { QualityHome } from "./QualityHome";