feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 자동 창고 매칭

- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단
- 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선
- 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거
- 검사관리+입출고관리: 신규 화면 (quality, inventory)
- 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
This commit is contained in:
SeongHyun Kim
2026-04-09 14:38:28 +09:00
parent 1b62dae277
commit 327b4d01c2
25 changed files with 15182 additions and 12185 deletions
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { InOutHistory } from "@/components/pop/hardcoded/inventory";
export default function InOutHistoryPage() {
return (
<PopShell showBanner={false} title="입출고관리">
<InOutHistory />
</PopShell>
);
return (
<PopShell showBanner={false} title="입출고관리">
<InOutHistory />
</PopShell>
);
}
+5 -5
View File
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { InventoryHome } from "@/components/pop/hardcoded/inventory";
export default function InventoryPage() {
return (
<PopShell showBanner={false} title="재고">
<InventoryHome />
</PopShell>
);
return (
<PopShell showBanner={false} title="재고">
<InventoryHome />
</PopShell>
);
}
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { InspectionList } from "@/components/pop/hardcoded/quality";
export default function InspectionListPage() {
return (
<PopShell showBanner={false} title="검사관리">
<InspectionList />
</PopShell>
);
return (
<PopShell showBanner={false} title="검사관리">
<InspectionList />
</PopShell>
);
}
+5 -5
View File
@@ -4,9 +4,9 @@ import { PopShell } from "@/components/pop/hardcoded";
import { QualityHome } from "@/components/pop/hardcoded/quality";
export default function QualityPage() {
return (
<PopShell showBanner={false} title="품질">
<QualityHome />
</PopShell>
);
return (
<PopShell showBanner={false} title="품질">
<QualityHome />
</PopShell>
);
}
+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/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",
},
{
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>
);
}
@@ -3,14 +3,14 @@
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;
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
onCancel: () => void;
}
/**
@@ -18,65 +18,65 @@ export interface ConfirmModalProps {
* 모바일 친화 디자인, bottom-sheet 스타일
*/
export function ConfirmModal({
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
open,
title,
message,
confirmText = "확인",
cancelText = "취소",
variant = "primary",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!open) return null;
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";
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" />
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>
{/* 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>
);
{/* 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 -6
View File
@@ -1,9 +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";
export { InventoryHome, InOutHistory } from "./inventory";
export { QualityHome, InspectionList } from "./quality";
@@ -1,15 +1,15 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
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;
from: string; // YYYY-MM-DD
to: string; // YYYY-MM-DD
onChange: (from: string, to: string) => void;
}
/* ------------------------------------------------------------------ */
@@ -17,234 +17,332 @@ interface DateRangePickerProps {
/* ------------------------------------------------------------------ */
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
return new Date(year, month + 1, 0).getDate();
}
function firstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay(); // 0=Sun
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}`;
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}`;
if (!dateStr) return "";
const [y, m, d] = dateStr.split("-");
return `${y}.${m}.${d}`;
}
function isSame(a: string, b: string): boolean {
return a === b;
return a === b;
}
function isBetween(date: string, from: string, to: string): boolean {
return date >= from && date <= to;
return date >= from && date <= to;
}
const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
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);
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]);
// 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 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 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 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);
};
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 },
];
// 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)}`
: "기간 선택";
// 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);
}
// 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>
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>
{/* 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>
{/* 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>
{/* 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>
{/* 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" />;
{/* 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);
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";
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";
}
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>
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>
);
{/* 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>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -9,40 +9,43 @@ import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
time: string;
direction: "입고" | "출고";
type: string;
itemName: string;
qty: string;
partnerName: string;
statusColor: string;
statusLabel: string;
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;
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 || "대기" };
}
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 || "대기" };
}
}
/* ------------------------------------------------------------------ */
@@ -50,30 +53,50 @@ function getStatusStyle(status: string | null): { color: string; label: string }
/* ------------------------------------------------------------------ */
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: "#",
},
{
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: "#",
},
];
/* ------------------------------------------------------------------ */
@@ -81,205 +104,280 @@ const MENU_ITEMS = [
/* ------------------------------------------------------------------ */
export function InventoryHome() {
const router = useRouter();
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({ todayInbound: 0, todayOutbound: 0, todayTotal: 0 });
const [recentItems, setRecentItems] = useState<RecentItem[]>([]);
const [loading, setLoading] = useState(true);
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);
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 [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 ?? [];
const inRows: any[] = inRes.data?.data ?? [];
const outRows: any[] = outRes.data?.data ?? [];
setKpi({
todayInbound: inRows.length,
todayOutbound: outRows.length,
todayTotal: inRows.length + outRows.length,
});
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);
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);
}
};
setRecentItems(combined);
} catch {
// keep empty
} finally {
setLoading(false);
}
};
fetchData();
}, []);
fetchData();
}, []);
const handleMenuClick = (item: (typeof MENU_ITEMS)[number]) => {
if (item.href === "#") {
alert(`${item.title} 화면은 준비 중입니다.`);
} else {
router.push(item.href);
}
};
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>
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>
{/* 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>
{/* 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>
);
{/* 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>
);
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>
);
}
@@ -1,2 +1,2 @@
export { InventoryHome } from "./InventoryHome";
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
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
@@ -9,21 +9,21 @@ import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
interface RecentItem {
id: string;
itemName: string;
itemCode: string;
inspectionType: string;
judgment: string;
judgmentColor: string;
judgmentLabel: string;
time: string;
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;
todayTotal: number;
todayPass: number;
todayFail: number;
passRate: number;
}
/* ------------------------------------------------------------------ */
@@ -31,24 +31,36 @@ interface KpiData {
/* ------------------------------------------------------------------ */
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: "대기" };
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",
},
{
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",
},
];
/* ------------------------------------------------------------------ */
@@ -56,164 +68,252 @@ const MENU_ITEMS = [
/* ------------------------------------------------------------------ */
export function QualityHome() {
const router = useRouter();
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);
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);
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 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 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;
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 });
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();
}, []);
// 최근 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>
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>
{/* 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>
{/* 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>
);
{/* 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>
);
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>
);
}
@@ -1,2 +1,2 @@
export { QualityHome } from "./QualityHome";
export { InspectionList } from "./InspectionList";
export { QualityHome } from "./QualityHome";
+341 -290
View File
@@ -23,362 +23,413 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { dataApi } from "@/lib/api/data";
import type {
CartItem,
CartItemWithId,
CartSyncStatus,
CartItemStatus,
CartItem,
CartItemStatus,
CartItemWithId,
CartSyncStatus,
} from "@/lib/registry/pop-components/types";
// ===== 반환 타입 =====
export interface CartChanges {
toCreate: Record<string, unknown>[];
toUpdate: Record<string, unknown>[];
toDelete: (string | number)[];
toCreate: Record<string, unknown>[];
toUpdate: Record<string, unknown>[];
toDelete: (string | number)[];
}
export interface UseCartSyncReturn {
cartItems: CartItemWithId[];
savedItems: CartItemWithId[];
syncStatus: CartSyncStatus;
cartCount: number;
isDirty: boolean;
loading: boolean;
cartItems: CartItemWithId[];
savedItems: CartItemWithId[];
syncStatus: CartSyncStatus;
cartCount: number;
isDirty: boolean;
loading: boolean;
addItem: (item: CartItem, rowKey: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void;
updateItemRow: (rowKey: string, partialRow: Record<string, unknown>) => void;
isItemInCart: (rowKey: string) => boolean;
getCartItem: (rowKey: string) => CartItemWithId | undefined;
addItem: (item: CartItem, rowKey: string) => void;
removeItem: (rowKey: string) => void;
updateItemQuantity: (
rowKey: string,
quantity: number,
packageUnit?: string,
packageEntries?: CartItem["packageEntries"],
) => void;
updateItemRow: (rowKey: string, partialRow: Record<string, unknown>) => void;
isItemInCart: (rowKey: string) => boolean;
getCartItem: (rowKey: string) => CartItemWithId | undefined;
getChanges: (selectedColumns?: string[]) => CartChanges;
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
loadFromDb: () => Promise<void>;
resetToSaved: () => void;
getChanges: (selectedColumns?: string[]) => CartChanges;
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
loadFromDb: () => Promise<void>;
resetToSaved: () => void;
}
// ===== DB 행 -> CartItemWithId 변환 =====
function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
let rowData: Record<string, unknown> = {};
try {
const raw = dbRow.row_data;
if (typeof raw === "string" && raw.trim()) {
rowData = JSON.parse(raw);
} else if (typeof raw === "object" && raw !== null) {
rowData = raw as Record<string, unknown>;
}
} catch {
rowData = {};
}
let rowData: Record<string, unknown> = {};
try {
const raw = dbRow.row_data;
if (typeof raw === "string" && raw.trim()) {
rowData = JSON.parse(raw);
} else if (typeof raw === "object" && raw !== null) {
rowData = raw as Record<string, unknown>;
}
} catch {
rowData = {};
}
let packageEntries: CartItem["packageEntries"] | undefined;
try {
const raw = dbRow.package_entries;
if (typeof raw === "string" && raw.trim()) {
packageEntries = JSON.parse(raw);
} else if (Array.isArray(raw)) {
packageEntries = raw;
}
} catch {
packageEntries = undefined;
}
let packageEntries: CartItem["packageEntries"] | undefined;
try {
const raw = dbRow.package_entries;
if (typeof raw === "string" && raw.trim()) {
packageEntries = JSON.parse(raw);
} else if (Array.isArray(raw)) {
packageEntries = raw;
}
} catch {
packageEntries = undefined;
}
return {
row: rowData,
quantity: Number(dbRow.quantity) || 0,
packageUnit: (dbRow.package_unit as string) || undefined,
packageEntries,
cartId: (dbRow.id as string) || undefined,
sourceTable: (dbRow.source_table as string) || "",
rowKey: (dbRow.row_key as string) || "",
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
_origin: "db",
memo: (dbRow.memo as string) || undefined,
};
return {
row: rowData,
quantity: Number(dbRow.quantity) || 0,
packageUnit: (dbRow.package_unit as string) || undefined,
packageEntries,
cartId: (dbRow.id as string) || undefined,
sourceTable: (dbRow.source_table as string) || "",
rowKey: (dbRow.row_key as string) || "",
status: ((dbRow.status as string) || "in_cart") as CartItemStatus,
_origin: "db",
memo: (dbRow.memo as string) || undefined,
};
}
// ===== CartItemWithId -> DB 저장용 레코드 변환 =====
function cartItemToDbRecord(
item: CartItemWithId,
screenId: string,
selectedColumns?: string[],
item: CartItemWithId,
screenId: string,
selectedColumns?: string[],
): Record<string, unknown> {
const rowData =
selectedColumns && selectedColumns.length > 0
? Object.fromEntries(
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
)
: item.row;
const rowData =
selectedColumns && selectedColumns.length > 0
? Object.fromEntries(
Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)),
)
: item.row;
return {
cart_type: "pop",
screen_id: screenId,
source_table: item.sourceTable,
row_key: item.rowKey,
row_data: JSON.stringify(rowData),
quantity: String(item.quantity),
unit: "",
package_unit: item.packageUnit || "",
package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "",
status: item.status,
memo: item.memo || "",
};
return {
cart_type: "pop",
screen_id: screenId,
source_table: item.sourceTable,
row_key: item.rowKey,
row_data: JSON.stringify(rowData),
quantity: String(item.quantity),
unit: "",
package_unit: item.packageUnit || "",
package_entries: item.packageEntries
? JSON.stringify(item.packageEntries)
: "",
status: item.status,
memo: item.memo || "",
};
}
// ===== dirty check: 두 배열의 내용이 동일한지 비교 =====
function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
if (a.length !== b.length) return false;
if (a.length !== b.length) return false;
const serialize = (items: CartItemWithId[]) =>
items
.map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`)
.sort()
.join("|");
const serialize = (items: CartItemWithId[]) =>
items
.map(
(item) =>
`${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}:${JSON.stringify(item.row)}`,
)
.sort()
.join("|");
return serialize(a) === serialize(b);
return serialize(a) === serialize(b);
}
// ===== 훅 본체 =====
export function useCartSync(
screenId: string,
sourceTable: string,
screenId: string,
sourceTable: string,
): UseCartSyncReturn {
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
const [loading, setLoading] = useState(false);
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("clean");
const [loading, setLoading] = useState(false);
const screenIdRef = useRef(screenId);
const sourceTableRef = useRef(sourceTable);
screenIdRef.current = screenId;
sourceTableRef.current = sourceTable;
const screenIdRef = useRef(screenId);
const sourceTableRef = useRef(sourceTable);
screenIdRef.current = screenId;
sourceTableRef.current = sourceTable;
// ----- DB에서 장바구니 로드 -----
const loadFromDb = useCallback(async () => {
if (!screenId || !sourceTable) return;
setLoading(true);
try {
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters: {
screen_id: screenId,
cart_type: "pop",
status: "in_cart",
},
});
// ----- DB에서 장바구니 로드 -----
const loadFromDb = useCallback(async () => {
if (!screenId || !sourceTable) return;
setLoading(true);
try {
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters: {
screen_id: screenId,
cart_type: "pop",
status: "in_cart",
},
});
const items = (result.data || []).map(dbRowToCartItem);
setSavedItems(items);
setCartItems(items);
setSyncStatus("clean");
} catch (err) {
console.error("[useCartSync] DB 로드 실패:", err);
} finally {
setLoading(false);
}
}, [screenId, sourceTable]);
const items = (result.data || []).map(dbRowToCartItem);
setSavedItems(items);
setCartItems(items);
setSyncStatus("clean");
} catch (err) {
console.error("[useCartSync] DB 로드 실패:", err);
} finally {
setLoading(false);
}
}, [screenId, sourceTable]);
// 마운트 시 자동 로드
useEffect(() => {
loadFromDb();
}, [loadFromDb]);
// 마운트 시 자동 로드
useEffect(() => {
loadFromDb();
}, [loadFromDb]);
// ----- dirty 상태 계산 -----
const isDirty = !areItemsEqual(cartItems, savedItems);
// ----- dirty 상태 계산 -----
const isDirty = !areItemsEqual(cartItems, savedItems);
// isDirty 변경 시 syncStatus 자동 갱신
useEffect(() => {
if (syncStatus !== "saving") {
setSyncStatus(isDirty ? "dirty" : "clean");
}
}, [isDirty, syncStatus]);
// isDirty 변경 시 syncStatus 자동 갱신
useEffect(() => {
if (syncStatus !== "saving") {
setSyncStatus(isDirty ? "dirty" : "clean");
}
}, [isDirty, syncStatus]);
// ----- 로컬 조작 (DB 미반영) -----
// ----- 로컬 조작 (DB 미반영) -----
const addItem = useCallback(
(item: CartItem, rowKey: string) => {
setCartItems((prev) => {
const exists = prev.find((i) => i.rowKey === rowKey);
if (exists) {
return prev.map((i) =>
i.rowKey === rowKey
? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row }
: i,
);
}
const newItem: CartItemWithId = {
...item,
cartId: undefined,
sourceTable: sourceTableRef.current,
rowKey,
status: "in_cart",
_origin: "local",
};
return [...prev, newItem];
});
},
[],
);
const addItem = useCallback((item: CartItem, rowKey: string) => {
setCartItems((prev) => {
const exists = prev.find((i) => i.rowKey === rowKey);
if (exists) {
return prev.map((i) =>
i.rowKey === rowKey
? {
...i,
quantity: item.quantity,
packageUnit: item.packageUnit,
packageEntries: item.packageEntries,
row: item.row,
}
: i,
);
}
const newItem: CartItemWithId = {
...item,
cartId: undefined,
sourceTable: sourceTableRef.current,
rowKey,
status: "in_cart",
_origin: "local",
};
return [...prev, newItem];
});
}, []);
const removeItem = useCallback((rowKey: string) => {
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
}, []);
const removeItem = useCallback((rowKey: string) => {
setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey));
}, []);
const updateItemQuantity = useCallback(
(rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? {
...i,
quantity,
...(packageUnit !== undefined && { packageUnit }),
...(packageEntries !== undefined && { packageEntries }),
}
: i,
),
);
},
[],
);
const updateItemQuantity = useCallback(
(
rowKey: string,
quantity: number,
packageUnit?: string,
packageEntries?: CartItem["packageEntries"],
) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? {
...i,
quantity,
...(packageUnit !== undefined && { packageUnit }),
...(packageEntries !== undefined && { packageEntries }),
}
: i,
),
);
},
[],
);
// row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult)
const updateItemRow = useCallback(
(rowKey: string, partialRow: Record<string, unknown>) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey
? { ...i, row: { ...i.row, ...partialRow } }
: i,
),
);
},
[],
);
// row 객체에 임의 필드를 부분 업데이트 (예: inspectionResult)
const updateItemRow = useCallback(
(rowKey: string, partialRow: Record<string, unknown>) => {
setCartItems((prev) =>
prev.map((i) =>
i.rowKey === rowKey ? { ...i, row: { ...i.row, ...partialRow } } : i,
),
);
},
[],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
);
const isItemInCart = useCallback(
(rowKey: string) => cartItems.some((i) => i.rowKey === rowKey),
[cartItems],
);
const getCartItem = useCallback(
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
[cartItems],
);
const getCartItem = useCallback(
(rowKey: string) => cartItems.find((i) => i.rowKey === rowKey),
[cartItems],
);
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
// ----- diff 계산 (백엔드 전송용) -----
const getChanges = useCallback(
(selectedColumns?: string[]): CartChanges => {
const currentScreenId = screenIdRef.current;
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
const toCreateItems = cartItems.filter((c) => !c.cartId);
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDeleteItems = savedItems.filter(
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
);
const toCreateItems = cartItems.filter((c) => !c.cartId);
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdateItems = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
// row JSON 비교 (검사 결과 등 포함)
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status || rowChanged;
});
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdateItems = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
// row JSON 비교 (검사 결과 등 포함)
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status ||
rowChanged
);
});
return {
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
toDelete: toDeleteItems.map((item) => item.cartId!),
};
}, [cartItems, savedItems]);
return {
toCreate: toCreateItems.map((item) =>
cartItemToDbRecord(item, currentScreenId, selectedColumns),
),
toUpdate: toUpdateItems.map((item) => ({
id: item.cartId,
...cartItemToDbRecord(item, currentScreenId, selectedColumns),
})),
toDelete: toDeleteItems.map((item) => item.cartId!),
};
},
[cartItems, savedItems],
);
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// ----- DB 저장 (일괄) -----
const saveToDb = useCallback(
async (selectedColumns?: string[]): Promise<boolean> => {
setSyncStatus("saving");
try {
const currentScreenId = screenIdRef.current;
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
// 삭제 대상: savedItems에 있지만 cartItems에 없는 것
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
const toDelete = savedItems.filter(
(s) => s.cartId && !cartRowKeys.has(s.rowKey),
);
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
// 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨)
const toCreate = cartItems.filter((c) => !c.cartId);
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdate = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
const rowChanged = JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status ||
rowChanged
);
});
// 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
const toUpdate = cartItems.filter((c) => {
if (!c.cartId) return false;
const saved = savedMap.get(c.rowKey);
if (!saved) return false;
const rowChanged =
JSON.stringify(c.row) !== JSON.stringify(saved.row);
return (
c.quantity !== saved.quantity ||
c.packageUnit !== saved.packageUnit ||
c.status !== saved.status ||
rowChanged
);
});
const promises: Promise<unknown>[] = [];
const promises: Promise<unknown>[] = [];
for (const item of toDelete) {
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
}
for (const item of toDelete) {
promises.push(
dataApi.updateRecord("cart_items", item.cartId!, {
status: "cancelled",
}),
);
}
for (const item of toCreate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
const recordWithId = { id: crypto.randomUUID(), ...record };
promises.push(dataApi.createRecord("cart_items", recordWithId));
}
for (const item of toCreate) {
const record = cartItemToDbRecord(
item,
currentScreenId,
selectedColumns,
);
// cart_items.id는 NOT NULL + 자동생성 없음 → UUID 직접 생성
const recordWithId = { id: crypto.randomUUID(), ...record };
promises.push(dataApi.createRecord("cart_items", recordWithId));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
}
for (const item of toUpdate) {
const record = cartItemToDbRecord(
item,
currentScreenId,
selectedColumns,
);
promises.push(
dataApi.updateRecord("cart_items", item.cartId!, record),
);
}
await Promise.all(promises);
await Promise.all(promises);
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
await loadFromDb();
return true;
} catch (err) {
console.error("[useCartSync] DB 저장 실패:", err);
setSyncStatus("dirty");
return false;
}
}, [cartItems, savedItems, loadFromDb]);
// 저장 후 DB에서 다시 로드하여 cartId 등을 최신화
await loadFromDb();
return true;
} catch (err) {
console.error("[useCartSync] DB 저장 실패:", err);
setSyncStatus("dirty");
return false;
}
},
[cartItems, savedItems, loadFromDb],
);
// ----- 로컬 변경 취소 -----
const resetToSaved = useCallback(() => {
setCartItems(savedItems);
setSyncStatus("clean");
}, [savedItems]);
// ----- 로컬 변경 취소 -----
const resetToSaved = useCallback(() => {
setCartItems(savedItems);
setSyncStatus("clean");
}, [savedItems]);
return {
cartItems,
savedItems,
syncStatus,
cartCount: cartItems.length,
isDirty,
loading,
addItem,
removeItem,
updateItemQuantity,
updateItemRow,
isItemInCart,
getCartItem,
getChanges,
saveToDb,
loadFromDb,
resetToSaved,
};
return {
cartItems,
savedItems,
syncStatus,
cartCount: cartItems.length,
isDirty,
loading,
addItem,
removeItem,
updateItemQuantity,
updateItemRow,
isItemInCart,
getCartItem,
getChanges,
saveToDb,
loadFromDb,
resetToSaved,
};
}