feat: POP 시연 준비 — 5개 화면 + 버그 수정 + 자동 창고 매칭
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단 - 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선 - 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거 - 검사관리+입출고관리: 신규 화면 (quality, inventory) - 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user