From c01166263bacf6604ca92239ab0ff9e0e7d1328b Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 24 Apr 2026 09:29:41 +0900 Subject: [PATCH 1/2] feat: Implement sorting functionality in inspection management page - Added sorting capabilities for equipment listings, allowing users to sort by various columns such as equipment code, name, type, and more. - Introduced visual indicators for sort direction using icons (ArrowUp, ArrowDown, ArrowUpDown). - Enhanced filtering logic to accommodate sorting, improving data retrieval and user experience. These changes aim to provide users with better control over equipment data presentation and enhance overall usability in the inspection management workflow. --- .../COMPANY_10/quality/inspection/page.tsx | 100 +++++++++++++----- .../COMPANY_16/quality/inspection/page.tsx | 100 +++++++++++++----- .../COMPANY_29/quality/inspection/page.tsx | 100 +++++++++++++----- .../COMPANY_30/quality/inspection/page.tsx | 100 +++++++++++++----- .../COMPANY_7/quality/inspection/page.tsx | 100 +++++++++++++----- .../COMPANY_8/quality/inspection/page.tsx | 100 +++++++++++++----- .../COMPANY_9/quality/inspection/page.tsx | 100 +++++++++++++----- 7 files changed, 511 insertions(+), 189 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx index 8b93fa89..dadb4a2f 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx index c0a7b6b9..12f837fb 100644 --- a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} diff --git a/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx index 8b93fa89..dadb4a2f 100644 --- a/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} diff --git a/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx index 53f0e142..ced73cbe 100644 --- a/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} diff --git a/frontend/app/(main)/COMPANY_7/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_7/quality/inspection/page.tsx index 8b93fa89..dadb4a2f 100644 --- a/frontend/app/(main)/COMPANY_7/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_7/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} diff --git a/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx index 8b93fa89..dadb4a2f 100644 --- a/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_8/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} diff --git a/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx index 3edaadc3..83a712d5 100644 --- a/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_9/quality/inspection/page.tsx @@ -30,6 +30,9 @@ import { Inbox, Settings2, Upload, + ArrowUpDown, + ArrowUp, + ArrowDown, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { ImageUpload } from "@/components/common/ImageUpload"; @@ -113,6 +116,8 @@ export default function InspectionManagementPage() { const [eqForm, setEqForm] = useState>({}); const [eqSaving, setEqSaving] = useState(false); const [eqKeyword, setEqKeyword] = useState(""); + const [eqSortKey, setEqSortKey] = useState(null); + const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc"); /* ───── 채번 ───── */ const [numberingRuleId, setNumberingRuleId] = useState(null); @@ -288,13 +293,54 @@ export default function InspectionManagementPage() { ) : defects; - const filteredEquipments = eqKeyword.trim() - ? equipments.filter( - (r) => - (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || - (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), - ) - : equipments; + const filteredEquipments = useMemo(() => { + const base = eqKeyword.trim() + ? equipments.filter( + (r) => + (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || + (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()), + ) + : equipments; + if (!eqSortKey) return base; + const key = eqSortKey; + const dir = eqSortDir; + return [...base].sort((a, b) => { + const av = a[key]; + const bv = b[key]; + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na; + const sa = String(av).toLowerCase(); + const sb = String(bv).toLowerCase(); + return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa); + }); + }, [equipments, eqKeyword, eqSortKey, eqSortDir]); + + const handleEqSort = useCallback((key: string) => { + setEqSortKey((prev) => { + if (prev === key) { + setEqSortDir((d) => (d === "asc" ? "desc" : "asc")); + return prev; + } + setEqSortDir("asc"); + return key; + }); + }, []); + + const renderEqSortIcon = (key: string) => { + if (eqSortKey !== key) + return ; + return eqSortDir === "asc" ? ( + + ) : ( + + ); + }; /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ const openInspCreate = async () => { @@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() { 이미지 - - 장비코드 + handleEqSort("equipment_code")}> + 장비코드{renderEqSortIcon("equipment_code")} - - 장비명 + handleEqSort("equipment_name")}> + 장비명{renderEqSortIcon("equipment_name")} - - 장비유형 + handleEqSort("equipment_type")}> + 장비유형{renderEqSortIcon("equipment_type")} - - 모델명 + handleEqSort("model_name")}> + 모델명{renderEqSortIcon("model_name")} - - 제조사 + handleEqSort("manufacturer")}> + 제조사{renderEqSortIcon("manufacturer")} - - 설치장소 + handleEqSort("installation_location")}> + 설치장소{renderEqSortIcon("installation_location")} - - 최근교정일 + handleEqSort("last_calibration_date")}> + 최근교정일{renderEqSortIcon("last_calibration_date")} - - 교정주기(개월) + handleEqSort("calibration_period")}> + 교정주기(개월){renderEqSortIcon("calibration_period")} - - 장비상태 + handleEqSort("equipment_status")}> + 장비상태{renderEqSortIcon("equipment_status")} - - 담당자 + handleEqSort("manager_id")}> + 담당자{renderEqSortIcon("manager_id")} From 37ca354af9f8f0367bfe50866fba6dde42d69539 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 24 Apr 2026 11:12:32 +0900 Subject: [PATCH 2/2] feat: Implement cutting plan management and work instruction modal - Introduced a new cutting plan management page for COMPANY_9, allowing users to manage cutting plans effectively. - Added a Work Instruction Apply Modal to facilitate the application of work instructions linked to cutting plans. - Enhanced data handling by incorporating additional fields such as condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, and condition_plc_data in relevant controllers and database interactions. - Updated UI components to support new features, including displaying batch numbers and item sizes in the work instruction page. These changes aim to improve the efficiency and usability of cutting plan and work instruction management processes. --- .../controllers/materialStatusController.ts | 17 +- .../processWorkStandardController.ts | 46 +- .../controllers/workInstructionController.ts | 20 +- .../quality/item-inspection/page.tsx | 10 +- .../WorkInstructionApplyModal.tsx | 376 +++ .../production/cutting-plan/page.tsx | 2785 +++++++++++++++++ .../production/work-instruction/page.tsx | 9 +- .../components/layout/AdminPageRenderer.tsx | 2 + 8 files changed, 3240 insertions(+), 25 deletions(-) create mode 100644 frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx create mode 100644 frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts index d522997b..72116277 100644 --- a/backend-node/src/controllers/materialStatusController.ts +++ b/backend-node/src/controllers/materialStatusController.ts @@ -163,24 +163,29 @@ export async function getMaterialStatus( bomParams.push(companyCode); } + // inventory_unit은 카테고리 코드(CAT_xxx)로 저장됨 → category_values 조인으로 라벨 해상 const bomQuery = ` SELECT b.item_code AS parent_item_code, b.base_qty AS bom_base_qty, bd.child_item_id, bd.quantity AS bom_qty, - bd.unit AS bom_unit, bd.loss_rate, ii.item_name AS material_name, ii.item_number AS material_code, - ii.unit AS material_unit, - ii.inventory_unit AS material_inventory_unit, + COALESCE(cv_inv.value_label, ii.inventory_unit) AS material_inventory_unit, COALESCE(ii.width::text, '') AS material_width, COALESCE(ii.height::text, '') AS material_height, COALESCE(ii.thickness::text, '') AS material_thickness FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code + LEFT JOIN category_values cv_inv + ON cv_inv.table_name = 'item_info' + AND cv_inv.column_name = 'inventory_unit' + AND cv_inv.value_code = ii.inventory_unit + AND cv_inv.company_code = ii.company_code + AND cv_inv.is_active = true WHERE b.item_code IN (${itemPlaceholders}) ${bomCompanyCondition} ORDER BY b.item_code, bd.seq_no @@ -221,11 +226,7 @@ export async function getMaterialStatus( materialCode: bomRow.material_code || bomRow.child_item_id, materialName: bomRow.material_name || "알 수 없음", - unit: - bomRow.material_inventory_unit || - bomRow.bom_unit || - bomRow.material_unit || - "EA", + unit: bomRow.material_inventory_unit || "", requiredQty, width: bomRow.material_width || "", height: bomRow.material_height || "", diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index 622d8f89..19152d4d 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -463,7 +463,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, - selected_bom_items, process_inspection_apply, equip_inspection_apply, created_date + selected_bom_items, process_inspection_apply, equip_inspection_apply, + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data, + created_date FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order, created_date @@ -493,6 +496,9 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, selected_bom_items, process_inspection_apply, equip_inspection_apply, + // 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015 + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data, } = req.body; if (!work_item_id || !content) { @@ -516,8 +522,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, selected_bom_items, - process_inspection_apply, equip_inspection_apply) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + process_inspection_apply, equip_inspection_apply, + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25) RETURNING * `; @@ -545,6 +554,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo bomItemsJson, process_inspection_apply || null, equip_inspection_apply || null, + condition_unit || null, + condition_base_value || null, + condition_tolerance || null, + condition_auto_collect || null, + condition_plc_data || null, ]); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); @@ -571,6 +585,9 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, selected_bom_items, process_inspection_apply, equip_inspection_apply, + // 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015 + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data, } = req.body; const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null; @@ -594,6 +611,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo selected_bom_items = $17, process_inspection_apply = $18, equip_inspection_apply = $19, + condition_unit = $20, + condition_base_value = $21, + condition_tolerance = $22, + condition_auto_collect = $23, + condition_plc_data = $24, updated_date = NOW() WHERE id = $6 AND company_code = $7 RETURNING * @@ -619,6 +641,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo bomItemsJson, process_inspection_apply || null, equip_inspection_apply || null, + condition_unit ?? null, + condition_base_value ?? null, + condition_tolerance ?? null, + condition_auto_collect ?? null, + condition_plc_data ?? null, ]); if (result.rowCount === 0) { @@ -733,8 +760,11 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { `INSERT INTO process_work_item_detail (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, inspection_code, inspection_method, unit, lower_limit, upper_limit, - duration_minutes, input_type, lookup_target, display_fields) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + duration_minutes, input_type, lookup_target, display_fields, + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, + $18, $19, $20, $21, $22)`, [ companyCode, workItemId, @@ -753,6 +783,12 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { detail.input_type || null, detail.lookup_target || null, detail.display_fields || null, + // 설비조건(equip_condition) 전용 5개 — TASK:ERP-015 + detail.condition_unit || null, + detail.condition_base_value || null, + detail.condition_tolerance || null, + detail.condition_auto_collect || null, + detail.condition_plc_data || null, ] ); } diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 5611689d..2da91a0e 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -665,7 +665,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) `SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, - process_inspection_apply, equip_inspection_apply + process_inspection_apply, equip_inspection_apply, + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, @@ -690,7 +692,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) `SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, - process_inspection_apply, equip_inspection_apply + process_inspection_apply, equip_inspection_apply, + condition_unit, condition_base_value, condition_tolerance, + condition_auto_collect, condition_plc_data FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order`, @@ -771,9 +775,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) for (const origDetail of origDetails.rows) { await client.query( - `INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, - [companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, userId] + `INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, + [companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, userId] ); } } @@ -838,9 +842,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) if (wi.details && Array.isArray(wi.details)) { for (const d of wi.details) { await client.query( - `INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`, - [companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, userId] + `INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`, + [companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, userId] ); } } diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index 9e0a914f..6bdf9f93 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -74,7 +74,7 @@ export default function ItemInspectionInfoPage() { const [saving, setSaving] = useState(false); // FK 옵션 - const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); + const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string; size: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -136,6 +136,7 @@ export default function ItemInspectionInfoPage() { name: r.item_name || "", item_type: r.type || r.item_type || "", unit: r.inventory_unit || "", + size: r.size || "", }))); const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || []; @@ -244,7 +245,7 @@ export default function ItemInspectionInfoPage() { const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; const cm = itemCatMapRef.current; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "", size: r.size || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; @@ -296,6 +297,7 @@ export default function ItemInspectionInfoPage() { name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "", + size: r.size || "", })); setCopyFilteredItems(list); setCopyTotal(resData?.total || resData?.totalCount || rows.length); @@ -1244,17 +1246,19 @@ export default function ItemInspectionInfoPage() { 품목코드 품목명 + 규격 품목유형 단위 {filteredItems.length === 0 ? ( - {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} + {itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"} ) : filteredItems.map((item) => ( selectItem(item)}> {item.code} {item.name} + {item.size} {item.item_type} {item.unit} diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx new file mode 100644 index 00000000..b4a70af2 --- /dev/null +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/WorkInstructionApplyModal.tsx @@ -0,0 +1,376 @@ +"use client"; + +/** + * 절단계획 → 작업지시 적용 모달 + * jskim-node의 작업지시 모달 구조와 호환 (품목별 일정/설비/작업조/작업자 지정). + * 저장 시 마스터에 batch_no(=plan_no) / cutting_plan_id 를 함께 전달. + */ + +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { CheckCircle2, ChevronsUpDown, Loader2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from "@/components/ui/select"; +import { + Popover, PopoverContent, PopoverTrigger, +} from "@/components/ui/popover"; +import { + Table, TableHeader, TableRow, TableHead, TableBody, TableCell, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; + +import { + previewWorkInstructionNo, saveWorkInstruction, + getEquipmentList, getEmployeeList, +} from "@/lib/api/workInstruction"; + +// ─── 공용 다중선택 Popover (설비/작업조/작업자) ──────────────────── +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} +function MultiSelectPopover({ + options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요", +}: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter((v) => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter((o) => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find((o) => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + return value.map((v) => options.find((o) => o.value === v)?.label || v).join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map((opt) => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); +} + +// ─── 모달 인터페이스 ──────────────────────────────────────────────── +export interface WorkInstructionApplyItem { + itemCode: string; + itemName: string; + spec?: string; + qty: number; + remark?: string; + sourceTable?: string; + sourceId?: string; + // 품목별 일정/설비/작업조/작업자 + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +export interface WorkInstructionApplyModalProps { + open: boolean; + onOpenChange: (v: boolean) => void; + initialItems: WorkInstructionApplyItem[]; + batchNo?: string | null; + cuttingPlanId?: number | null; + onSaved?: (result: { id: string; workInstructionNo: string }) => void; +} + +export default function WorkInstructionApplyModal({ + open, onOpenChange, initialItems, batchNo, cuttingPlanId, onSaved, +}: WorkInstructionApplyModalProps) { + const [wiNo, setWiNo] = useState(""); + const [status, setStatus] = useState("일반"); + const [remark, setRemark] = useState(""); + const [items, setItems] = useState([]); + const [saving, setSaving] = useState(false); + + const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]); + const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]); + + // 모달 오픈 시 초기화 + 옵션 로드 + useEffect(() => { + if (!open) return; + const today = new Date().toISOString().slice(0, 10); + setItems(initialItems.map((x) => ({ + ...x, + startDate: x.startDate || today, + endDate: x.endDate || "", + equipmentIds: x.equipmentIds || [], + workTeams: x.workTeams || [], + workers: x.workers || [], + }))); + setStatus("일반"); + setRemark(""); + + previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {}); + getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {}); + getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {}); + }, [open, initialItems]); + + const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]); + + const handleSave = async () => { + if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; } + setSaving(true); + try { + // 하위호환: 마스터에는 첫 품목의 대표값을 실음 (jskim 스타일) + const first = items[0]; + const payload = { + status, + startDate: first?.startDate || "", + endDate: first?.endDate || "", + equipmentId: first?.equipmentIds?.[0] || "", + workTeam: first?.workTeams?.[0] || "", + worker: first?.workers?.[0] || "", + remark, + routing: null, + batchNo: batchNo || null, + cuttingPlanId: cuttingPlanId ?? null, + items: items.map((i) => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, partCode: i.itemCode, + qty: String(i.qty), remark: i.remark || "", + sourceTable: i.sourceTable || "cutting_plan", + sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""), + routing: null, + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), + }; + const r = await saveWorkInstruction(payload); + if (!r.success) { toast.error(r.message || "저장 실패"); return; } + toast.success(`작업지시 ${r.data?.workInstructionNo || wiNo} 등록 완료`); + onOpenChange(false); + onSaved?.(r.data); + } catch (e: any) { + toast.error(e?.message || "저장 실패"); + } finally { + setSaving(false); + } + }; + + const equipmentSelectOptions = useMemo( + () => equipmentOptions.map((eq) => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code })), + [equipmentOptions] + ); + const workerSelectOptions = useMemo( + () => workerOptions.map((w) => ({ value: w.user_id, label: w.user_name, sub: w.dept_name || undefined })), + [workerOptions] + ); + + return ( + + + + 작업지시 적용 확인 + + 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. + {batchNo ? 배치번호 {batchNo} : null} + + + +
+
+
+

작업지시 기본 정보

+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
+
+ + +
+
+ + +
+
+ + setRemark(e.target.value)} /> +
+
+
+ +
+

품목 목록

+
+ + + + 순번 + 배치번호 + 품목코드 + 품목명 + 규격 + 수량 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 + + + + + {items.map((item, idx) => ( + + {idx + 1} + {batchNo || "-"} + {item.itemCode || "-"} + + {item.itemName || "-"} + + {item.spec || "-"} + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + + + + + + ))} + {items.length === 0 && ( + + + 품목이 없습니다 + + + )} + +
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx new file mode 100644 index 00000000..a2d05dd9 --- /dev/null +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx @@ -0,0 +1,2785 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + ResizableHandle, ResizablePanel, ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Search, RefreshCw, Save, Trash2, Zap, Package, Wrench, + Scissors, Ruler, LayoutGrid, Layers, Loader2, Plus, X, + RotateCcw, ClipboardList, CalendarClock, Truck, Maximize2, Pencil, +} from "lucide-react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { + CutType, PackMode, Dir, Material, PlanItem, + AreaResult, LengthResult, Sheet, Pipe, Placement, Remnant, RemnantItem, RemnantGroup, + packArea, packAreaHomogeneous, packLength, + computeSheetGroups, computePipeGroups, + computeAreaStats, computeLengthStats, + computeRemnants, extractInitialRemnants, + computeRemnantGroups, computeGroupOutline, decomposeUnion, + COLORS, +} from "@/lib/cutting/packing"; +import WorkInstructionApplyModal, { WorkInstructionApplyItem } from "./WorkInstructionApplyModal"; + +// ───────────────────────────────────────────────────────── +// 타입 +// ───────────────────────────────────────────────────────── +interface OrderRow { + order_no: string; + customer?: string; + partner_id?: string; + part_code?: string; + part_name?: string; + spec?: string; + order_qty?: number; + due_date?: string; + status?: string; + width?: number; + height?: number; + length?: number; + type?: CutType; + item_id?: string; + item_name?: string; + batch_id?: number; + batch_no?: string; +} + +// spec 파싱 ("668*1318" 또는 "L750mm") +function parseSpec(spec?: string): { width?: number; height?: number; length?: number; type?: CutType } { + if (!spec) return {}; + const m1 = String(spec).match(/(\d+)\s*[*x×X]\s*(\d+)/); + if (m1) return { width: +m1[1], height: +m1[2], type: "area" }; + const m2 = String(spec).match(/L?\s*(\d+)\s*mm/i); + if (m2) return { length: +m2[1], type: "length" }; + return {}; +} + +// ───────────────────────────────────────────────────────── +// 메인 컴포넌트 +// ───────────────────────────────────────────────────────── +export default function CuttingPlanPage() { + // 검색 / 기본 상태 + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [planNoFilter, setPlanNoFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + + // 좌측 소스 탭 & 데이터 + const [leftTab, setLeftTab] = useState<"order" | "plan" | "ship">("order"); + const [orders, setOrders] = useState([]); + const [orderTotal, setOrderTotal] = useState(0); + const [orderPage, setOrderPage] = useState(1); + const [orderLimit] = useState(100); + const [orderKeyword, setOrderKeyword] = useState(""); + const [excludeInPlan, setExcludeInPlan] = useState(true); + const [checkedOrders, setCheckedOrders] = useState>(new Set()); + const [loadingOrders, setLoadingOrders] = useState(false); + + // 설정 상태 + const [cutType, setCutType] = useState("area"); + const [calcMode, setCalcMode] = useState<"auto" | "manual">("auto"); + const [packMode, setPackMode] = useState("mixed"); + const [kerf, setKerf] = useState(3); + const [margin, setMargin] = useState(0); + const [minRemnant, setMinRemnant] = useState(100); + const [minReuse, setMinReuse] = useState(100); + + // 원자재 + const [materials, setMaterials] = useState([]); + const [mat1Id, setMat1Id] = useState(""); + const [mat2Id, setMat2Id] = useState(""); + const [showMat2, setShowMat2] = useState(false); + const mat1 = useMemo(() => materials.find((m) => String(m.id) === mat1Id), [materials, mat1Id]); + const mat2 = useMemo(() => materials.find((m) => String(m.id) === mat2Id), [materials, mat2Id]); + + // 절단 품목 + const [planItems, setPlanItems] = useState([]); + // 배치 결과 + const [batchResult, setBatchResult] = useState(null); + // 우측 탭 + const [rightTab, setRightTab] = useState<"batch" | "remnant">("batch"); + + // 확대 편집 모달 + const [zoomSheetIdx, setZoomSheetIdx] = useState(null); + // 수동편집 / 자동복원 + const [manualEditMode, setManualEditMode] = useState(false); + const [originalBatchResult, setOriginalBatchResult] = useState(null); + // loadPlan 직후 useEffect가 자동 calculate()로 remnants를 덮어쓰는 것 방지 (한 번 소비) + const skipAutoRecalcRef = useRef(false); + // 작업지시 적용 모달 열림 상태 + const [isWIModalOpen, setIsWIModalOpen] = useState(false); + + // 저장 + const [currentPlanId, setCurrentPlanId] = useState(null); + const [currentPlanNo, setCurrentPlanNo] = useState(""); + const [saving, setSaving] = useState(false); + + // ─────────────────────────────────────────────────────── + // 데이터 로딩 + // ─────────────────────────────────────────────────────── + const loadMaterials = useCallback(async () => { + try { + const res = await apiClient.get(`/cutting-plan/materials?cutType=${cutType}`); + const raw = res.data?.data || []; + const list: Material[] = raw.map((r: any) => ({ + id: r.id, + code: r.item_number || r.id, + name: r.item_name, + width: +r.width || 0, + height: +r.height || 0, + length: cutType === "length" ? (+r.length || +r.width || 0) : 0, + stock: +r.stock || 0, + unit: cutType === "length" ? "개" : "장", + })); + setMaterials(list); + } catch (e: any) { + toast.error("원자재 조회 실패: " + (e?.message || "")); + } + }, [cutType]); + + const loadOrders = useCallback(async () => { + setLoadingOrders(true); + try { + const res = await apiClient.get("/cutting-plan/orders", { + params: { + from: dateFrom || undefined, + to: dateTo || undefined, + keyword: orderKeyword || undefined, + page: orderPage, + limit: orderLimit, + excludeInPlan: excludeInPlan ? "true" : undefined, + }, + }); + const payload = res.data?.data || {}; + const raw = payload.rows || []; + setOrderTotal(payload.total || 0); + const rows: OrderRow[] = raw.map((o: any) => { + const dims = parseSpec(o.spec); + const qty = +o.order_qty || 0; + const balance = +o.balance_qty || qty; + return { + order_no: o.order_no, + customer: o.partner_id || "-", + partner_id: o.partner_id, + part_code: o.part_code || "", + part_name: o.part_name || "-", + spec: o.spec || "", + order_qty: qty, + due_date: o.due_date ? String(o.due_date).substring(0, 10) : "", + status: balance <= 0 ? "완료" : "미계획", + type: dims.type || "area", + width: dims.width || 0, + height: dims.height || 0, + length: dims.length || 0, + item_id: o.item_id ? String(o.item_id) : undefined, + item_name: o.item_name || undefined, + batch_id: o.batch_id ?? undefined, + batch_no: o.batch_no ?? undefined, + }; + }); + setOrders(rows); + } catch (e: any) { + toast.error("수주 조회 실패: " + (e?.message || "")); + } finally { + setLoadingOrders(false); + } + }, [dateFrom, dateTo, orderKeyword, orderPage, orderLimit, excludeInPlan]); + + useEffect(() => { loadMaterials(); }, [loadMaterials]); + useEffect(() => { loadOrders(); }, [loadOrders]); + + // 절단유형 바뀌면 선택/결과 리셋 + useEffect(() => { + setMat1Id(""); + setMat2Id(""); + setShowMat2(false); + setPlanItems([]); + setBatchResult(null); + }, [cutType]); + + // ─────────────────────────────────────────────────────── + // 좌측 테이블: 수주 → 필터링 + // ─────────────────────────────────────────────────────── + // 수주 탭은 절단유형 무관하게 전체 표시 (실제 운영 데이터 반영) + const filteredOrders = useMemo(() => orders, [orders]); + + const toggleOrderAll = useCallback( + (checked: boolean) => { + setCheckedOrders(checked ? new Set(filteredOrders.map((o) => o.order_no)) : new Set()); + }, + [filteredOrders] + ); + + const toggleOrderOne = useCallback((orderNo: string) => { + setCheckedOrders((prev) => { + const next = new Set(prev); + if (next.has(orderNo)) next.delete(orderNo); + else next.add(orderNo); + return next; + }); + }, []); + + // ─────────────────────────────────────────────────────── + // 계획에 추가 + // ─────────────────────────────────────────────────────── + const addToPlan = useCallback(() => { + if (checkedOrders.size === 0) { + toast.error("추가할 항목을 선택하세요"); + return; + } + const newItems: PlanItem[] = []; + let skipped = 0; + checkedOrders.forEach((orderNo) => { + const o = orders.find((x) => x.order_no === orderNo); + if (!o) return; + // 중복 기준: 품목명 + 가로 + 세로 + 길이 (완전히 같은 규격만 중복으로 취급) + const sameKey = (p: PlanItem) => + p.name === o.part_name && + Math.abs((p.width || 0) - (o.width || 0)) < 0.1 && + Math.abs((p.height || 0) - (o.height || 0)) < 0.1 && + Math.abs((p.length || 0) - (o.length || 0)) < 0.1; + const existsInPlan = planItems.find(sameKey); + const existsInNew = newItems.find(sameKey); + if (existsInPlan || existsInNew) { + // 같은 규격이면 수량 합산 + 수주번호 추가 + const target = (existsInNew || existsInPlan!) as PlanItem & { srcOrders?: string[] }; + target.qty = (target.qty || 0) + (o.order_qty || 0); + target.srcOrders = [...(target.srcOrders || []), orderNo]; + skipped++; + return; + } + newItems.push({ + name: o.item_name || o.part_name || "-", + code: o.part_code || undefined, + item_id: o.item_id || undefined, + width: o.width || 0, + height: o.height || 0, + length: o.length || 0, + qty: o.order_qty || 0, + dir: "무관", + color: COLORS[(planItems.length + newItems.length) % COLORS.length], + placed: 0, + srcOrders: [orderNo], + } as PlanItem & { srcOrders?: string[] }); + }); + setPlanItems((prev) => [...prev, ...newItems]); + setCheckedOrders(new Set()); + setBatchResult(null); + const msgs: string[] = []; + if (newItems.length) msgs.push(`${newItems.length}개 품목 추가`); + if (skipped) msgs.push(`${skipped}건 수량 합산`); + toast.success(msgs.join(" · ") || "추가 없음"); + }, [checkedOrders, orders, planItems]); + + const updateItem = useCallback((idx: number, field: keyof PlanItem, value: any) => { + setPlanItems((prev) => { + const next = [...prev]; + const n: any = { ...next[idx] }; + n[field] = field === "dir" ? value : Number(value) || 0; + next[idx] = n; + return next; + }); + }, []); + + const removeItem = useCallback((idx: number) => { + setPlanItems((prev) => prev.filter((_, i) => i !== idx)); + setBatchResult(null); + }, []); + + const clearItems = useCallback(() => { + if (!planItems.length) return; + if (!confirm("모든 품목을 삭제하시겠습니까?")) return; + setPlanItems([]); + setBatchResult(null); + }, [planItems.length]); + + // ─────────────────────────────────────────────────────── + // 계산 실행 + // ─────────────────────────────────────────────────────── + const calculate = useCallback((overrideMode?: PackMode) => { + if (!planItems.length) { + toast.error("절단 품목을 먼저 추가하세요"); + return; + } + if (!mat1) { + toast.error("원자재를 선택하세요"); + return; + } + const mats = [mat1, mat2].filter(Boolean) as Material[]; + const mode = overrideMode ?? packMode; + + // 여유율 적용: qty × (1 + margin/100) 올림 + const marginRate = Math.max(0, margin || 0) / 100; + const effectiveItems = planItems.map((p) => ({ + ...p, + qty: marginRate > 0 ? Math.ceil((p.qty || 0) * (1 + marginRate)) : (p.qty || 0), + })); + + let result: AreaResult | LengthResult; + if (cutType === "area") { + result = mode === "homo" + ? packAreaHomogeneous(mats, effectiveItems, kerf) + : packArea(mats, effectiveItems, kerf); + (result as AreaResult).sheetGroups = computeSheetGroups((result as AreaResult).sheets); + } else { + result = packLength(mats, effectiveItems, kerf); + (result as LengthResult).pipeGroups = computePipeGroups((result as LengthResult).pipes); + } + + // 배치 수량 업데이트 + setPlanItems((prev) => { + const next = prev.map((p) => ({ ...p, placed: 0 })); + if (cutType === "area") { + (result as AreaResult).sheets.forEach((sh) => + sh.placements.forEach((p) => { + if (next[p.itemIdx]) next[p.itemIdx].placed = (next[p.itemIdx].placed || 0) + 1; + }) + ); + } else { + (result as LengthResult).pipes.forEach((pi) => + pi.segments.forEach((s) => { + if (next[s.itemIdx]) next[s.itemIdx].placed = (next[s.itemIdx].placed || 0) + 1; + }) + ); + } + return next; + }); + + setBatchResult(result); + setOriginalBatchResult(JSON.parse(JSON.stringify(result))); // 자동복원용 + setManualEditMode(false); + setRightTab("batch"); + const marginMsg = marginRate > 0 ? ` · 여유율 ${margin}% 포함` : ""; + toast.success( + (mode === "homo" ? "계산 완료 (동일 품목 우선)" : "계산 완료 (4전략 최적)") + marginMsg + ); + }, [cutType, packMode, mat1, mat2, kerf, margin, planItems]); + + // calcMode="auto"이고 이미 배치결과가 있으면 설정 변경 시 자동 재계산 + useEffect(() => { + if (skipAutoRecalcRef.current) { + skipAutoRecalcRef.current = false; + return; + } + if (calcMode !== "auto") return; + if (!batchResult) return; + calculate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [packMode, cutType, kerf, margin, mat1?.code, mat2?.code]); + + // 자투리 helper: sheet에 사용자 편집 자투리(remnants)가 있으면 사용, 없으면 자동 추출 (kerf 반영). + const getSheetRemnants = useCallback((sheet: Sheet): RemnantItem[] => { + return sheet.remnants || extractInitialRemnants(sheet, `s${sheet.id}-`, kerf); + }, [kerf]); + + // 자투리 status 토글 (sheet ID + remnant ID로 식별, batchResult 직접 수정) + const setRemnantStatusById = useCallback((sheetId: number, remId: string, status: "keep" | "discard") => { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh) => { + if (sh.id !== sheetId) return sh; + const cur = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + return { ...sh, remnants: cur.map((rm) => rm.id === remId ? { ...rm, status } : rm) }; + }); + return { ...r, sheets: newSheets }; + }); + }, [kerf]); + + // 모든 자투리 일괄 status 변경 + const setAllRemnantStatus = useCallback((status: "keep" | "discard") => { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh) => { + const cur = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + return { ...sh, remnants: cur.map((rm) => ({ ...rm, status })) }; + }); + return { ...r, sheets: newSheets }; + }); + }, [kerf]); + + // 그룹(여러 remnant) 단위 status 일괄 변경 + const setGroupRemnantStatus = useCallback((sheetId: number, remIds: string[], status: "keep" | "discard") => { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const idSet = new Set(remIds); + const newSheets = r.sheets.map((sh) => { + if (sh.id !== sheetId) return sh; + const cur = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + return { ...sh, remnants: cur.map((rm) => idSet.has(rm.id) ? { ...rm, status } : rm) }; + }); + return { ...r, sheets: newSheets }; + }); + }, [kerf]); + + // ─────────────────────────────────────────────────────── + // 자동복원 (수동편집/확대편집 취소 → 최초 자동 계산 결과로 복구) + // ─────────────────────────────────────────────────────── + const resetLayout = useCallback(() => { + if (!originalBatchResult) return; + const restored = JSON.parse(JSON.stringify(originalBatchResult)); + setBatchResult(restored); + setManualEditMode(false); + // 품목 placed 재계산 + setPlanItems((prev) => { + const next = prev.map((p) => ({ ...p, placed: 0 })); + if (cutType === "area") { + (restored as AreaResult).sheets.forEach((sh: Sheet) => + sh.placements.forEach((p) => { + if (next[p.itemIdx]) next[p.itemIdx].placed = (next[p.itemIdx].placed || 0) + 1; + }) + ); + } else { + (restored as LengthResult).pipes.forEach((pi: Pipe) => + pi.segments.forEach((s) => { + if (next[s.itemIdx]) next[s.itemIdx].placed = (next[s.itemIdx].placed || 0) + 1; + }) + ); + } + return next; + }); + toast.success("자동 배치로 복원되었습니다"); + }, [originalBatchResult, cutType]); + + // ─────────────────────────────────────────────────────── + // 저장된 절단계획 불러오기 (배치번호 클릭 시) + // ─────────────────────────────────────────────────────── + const loadPlan = useCallback(async (planId: number) => { + try { + const res = await apiClient.get(`/cutting-plan/plans/${planId}`); + const data = res.data?.data; + if (!data) { toast.error("계획을 찾을 수 없습니다"); return; } + const { header: h, items: rawItems, sheets: rawSheets, placements: rawPlacements } = data; + + // 복원한 batchResult(remnants 포함)가 자동 재계산 useEffect에 덮어쓰이지 않도록 한 번 스킵 + skipAutoRecalcRef.current = true; + + // 헤더 → state + setCurrentPlanId(h.id); + setCurrentPlanNo(h.plan_no || ""); + setDateFrom(h.plan_date_from ? String(h.plan_date_from).substring(0, 10) : ""); + setDateTo(h.plan_date_to ? String(h.plan_date_to).substring(0, 10) : ""); + setCutType((h.cut_type as CutType) || "area"); + setCalcMode((h.calc_mode as "auto" | "manual") || "auto"); + setPackMode((h.pack_mode as PackMode) || "mixed"); + if (h.mat_item_id) setMat1Id(String(h.mat_item_id)); + if (h.mat_item_id_2) { setMat2Id(String(h.mat_item_id_2)); setShowMat2(true); } + if (h.kerf != null) setKerf(+h.kerf); + if (h.margin != null) setMargin(+h.margin); + if (h.min_remnant != null) setMinRemnant(+h.min_remnant); + if (h.min_reuse != null) setMinReuse(+h.min_reuse); + + // items → planItems (같은 (item_name + W×H + L)로 합산 + srcOrders 모음) + // 백엔드 getPlanDetail이 item_info JOIN으로 item_number/item_name_resolved 내려줌. + const itemMap = new Map(); + const itemDbIdToKey = new Map(); // db item id → group key + (rawItems || []).forEach((it: any, idx: number) => { + const w = +it.width || 0, hh = +it.height || 0, len = +it.length || 0; + const resolvedName = it.item_name_resolved || it.item_name || "-"; + const resolvedCode = it.item_number || undefined; + const key = `${resolvedName}|${w}|${hh}|${len}`; + const exist = itemMap.get(key); + if (exist) { + exist.qty = (exist.qty || 0) + (+it.qty || 0); + if (it.src_no) exist.srcOrders.push(it.src_no); + if (!exist.code && resolvedCode) exist.code = resolvedCode; + if (!exist.item_id && it.item_id) exist.item_id = String(it.item_id); + } else { + itemMap.set(key, { + name: resolvedName, + code: resolvedCode, + item_id: it.item_id ? String(it.item_id) : undefined, + width: w, height: hh, length: len, + qty: +it.qty || 0, + dir: (it.dir as Dir) || "무관", + color: it.color || COLORS[itemMap.size % COLORS.length], + placed: +it.placed_qty || 0, + srcOrders: it.src_no ? [it.src_no] : [], + }); + } + itemDbIdToKey.set(it.id, key); + }); + const loadedPlanItems = [...itemMap.values()]; + setPlanItems(loadedPlanItems); + const keyToIdx = new Map(); + [...itemMap.keys()].forEach((k, i) => keyToIdx.set(k, i)); + + // sheets + placements → batchResult + if (h.cut_type === "length") { + const pipes: Pipe[] = (rawSheets || []).map((sh: any, si: number) => { + const segs = (rawPlacements || []) + .filter((p: any) => p.sheet_id === sh.id) + .map((p: any) => { + const itemKey = itemDbIdToKey.get(p.plan_item_id); + const itemIdx = itemKey ? (keyToIdx.get(itemKey) ?? 0) : 0; + const item = itemKey ? itemMap.get(itemKey) : undefined; + return { + len: +p.seg_length || 0, + color: item?.color || "#3b82f6", + name: item?.name || "-", + itemIdx, + startX: +p.start_x || 0, + }; + }); + return { + id: si + 1, + matLen: +sh.mat_length || 0, + matCode: sh.mat_name || "", + matName: sh.mat_name || "", + remaining: +sh.remnant_length || 0, + segments: segs, + }; + }); + setBatchResult({ pipes, pipeGroups: computePipeGroups(pipes) }); + } else { + const sheets: Sheet[] = (rawSheets || []).map((sh: any, si: number) => { + const placements: Placement[] = (rawPlacements || []) + .filter((p: any) => p.sheet_id === sh.id) + .map((p: any) => { + const itemKey = itemDbIdToKey.get(p.plan_item_id); + const itemIdx = itemKey ? (keyToIdx.get(itemKey) ?? 0) : 0; + const item = itemKey ? itemMap.get(itemKey) : undefined; + return { + x: +p.x || 0, y: +p.y || 0, w: +p.w || 0, h: +p.h || 0, + color: item?.color || "#3b82f6", + name: item?.name || "-", + itemIdx, + rotated: !!p.rotated, + }; + }); + return { + id: si + 1, + matW: +sh.mat_width || 0, matH: +sh.mat_height || 0, + matCode: sh.mat_name || "", matName: sh.mat_name || "", + shelves: [], placements, + remnants: Array.isArray(sh.remnants) ? (sh.remnants as RemnantItem[]) : undefined, + }; + }); + setBatchResult({ sheets, sheetGroups: computeSheetGroups(sheets) }); + } + toast.success(`배치 ${h.plan_no} 불러오기 완료`); + } catch (e: any) { + toast.error("불러오기 실패: " + (e?.response?.data?.message || e?.message || "")); + } + }, []); + + // ─────────────────────────────────────────────────────── + // 저장 + // ─────────────────────────────────────────────────────── + const savePlan = useCallback(async () => { + if (!planItems.length) { toast.error("품목을 먼저 추가하세요"); return; } + setSaving(true); + try { + const header: any = { + id: currentPlanId, + plan_no: currentPlanNo, + plan_date_from: dateFrom || null, + plan_date_to: dateTo || null, + cut_type: cutType, + calc_mode: calcMode, + pack_mode: packMode, + mat_item_id: mat1?.id || null, + mat_item_id_2: mat2?.id || null, + kerf, margin, min_remnant: minRemnant, min_reuse: minReuse, + status: "draft", + }; + + if (batchResult) { + if (cutType === "area") { + const stats = computeAreaStats(batchResult as AreaResult); + header.total_sheets = stats.count; + header.total_pieces = stats.totalPieces; + header.util_rate = +stats.util.toFixed(2); + header.total_loss = stats.loss; + header.rotated_count = stats.rotated; + } else { + const stats = computeLengthStats(batchResult as LengthResult); + header.total_sheets = stats.count; + header.total_pieces = stats.totalPieces; + header.util_rate = +stats.util.toFixed(2); + header.total_loss = stats.loss; + } + } + + const items = planItems.map((p, i) => { + const srcOrders = (p as PlanItem & { srcOrders?: string[] }).srcOrders || []; + return { + seq: i + 1, + src_type: srcOrders.length > 0 ? "order" : "manual", + src_no: srcOrders.length === 1 ? srcOrders[0] : null, + src_orders: srcOrders, + item_id: p.item_id || null, + item_name: p.name, + width: p.width || null, height: p.height || null, length: p.length || null, + qty: p.qty, dir: p.dir, color: p.color, placed_qty: p.placed || 0, + }; + }); + + const sheets: any[] = []; + if (batchResult) { + if (cutType === "area") { + const areaRes = batchResult as AreaResult; + // "×N 동일" 그룹: 대표 sheet에만 토글된 remnants가 있으므로 같은 그룹의 다른 sheet에도 복제 + // (개별 sheet에 자체 remnants가 이미 있으면 그대로 유지) + const groupRemBySheetIdx = new Map(); + const sheetGroups = (areaRes.sheetGroups && areaRes.sheetGroups.length > 0) + ? areaRes.sheetGroups + : computeSheetGroups(areaRes.sheets); + sheetGroups.forEach((g) => { + const repRem = g.representative.remnants; + if (repRem && repRem.length > 0) { + g.indices.forEach((i) => groupRemBySheetIdx.set(i, repRem)); + } + }); + areaRes.sheets.forEach((sh, si) => { + const usedA = sh.placements.reduce((s, p) => s + p.w * p.h, 0); + const effectiveRem = sh.remnants ?? groupRemBySheetIdx.get(si) ?? null; + sheets.push({ + sheet_no: si + 1, + mat_item_id: mat1?.id || null, + mat_name: sh.matName, + mat_width: sh.matW, mat_height: sh.matH, + used_area: usedA, + remnant_area: sh.matW * sh.matH - usedA, + util_rate: sh.matW * sh.matH > 0 ? +(usedA / (sh.matW * sh.matH) * 100).toFixed(2) : 0, + remnants: effectiveRem, + placements: sh.placements.map((p, pi) => ({ + itemIdx: p.itemIdx, + x: p.x, y: p.y, w: p.w, h: p.h, + rotated: !!p.rotated, + placement_order: pi, + })), + }); + }); + } else { + (batchResult as LengthResult).pipes.forEach((pipe, pi) => { + const usedL = pipe.segments.reduce((s, seg) => s + seg.len, 0); + sheets.push({ + sheet_no: pi + 1, + mat_item_id: mat1?.id || null, + mat_name: pipe.matName, + mat_length: pipe.matLen, + used_length: usedL, + remnant_length: Math.max(0, pipe.remaining), + util_rate: pipe.matLen > 0 ? +(usedL / pipe.matLen * 100).toFixed(2) : 0, + placements: pipe.segments.map((seg, si) => ({ + itemIdx: seg.itemIdx, + start_x: seg.startX, seg_length: seg.len, + placement_order: si, + })), + }); + }); + } + } + + const res = await apiClient.post("/cutting-plan/plans", { header, items, sheets }); + const data = res.data?.data; + if (data?.id) setCurrentPlanId(data.id); + if (data?.plan_no) setCurrentPlanNo(data.plan_no); + toast.success(`저장되었습니다 — 배치번호 ${data?.plan_no || currentPlanNo}`); + // 수주 목록 자동 새로고침 → 배치번호 표시 + loadOrders(); + } catch (e: any) { + toast.error("저장 실패: " + (e?.response?.data?.message || e?.message || "")); + } finally { + setSaving(false); + } + }, [planItems, currentPlanId, currentPlanNo, dateFrom, dateTo, cutType, calcMode, packMode, mat1, mat2, kerf, margin, minRemnant, minReuse, batchResult]); + + // ─────────────────────────────────────────────────────── + // UI Helpers + // ─────────────────────────────────────────────────────── + const statusBadge = (s?: string) => { + if (s === "미계획") return {s}; + if (s === "계획중") return {s}; + if (s === "완료") return {s}; + return {s || "-"}; + }; + + const stats = useMemo(() => { + if (!batchResult) return null; + if (cutType === "area") return computeAreaStats(batchResult as AreaResult); + return computeLengthStats(batchResult as LengthResult); + }, [batchResult, cutType]); + + // ─────────────────────────────────────────────────────── + // 렌더 + // ─────────────────────────────────────────────────────── + return ( +
+ {/* 브레드크럼 */} + + + {/* 검색 영역 */} +
+
+
+ + setDateFrom(e.target.value)} className="h-8 w-[140px] text-xs" /> + ~ + setDateTo(e.target.value)} className="h-8 w-[140px] text-xs" /> +
+
+
+ + setPlanNoFilter(e.target.value)} placeholder="CP-2026-" className="h-8 w-[130px] text-xs" /> +
+
+ + +
+
+ + +
+
+
+ + {/* 본문 - 좌우 패널 */} + + {/* 좌측: 소스 탭 */} + +
+ setLeftTab(v as any)} className="flex h-full flex-col"> +
+ + + 수주 + + + 생산계획 + + + 출하계획 + + +
+ + +
+
+ setOrderKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { setOrderPage(1); loadOrders(); } }} + placeholder="수주번호/품목 검색" + className="h-7 text-xs flex-1" + /> + +
+
+ + + 총 {orderTotal.toLocaleString()}건 + {" | "}선택 {checkedOrders.size}건 + + +
+
+
+ {loadingOrders ? ( +
+ +
+ ) : filteredOrders.length === 0 ? ( +
+ +

수주 데이터가 없습니다

+
+ ) : ( + + + + + 0} + onCheckedChange={(c) => toggleOrderAll(!!c)} + className="h-4 w-4" + /> + + 수주번호 + 배치번호 + 거래처 + 품목명 + 규격 + 수량 + 납기 + 상태 + + + + {filteredOrders.map((o, idx) => { + const prev = idx > 0 ? filteredOrders[idx - 1] : null; + const isFirstOfBatch = o.batch_no && (!prev || prev.batch_no !== o.batch_no); + return ( + toggleOrderOne(o.order_no)} + > + e.stopPropagation()}> + toggleOrderOne(o.order_no)} + className="h-4 w-4" + /> + + {o.order_no} + e.stopPropagation()}> + {o.batch_no && o.batch_id ? ( + + ) : ( + - + )} + + {o.customer} + {o.part_name} + {o.spec} + {o.order_qty} + {o.due_date} + {statusBadge(o.status)} + + ); + })} + +
+ )} +
+ {/* 페이지네이션 */} + {orderTotal > orderLimit && ( +
+ + {((orderPage - 1) * orderLimit + 1).toLocaleString()}- + {Math.min(orderPage * orderLimit, orderTotal).toLocaleString()} / {orderTotal.toLocaleString()} + +
+ + + + {orderPage} / {Math.max(1, Math.ceil(orderTotal / orderLimit))} + + + +
+
+ )} +
+ + +
+ +

생산계획 데이터가 없습니다

+
+
+ +
+ +

출하계획 데이터가 없습니다

+
+
+
+
+
+ + + + {/* 우측 */} + +
+ {/* 설정 카드 */} +
+ {/* 토글 row */} +
+
+ + setCutType(v as CutType)} + options={[ + { value: "area", label: "면적형 (판재)", icon: }, + // 유리 전용은 파이프 탭 숨김 — 필요 시 주석 해제 + // { value: "length", label: "길이형 (파이프)", icon: }, + ]} + activeColor="blue" + /> +
+
+
+ + setCalcMode(v as any)} + options={[ + { value: "auto", label: "자동", icon: }, + { value: "manual", label: "수동", icon: }, + ]} + activeColor="green" + /> +
+
+
+ + { + const nm = v as PackMode; + setPackMode(nm); + if (calcMode === "auto" && batchResult) calculate(nm); + }} + options={[ + { value: "mixed", label: "혼합 최적", icon: }, + { value: "homo", label: "동일 품목 우선", icon: }, + ]} + activeColor="orange" + /> +
+
+
+ + setKerf(+e.target.value)} className="h-7 w-[56px] text-xs px-1.5" /> + mm +
+
+ + setMargin(+e.target.value)} className="h-7 w-[56px] text-xs px-1.5" /> + % +
+
+
+ + {/* 원자재 선택 */} +
+ + + {mat1 && ( +
+ + {cutType === "area" ? `${mat1.width}×${mat1.height}` : `${mat1.length}`} + + · + = 10 ? "text-success" : "text-warning")}> + {(mat1.stock || 0)}{mat1.unit || "장"} + +
+ )} + {/* 원자재 2 (인라인 초-컴팩트) */} + {showMat2 ? ( + <> + + + {mat2 && ( +
+ + {cutType === "area" ? `${mat2.width}×${mat2.height}` : `${mat2.length}`} + + · + = 10 ? "text-success" : "text-warning")}> + {(mat2.stock || 0)}{mat2.unit || "장"} + +
+ )} + + + ) : ( + + )} +
+ + setMinRemnant(+e.target.value)} className="h-7 w-[60px] text-xs px-1.5" /> + mm↑ +
+
+
+ + {/* 품목 목록 + 결과 (수직 리사이즈) */} + + +
+
+
+ + 절단 품목 목록 + + {planItems.length}건 + +
+
+ + +
+
+
+ {planItems.length === 0 ? ( +
+ +

좌측에서 항목 선택 후 [계획 추가]를 클릭하세요

+
+ ) : ( + + + + No + 품목명 + + {cutType === "area" ? "가로(W)" : "길이(L)"} + + {cutType === "area" && 세로(H)} + 수량 + {cutType === "area" && 방향} + 배치결과 + + + + + {planItems.map((item, i) => ( + + {i + 1} + +
+ + {item.name} +
+
+ + {cutType === "area" ? item.width : (item.length || 0)} + + {cutType === "area" && ( + + {item.height} + + )} + + updateItem(i, "qty", e.target.value)} className="h-7 text-xs px-1.5" /> + + {cutType === "area" && ( + + + + )} + + {item.placed !== undefined && item.placed > 0 ? ( + + {item.placed}개 + + ) : ( + 미배치 + )} + + + + +
+ ))} +
+
+ )} +
+
+
+ + + + + {/* 결과 카드 */} +
+ setRightTab(v as any)} className="flex flex-col h-full"> +
+ + + 배치 계획 + + + 자투리 관리 + + + {batchResult && rightTab === "batch" && ( +
+ + +
+ )} +
+ + {/* 배치 계획 */} + + {batchResult ? ( + <> + {/* 범례 */} +
+ {planItems.filter((p) => (p.placed || 0) > 0).map((p, i) => ( +
+
+ {p.name} +
+ ))} +
+
+ 자투리 +
+
+
+ 회전배치 +
+
+ {/* 통계 */} + {stats && ( +
+ + + + + {cutType === "area" && } +
+ )} + {manualEditMode && ( +
+ + 수동편집 모드 + — 피스를 드래그하여 이동 | 더블클릭하여 회전 +
+ )} +
+ {cutType === "area" + ? { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh, i) => i === si ? { ...sh, placements, remnants: undefined } : sh); + return { ...r, sheets: newSheets, sheetGroups: computeSheetGroups(newSheets) }; + }); + }} + /> + : + } +
+ + ) : ( +
+ +

원자재와 품목을 설정하고 [계산실행]을 클릭하세요

+
+ )} + + + {/* 자투리 관리 */} + + + + +
+ + + + {/* 하단 버튼바 */} +
+
+ {currentPlanNo && ( + + {currentPlanNo} + + )} +
+
+ + +
+
+
+ + + + {/* 확대 편집 모달 */} + {zoomSheetIdx !== null && batchResult && cutType === "area" && (batchResult as AreaResult).sheets[zoomSheetIdx] && ( + { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh, i) => + i === zoomSheetIdx ? { ...sh, placements: newPlacements, remnants: newRemnants } : sh + ); + return { ...r, sheets: newSheets, sheetGroups: computeSheetGroups(newSheets) }; + }); + // 배치 수량 재계산 + setPlanItems((prev) => { + const next = prev.map((p) => ({ ...p, placed: 0 })); + (batchResult as AreaResult).sheets.forEach((sh, i) => { + const ps = i === zoomSheetIdx ? newPlacements : sh.placements; + ps.forEach((p) => { + if (next[p.itemIdx]) next[p.itemIdx].placed = (next[p.itemIdx].placed || 0) + 1; + }); + }); + return next; + }); + setZoomSheetIdx(null); + toast.success("편집 내용이 반영되었습니다"); + }} + onClose={() => setZoomSheetIdx(null)} + /> + )} + + {/* ── 작업지시 적용 모달 (절단계획 → 작업지시) ── */} + ((p) => { + const spec = cutType === "length" && p.length + ? `L${p.length}mm` + : `${p.width || 0}×${p.height || 0}`; + const srcOrders = ((p as PlanItem & { srcOrders?: string[] }).srcOrders) || []; + return { + itemCode: p.code || "", + itemName: p.name, + spec, + qty: p.qty || 0, + sourceTable: "cutting_plan", + sourceId: srcOrders[0] || (currentPlanId != null ? String(currentPlanId) : ""), + }; + })} + /> +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 토글 그룹 (shadcn Button 기반 2~3지선다) +// ───────────────────────────────────────────────────────── +function ToggleGroup({ + value, onChange, options, activeColor = "blue", +}: { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string; icon?: React.ReactNode }[]; + activeColor?: "blue" | "green" | "orange"; +}) { + const activeCls = + activeColor === "blue" ? "bg-primary text-primary-foreground hover:bg-primary/90" + : activeColor === "green" ? "bg-emerald-600 text-white hover:bg-emerald-700" + : "bg-orange-500 text-white hover:bg-orange-600"; + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 통계 박스 +// ───────────────────────────────────────────────────────── +function StatBox({ + label, value, unit, color, +}: { + label: string; + value: number | string; + unit?: string; + color?: "primary" | "success" | "warning"; +}) { + const valCls = + color === "primary" ? "text-primary" + : color === "success" ? "text-success" + : color === "warning" ? "text-warning" + : "text-foreground"; + return ( +
+ {label} +
+ {value} + {unit && {unit}} +
+
+ ); +} + +// ───────────────────────────────────────────────────────── +// 면적형 배치 시각화 +// ───────────────────────────────────────────────────────── +function AreaBatchView({ + result, onOpenZoom, manualEditMode = false, planItems = [], onUpdatePlacements, kerf = 0, + getSheetRemnants, onToggleRemnant, onToggleGroup, +}: { + result: AreaResult; + onOpenZoom: (sheetIdx: number) => void; + manualEditMode?: boolean; + planItems?: PlanItem[]; + onUpdatePlacements?: (sheetIdx: number, placements: Placement[]) => void; + kerf?: number; + getSheetRemnants: (sheet: Sheet) => RemnantItem[]; + onToggleRemnant: (sheetId: number, remId: string, status: "keep" | "discard") => void; + onToggleGroup: (sheetId: number, remIds: string[], status: "keep" | "discard") => void; +}) { + const groups = result.sheetGroups || result.sheets.map((s, si) => ({ + count: 1, repIdx: si, representative: s, indices: [si], + })); + const totalSheets = result.sheets.length; + const hasGroups = groups.some((g) => g.count > 1); + const DW = 240; + + if (!result.sheets.length) return null; + + return ( +
+ {hasGroups && ( +
+ 📐 총 {totalSheets}장 필요 + + {groups.length}가지 배치 패턴으로 합산 표시 + {groups.filter((g) => g.count > 1).map((g) => ( + ×{g.count} 동일 + ))} +
+ )} +
+ {groups.map((g) => { + const sheet = g.representative; + const scale = DW / sheet.matW; + const DH = Math.round(sheet.matH * scale); + const first = Math.min(...g.indices) + 1; + const last = Math.max(...g.indices) + 1; + return ( +
+
+ #{g.count > 1 ? `${first}~${last} (×${g.count})` : first} {sheet.matName} +
+
+ {/* 확대 버튼 */} + + {(() => { + const remnants = getSheetRemnants(sheet); + const groups = computeRemnantGroups(remnants); + return groups.map((g) => { + const keepCount = g.rects.filter((r) => r.status === "keep").length; + const status: "keep" | "discard" | "mixed" = + keepCount === g.rects.length ? "keep" : + keepCount === 0 ? "discard" : "mixed"; + const outline = computeGroupOutline(g.rects); + const outlineColor = + status === "keep" ? "rgba(37,99,235,0.85)" : + status === "discard" ? "rgba(245,158,11,0.7)" : + "rgba(139,92,246,0.85)"; + const largest = g.rects.reduce((a, b) => a.w * a.h > b.w * b.h ? a : b); + const remIds = g.rects.map((r) => r.id); + const groupNewStatus: "keep" | "discard" = status === "keep" ? "discard" : "keep"; + return ( + + {g.rects.map((rm) => { + const rmKeep = rm.status === "keep"; + return ( +
{ + e.stopPropagation(); + if (e.shiftKey) { + onToggleRemnant(sheet.id, rm.id, rmKeep ? "discard" : "keep"); + } else { + onToggleGroup(sheet.id, remIds, groupNewStatus); + } + }} + title={`${rmKeep ? "[보관]" : "[폐기]"} ${rm.w}×${rm.h} — 클릭: 그룹 토글 / Shift+클릭: 이 조각만`} + className={cn( + "absolute cursor-pointer hover:brightness-110", + rmKeep ? "bg-blue-300/35" : "bg-amber-300/20" + )} + style={{ + left: Math.round(rm.x * scale), + top: Math.round(rm.y * scale), + width: Math.max(2, Math.round(rm.w * scale)), + height: Math.max(2, Math.round(rm.h * scale)), + border: g.rects.length > 1 ? "1px solid rgba(255,255,255,0.4)" : "none", + }} + /> + ); + })} + + {outline.map((path, pi) => { + const d = path.points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x * scale} ${p.y * scale}`) + .join(" ") + " Z"; + return ( + + ); + })} + + {/* 그룹 라벨 — 가장 큰 자투리 사각형 좌상단 (확대버튼/그룹배지 회피) */} +
+ + {status === "keep" ? "보관" : status === "discard" ? "폐기" : `혼합 ${keepCount}/${g.rects.length}`} + +
+ + ); + }); + })()} + {sheet.placements.map((p, pi) => { + const px = Math.round(p.x * scale); + const py = Math.round(p.y * scale); + const pw = Math.max(3, Math.round(p.w * scale)); + const ph = Math.max(3, Math.round(p.h * scale)); + const si = g.repIdx; + + const collidesWith = ( + list: Placement[], idx: number, + x: number, y: number, w: number, h: number + ) => { + for (let i = 0; i < list.length; i++) { + if (i === idx) continue; + const o = list[i]; + if ( + x < o.x + o.w + kerf && + x + w + kerf > o.x && + y < o.y + o.h + kerf && + y + h + kerf > o.y + ) return true; + } + return false; + }; + + const startDrag = (e: React.MouseEvent) => { + if (!manualEditMode || !onUpdatePlacements) return; + e.preventDefault(); e.stopPropagation(); + const startMX = e.clientX, startMY = e.clientY; + const origX = p.x, origY = p.y; + let curX = origX, curY = origY; + const others = sheet.placements.filter((_, i) => i !== pi); + // X 방향 이동 — 인접 piece와 kerf 간격으로 정확히 snap + const slideX = (from: number, to: number, y: number) => { + if (from === to) return from; + const direction = to > from ? 1 : -1; + if (direction > 0) { + let bound = sheet.matW - p.w; + for (const o of others) { + if (o.y + o.h + kerf > y && o.y - kerf < y + p.h) { + const limit = o.x - p.w - kerf; + if (limit >= from && limit < bound) bound = limit; + } + } + return Math.max(from, Math.min(to, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.y + o.h + kerf > y && o.y - kerf < y + p.h) { + const limit = o.x + o.w + kerf; + if (limit <= from && limit > bound) bound = limit; + } + } + return Math.min(from, Math.max(to, bound)); + } + }; + const slideY = (x: number, from: number, to: number) => { + if (from === to) return from; + const direction = to > from ? 1 : -1; + if (direction > 0) { + let bound = sheet.matH - p.h; + for (const o of others) { + if (o.x + o.w + kerf > x && o.x - kerf < x + p.w) { + const limit = o.y - p.h - kerf; + if (limit >= from && limit < bound) bound = limit; + } + } + return Math.max(from, Math.min(to, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.x + o.w + kerf > x && o.x - kerf < x + p.w) { + const limit = o.y + o.h + kerf; + if (limit <= from && limit > bound) bound = limit; + } + } + return Math.min(from, Math.max(to, bound)); + } + }; + const onMove = (ev: MouseEvent) => { + const dx = (ev.clientX - startMX) / scale; + const dy = (ev.clientY - startMY) / scale; + const tx = Math.max(0, Math.min(sheet.matW - p.w, origX + dx)); + const ty = Math.max(0, Math.min(sheet.matH - p.h, origY + dy)); + curX = slideX(curX, tx, curY); + curY = slideY(curX, curY, ty); + const nextPlacements = sheet.placements.map((pp, idx) => + idx === pi ? { ...pp, x: curX, y: curY } : pp + ); + onUpdatePlacements(si, nextPlacements); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + // 최종 방어선: 최종 위치 겹침 시 원위치 복구 + if (collidesWith(sheet.placements, pi, curX, curY, p.w, p.h)) { + const restored = sheet.placements.map((pp, idx) => + idx === pi ? { ...pp, x: origX, y: origY } : pp + ); + onUpdatePlacements(si, restored); + toast.error("다른 조각과 겹쳐 원위치로 복구"); + } + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + const rotateHere = (e: React.MouseEvent) => { + if (!manualEditMode || !onUpdatePlacements) return; + e.stopPropagation(); + const item = planItems[p.itemIdx]; + if (item && item.dir !== "무관") { toast.error("방향 지정 품목은 회전 불가"); return; } + const nw = p.h, nh = p.w; + const nx = Math.max(0, Math.min(sheet.matW - nw, p.x)); + const ny = Math.max(0, Math.min(sheet.matH - nh, p.y)); + if (collidesWith(sheet.placements, pi, nx, ny, nw, nh)) { + toast.error("회전 시 다른 조각과 겹칩니다"); return; + } + const nextPlacements = sheet.placements.map((pp, idx) => + idx === pi + ? { ...pp, w: nw, h: nh, rotated: !pp.rotated, x: nx, y: ny } + : pp + ); + onUpdatePlacements(si, nextPlacements); + }; + + return ( +
+ {pw > 50 && ph > 14 ? `${p.w}×${p.h}` : ""} +
+ ); + })} + {g.count > 1 && ( +
+ × {g.count}장 동일 +
+ )} +
+
+ ); + })} +
+
+ ); +} + +// ───────────────────────────────────────────────────────── +// 확대 편집 모달 (드래그 · 회전 · X/Y 입력 · 여백 표시) +// ───────────────────────────────────────────────────────── +function ZoomEditorModal({ + sheet, sheetIdx, planItems, kerf, onApply, onClose, +}: { + sheet: Sheet; + sheetIdx: number; + planItems: PlanItem[]; + kerf: number; + onApply: (placements: typeof sheet.placements, remnants: RemnantItem[]) => void; + onClose: () => void; +}) { + // 로컬 복사본 (닫기 시 원복, 반영 시 상위로) + const [placements, setPlacements] = useState(() => sheet.placements.map((p) => ({ ...p }))); + const [selectedPI, setSelectedPI] = useState(null); + const [scale, setScale] = useState(0.5); + const [inputX, setInputX] = useState(""); + const [inputY, setInputY] = useState(""); + + // 자투리 status 오버라이드 (위치 기반 키, zoom 안에서만 보존) + const [statusOverrides, setStatusOverrides] = useState>(() => { + const init: Record = {}; + if (sheet.remnants) { + sheet.remnants.forEach((rm) => { + init[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = rm.status; + }); + } + return init; + }); + + // 자투리 그룹 선택 + 사용자 분할 override (선언 순서: useMemo보다 먼저) + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [groupSplitOverrides, setGroupSplitOverrides] = useState>({}); + + // placements 변경 시 자투리 자동 재추출 + groupSplitOverrides 적용 + statusOverrides 매핑 + // 각 자투리에 groupKey 메타 부여 (자동 추출 그룹의 canonical key) + const localRemnants: (RemnantItem & { groupKey: string })[] = useMemo(() => { + const fresh = extractInitialRemnants({ ...sheet, placements, remnants: undefined }, `s${sheet.id}-`, kerf); + const groups = computeRemnantGroups(fresh); + const result: (RemnantItem & { groupKey: string })[] = []; + groups.forEach((g, gi) => { + const key = [...g.rects].sort((a, b) => a.y - b.y || a.x - b.x).map((r) => `${r.x},${r.y},${r.w},${r.h}`).join("|"); + const override = groupSplitOverrides[key]; + if (override && override.length > 0) { + override.forEach((r, ri) => { + const k = `${r.x}|${r.y}|${r.w}|${r.h}`; + result.push({ + id: `s${sheet.id}-g${gi}-${ri}`, + x: r.x, y: r.y, w: r.w, h: r.h, + status: statusOverrides[k] || "discard", + groupKey: key, + }); + }); + } else { + g.rects.forEach((rm) => { + const k = `${rm.x}|${rm.y}|${rm.w}|${rm.h}`; + result.push({ ...rm, status: statusOverrides[k] || "discard", groupKey: key }); + }); + } + }); + return result; + }, [placements, sheet, statusOverrides, groupSplitOverrides, kerf]); + + // 자투리 그룹화 — groupKey 기반 (자동 추출 그룹 ID 보존, override 적용 후에도 매칭 정확) + const remnantGroups = useMemo(() => { + const map = new Map(); + localRemnants.forEach((rm) => { + const arr = map.get(rm.groupKey) || []; + arr.push(rm); + map.set(rm.groupKey, arr); + }); + return [...map.entries()].map(([key, rects], gi) => { + const keepCount = rects.filter((r) => r.status === "keep").length; + const discardCount = rects.length - keepCount; + const status: "keep" | "discard" | "mixed" = + keepCount === rects.length ? "keep" : + keepCount === 0 ? "discard" : "mixed"; + const outline = computeGroupOutline(rects); + const totalArea = rects.reduce((s, r) => s + r.w * r.h, 0); + const minX = Math.min(...rects.map((r) => r.x)); + const minY = Math.min(...rects.map((r) => r.y)); + const maxX = Math.max(...rects.map((r) => r.x + r.w)); + const maxY = Math.max(...rects.map((r) => r.y + r.h)); + return { + groupId: gi, + groupKey: key, + rects, + status, keepCount, discardCount, + outline, totalArea, + bbox: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, + }; + }); + }, [localRemnants]); + + // 그룹 전체 토글 + const toggleGroup = (group: { rects: RemnantItem[]; status: "keep" | "discard" }) => { + const newStatus: "keep" | "discard" = group.status === "keep" ? "discard" : "keep"; + setStatusOverrides((prev) => { + const next = { ...prev }; + group.rects.forEach((rm) => { + next[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = newStatus; + }); + return next; + }); + }; + + // 분할 옵션 미리 계산 (현재 그룹 자투리 → 3가지 분할 결과) + const computeSplitOptions = (rects: RemnantItem[]) => { + const baseRects = rects.map((r) => ({ x: r.x, y: r.y, w: r.w, h: r.h })); + return { + h: decomposeUnion(baseRects, "h"), + v: decomposeUnion(baseRects, "v"), + max: decomposeUnion(baseRects, "max"), + }; + }; + + // 분할 옵션 적용 — 그룹 영역의 자투리를 새 사각형으로 교체 (groupKey는 자동 추출 키) + const applySplitToGroup = ( + group: { rects: RemnantItem[]; groupKey: string; status: "keep" | "discard" | "mixed" }, + newRects: { x: number; y: number; w: number; h: number }[] + ) => { + setGroupSplitOverrides((prev) => ({ ...prev, [group.groupKey]: newRects })); + // status: 그룹이 모두 keep이면 keep, 아니면 discard (mixed였으면 분할 후 다시 결정) + const newStatus: "keep" | "discard" = group.status === "keep" ? "keep" : "discard"; + setStatusOverrides((prev) => { + const next = { ...prev }; + newRects.forEach((r) => { + next[`${r.x}|${r.y}|${r.w}|${r.h}`] = newStatus; + }); + return next; + }); + setSelectedGroupId(null); + }; + + const areaRef = useRef(null); + const dragRef = useRef<{ + pi: number; origX: number; origY: number; startMX: number; startMY: number; + } | null>(null); + + // 모달 열릴 때 배율 계산 + useEffect(() => { + const calc = () => { + const area = areaRef.current; + if (!area) return; + const aw = Math.max(300, area.clientWidth - 36); + const ah = Math.max(200, area.clientHeight - 36); + let s = Math.min(aw / sheet.matW, ah / sheet.matH); + if (s > 1.5) s = 1.5; + if (s < 0.04) s = 0.04; + setScale(s); + }; + const t = setTimeout(calc, 50); + window.addEventListener("resize", calc); + return () => { clearTimeout(t); window.removeEventListener("resize", calc); }; + }, [sheet.matW, sheet.matH]); + + // 선택된 피스 정보 + const selected = selectedPI !== null ? placements[selectedPI] : null; + useEffect(() => { + if (selected) { + setInputX(String(Math.round(selected.x))); + setInputY(String(Math.round(selected.y))); + } else { + setInputX(""); setInputY(""); + } + }, [selectedPI]); + + // 겹침 체크 (kerf 여유 포함) + const collides = ( + list: typeof placements, + idx: number, + x: number, y: number, w: number, h: number + ) => { + for (let i = 0; i < list.length; i++) { + if (i === idx) continue; + const o = list[i]; + if ( + x < o.x + o.w + kerf && + x + w + kerf > o.x && + y < o.y + o.h + kerf && + y + h + kerf > o.y + ) return true; + } + return false; + }; + + // 드래그 + const handleMouseDown = (e: React.MouseEvent, pi: number) => { + e.preventDefault(); e.stopPropagation(); + setSelectedPI(pi); + const p = placements[pi]; + dragRef.current = { + pi, origX: p.x, origY: p.y, + startMX: e.clientX, startMY: e.clientY, + }; + const onMove = (ev: MouseEvent) => { + const drag = dragRef.current; + if (!drag) return; + const dx = (ev.clientX - drag.startMX) / scale; + const dy = (ev.clientY - drag.startMY) / scale; + setPlacements((prev) => { + if (drag.pi >= prev.length) return prev; + const p = { ...prev[drag.pi] }; + const tx = Math.max(0, Math.min(sheet.matW - p.w, drag.origX + dx)); + const ty = Math.max(0, Math.min(sheet.matH - p.h, drag.origY + dy)); + const others = prev.filter((_, i) => i !== drag.pi); + // X 방향: 인접 piece 변에서 정확히 kerf 거리에 snap + if (tx !== p.x) { + if (tx > p.x) { + let bound = sheet.matW - p.w; + for (const o of others) { + if (o.y + o.h + kerf > p.y && o.y - kerf < p.y + p.h) { + const limit = o.x - p.w - kerf; + if (limit >= p.x && limit < bound) bound = limit; + } + } + p.x = Math.max(p.x, Math.min(tx, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.y + o.h + kerf > p.y && o.y - kerf < p.y + p.h) { + const limit = o.x + o.w + kerf; + if (limit <= p.x && limit > bound) bound = limit; + } + } + p.x = Math.min(p.x, Math.max(tx, bound)); + } + } + // Y 방향: 동일하게 snap + if (ty !== p.y) { + if (ty > p.y) { + let bound = sheet.matH - p.h; + for (const o of others) { + if (o.x + o.w + kerf > p.x && o.x - kerf < p.x + p.w) { + const limit = o.y - p.h - kerf; + if (limit >= p.y && limit < bound) bound = limit; + } + } + p.y = Math.max(p.y, Math.min(ty, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.x + o.w + kerf > p.x && o.x - kerf < p.x + p.w) { + const limit = o.y + o.h + kerf; + if (limit <= p.y && limit > bound) bound = limit; + } + } + p.y = Math.min(p.y, Math.max(ty, bound)); + } + } + const next = [...prev]; + next[drag.pi] = p; + return next; + }); + }; + const onUp = () => { + dragRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + // X/Y 입력 반영 + const applyInput = (axis: "x" | "y", value: string) => { + if (selectedPI === null) return; + const v = parseFloat(value) || 0; + setPlacements((prev) => { + const p = { ...prev[selectedPI] }; + const nx = axis === "x" ? Math.max(0, Math.min(sheet.matW - p.w, v)) : p.x; + const ny = axis === "y" ? Math.max(0, Math.min(sheet.matH - p.h, v)) : p.y; + if (collides(prev, selectedPI, nx, ny, p.w, p.h)) { + toast.error("다른 조각과 겹칩니다"); + return prev; + } + p.x = nx; p.y = ny; + const next = [...prev]; + next[selectedPI] = p; + return next; + }); + }; + + // 회전 + const rotateSelected = () => { + if (selectedPI === null) return; + const item = planItems[placements[selectedPI].itemIdx]; + if (item && item.dir !== "무관") { toast.error("방향 지정 품목은 회전 불가"); return; } + setPlacements((prev) => { + const p = { ...prev[selectedPI] }; + const nw = p.h, nh = p.w; + const nx = Math.max(0, Math.min(sheet.matW - nw, p.x)); + const ny = Math.max(0, Math.min(sheet.matH - nh, p.y)); + if (collides(prev, selectedPI, nx, ny, nw, nh)) { + toast.error("회전 시 다른 조각과 겹칩니다"); + return prev; + } + p.w = nw; p.h = nh; p.x = nx; p.y = ny; p.rotated = !p.rotated; + const next = [...prev]; + next[selectedPI] = p; + return next; + }); + }; + + // 삭제 + const deleteSelected = () => { + if (selectedPI === null) return; + setPlacements((prev) => prev.filter((_, i) => i !== selectedPI)); + setSelectedPI(null); + }; + + // 여백 계산 + const gapL = selected ? Math.round(selected.x) : 0; + const gapR = selected ? Math.round(sheet.matW - selected.x - selected.w) : 0; + const gapT = selected ? Math.round(selected.y) : 0; + const gapB = selected ? Math.round(sheet.matH - selected.y - selected.h) : 0; + + // 격자 간격 + const gridMM = sheet.matW >= 2000 ? 200 : 100; + + const DW = Math.round(sheet.matW * scale); + const DH = Math.round(sheet.matH * scale); + + return ( + !o && onClose()}> + + + + + #{sheetIdx + 1} {sheet.matName} ({sheet.matW}×{sheet.matH}mm) — 확대 편집 + +
+ 배율: {(scale * 100).toFixed(0)}% + + +
+
+ +
+ {/* 원판 영역 */} +
+
+ {/* 격자 (SVG) */} + + {Array.from({ length: Math.floor(sheet.matW / gridMM) }, (_, i) => (i + 1) * gridMM).map((mm) => ( + + + {mm} + + ))} + {Array.from({ length: Math.floor(sheet.matH / gridMM) }, (_, i) => (i + 1) * gridMM).map((mm) => ( + + + {mm} + + ))} + + {/* 시트 라벨 */} +
+ {sheet.matW}×{sheet.matH}mm +
+ {/* 자투리 그룹 — 같은 그룹은 한 도형으로 표시 (fill + polygon outline) */} + {remnantGroups.map((g) => { + const isSelected = selectedGroupId === g.groupId; + const outlineColor = + g.status === "keep" ? "rgba(37,99,235,0.85)" : + g.status === "discard" ? "rgba(245,158,11,0.8)" : + "rgba(139,92,246,0.85)"; // mixed: violet + return ( + + {/* 그룹 내 사각형 fill — 각 조각의 status별 색상 (개별 표시) */} + {g.rects.map((rm) => { + const rx = Math.round(rm.x * scale); + const ry = Math.round(rm.y * scale); + const rw = Math.max(2, Math.round(rm.w * scale)); + const rh = Math.max(2, Math.round(rm.h * scale)); + const rmKeep = rm.status === "keep"; + const groupNewStatus: "keep" | "discard" = g.status === "keep" ? "discard" : "keep"; + return ( +
{ + e.stopPropagation(); + setSelectedPI(null); + setSelectedGroupId(g.groupId); + // Shift+클릭 = 그 조각만 토글, 일반 클릭 = 그룹 전체 토글 + if (e.shiftKey) { + setStatusOverrides((prev) => ({ + ...prev, + [`${rm.x}|${rm.y}|${rm.w}|${rm.h}`]: rmKeep ? "discard" : "keep", + })); + } else { + setStatusOverrides((prev) => { + const next = { ...prev }; + g.rects.forEach((r) => { + next[`${r.x}|${r.y}|${r.w}|${r.h}`] = groupNewStatus; + }); + return next; + }); + } + }} + title={`${rmKeep ? "[보관]" : "[폐기]"} ${rm.w}×${rm.h} — 클릭: 그룹 토글 / Shift+클릭: 이 조각만`} + className={cn( + "absolute cursor-pointer transition-colors hover:brightness-110", + rmKeep ? "bg-blue-300/40" : "bg-amber-300/25", + isSelected && "brightness-125" + )} + style={{ + left: rx, top: ry, width: rw, height: rh, + border: g.rects.length > 1 ? "1px solid rgba(255,255,255,0.5)" : "none", + }} + /> + ); + })} + {/* 그룹 외곽선 polygon (SVG) */} + + {g.outline.map((path, pi) => { + const d = path.points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x * scale} ${p.y * scale}`) + .join(" ") + " Z"; + return ( + + ); + })} + + {/* 그룹 라벨 — 가장 큰 자투리 사각형 안 좌상단 (piece와 안 겹침) */} + {(() => { + const largest = g.rects.reduce((a, b) => a.w * a.h > b.w * b.h ? a : b); + return ( +
+ + {g.status === "keep" ? "보관" : g.status === "discard" ? "폐기" : `혼합 ${g.keepCount}/${g.rects.length}`} + {g.rects.length > 1 && ` · ${g.rects.length}조각`} + +
+ ); + })()} + + ); + })} + {/* 피스 */} + {placements.map((p, pi) => { + const px = Math.round(p.x * scale); + const py = Math.round(p.y * scale); + const pw = Math.max(5, Math.round(p.w * scale)); + const ph = Math.max(5, Math.round(p.h * scale)); + const isSel = pi === selectedPI; + return ( +
handleMouseDown(e, pi)} + onDoubleClick={(e) => { e.stopPropagation(); setSelectedPI(pi); rotateSelected(); }} + className={cn( + "absolute flex items-center justify-center overflow-hidden cursor-grab select-none", + isSel && "ring-4 ring-blue-600 ring-offset-1 z-30" + )} + style={{ + left: px, top: py, width: pw, height: ph, + background: p.color, opacity: 0.88, + border: p.rotated ? "2px solid rgba(255,255,255,0.9)" : "1px solid rgba(255,255,255,0.5)", + }} + > + {pw > 40 && ph > 22 && ( +
+
{p.name}
+
{p.w}×{p.h}mm
+
+ )} + {p.rotated && ( + + )} +
+ ); + })} +
+
+ + {/* 사이드 패널 */} +
+
+
📌 선택된 피스
+ {selected ? ( +
+
{selected.name}
+
+ + { setInputX(e.target.value); applyInput("x", e.target.value); }} + className="h-7 w-[80px] text-xs" /> + mm +
+
+ + { setInputY(e.target.value); applyInput("y", e.target.value); }} + className="h-7 w-[80px] text-xs" /> + mm +
+
+ 크기: {selected.w}×{selected.h}mm{selected.rotated ? " [회전됨]" : ""} +
+
+ ) : ( +
피스를 클릭하여 선택하세요
+ )} +
+
+
📏 원판 내 여백
+
+
좌: {selected ? `${gapL}mm` : "-"}
+
우: {selected ? `${gapR}mm` : "-"}
+
상: {selected ? `${gapT}mm` : "-"}
+
하: {selected ? `${gapB}mm` : "-"}
+
+
+ {/* 자투리 관리 패널 — 항상 표시 */} +
+
+ ✂️ 자투리 그룹 {remnantGroups.length}개 +
+ {remnantGroups.length === 0 ? ( +
자투리가 없습니다
+ ) : ( +
+ {remnantGroups.map((g) => { + const isSelected = selectedGroupId === g.groupId; + const groupNewStatus: "keep" | "discard" = g.status === "keep" ? "discard" : "keep"; + return ( +
{ setSelectedGroupId(g.groupId); setSelectedPI(null); }} + className={cn( + "flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer text-[10px]", + isSelected ? "bg-red-100 ring-1 ring-red-400" : "hover:bg-muted" + )} + > + + {g.status === "keep" ? "보관" : g.status === "discard" ? "폐기" : `혼합${g.keepCount}/${g.rects.length}`} + + {g.rects.length}조각 · {g.totalArea.toLocaleString()}mm² + +
+ ); + })} +
+ )} +
+ {/* 선택된 자투리 그룹 옵션 */} + {selectedGroupId !== null && (() => { + const g = remnantGroups.find((x) => x.groupId === selectedGroupId); + if (!g) return null; + const options = computeSplitOptions(g.rects); + const hasOverride = !!groupSplitOverrides[g.groupKey]; + const setAllInGroup = (status: "keep" | "discard") => { + setStatusOverrides((prev) => { + const next = { ...prev }; + g.rects.forEach((rm) => { + next[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = status; + }); + return next; + }); + }; + const toggleSingle = (rm: RemnantItem) => { + setStatusOverrides((prev) => ({ + ...prev, + [`${rm.x}|${rm.y}|${rm.w}|${rm.h}`]: rm.status === "keep" ? "discard" : "keep", + })); + }; + return ( +
+
+ ✂️ 선택된 자투리 그룹 + +
+
+ {g.rects.length}조각 · 합 {g.totalArea.toLocaleString()}mm² · BBox {g.bbox.w}×{g.bbox.h} + {g.status === "mixed" && · 보관 {g.keepCount}/{g.rects.length}} +
+ {/* 그룹 일괄 토글 */} +
+ + +
+ {/* 조각별 개별 토글 */} +
조각별 보관/폐기 ({g.rects.length}개)
+
+ {g.rects.map((rm) => ( + + ))} +
+
분할 방식 적용
+
+ + + +
+ {hasOverride && ( + + )} +
+ ); + })()} +
+
+ 📋 피스 목록 {placements.length}개 +
+
+ {placements.map((p, pi) => ( +
setSelectedPI(pi)} + className={cn( + "flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer text-[11px]", + pi === selectedPI ? "bg-primary/20 font-bold" : "hover:bg-muted" + )} + > +
+ {p.name} + {p.w}×{p.h} + {p.rotated && } +
+ ))} +
+
+
+
+ + + + 드래그: 이동 | 더블클릭: 회전 | X/Y 입력: 정밀 이동 + +
+ + +
+
+ +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 길이형 배치 시각화 +// ───────────────────────────────────────────────────────── +function LengthBatchView({ result }: { result: LengthResult }) { + const groups = result.pipeGroups || result.pipes.map((p, pi) => ({ + count: 1, repIdx: pi, representative: p, indices: [pi], + })); + const totalPipes = result.pipes.length; + const hasGroups = groups.some((g) => g.count > 1); + const DW = 560; + + if (!result.pipes.length) return null; + + return ( +
+ {hasGroups && ( +
+ 📏 총 {totalPipes}개 필요 + + {groups.length}가지 절단 패턴 +
+ )} + {groups.map((g) => { + const pipe = g.representative; + const scale = DW / pipe.matLen; + const first = Math.min(...g.indices) + 1; + const last = Math.max(...g.indices) + 1; + const rem = Math.max(0, pipe.remaining); + return ( +
+
+ 파이프 #{g.count > 1 ? `${first}~${last}` : first} + ({pipe.matName}) + {g.count > 1 && ( + ×{g.count}개 동일 + )} + 잔재: {rem}mm ({((rem/pipe.matLen)*100).toFixed(1)}%) +
+
+ {pipe.segments.map((seg, si) => { + const sw = Math.max(2, Math.round(seg.len * scale)); + return ( +
+ {sw > 40 ? `${seg.len}mm` : ""} +
+ ); + })} + {rem > 0 && ( +
+ {Math.max(2, Math.round(rem * scale)) > 30 ? `${rem}mm` : ""} +
+ )} +
+
+ ); + })} +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 자투리 관리 뷰 +// ───────────────────────────────────────────────────────── +function RemnantView({ + batchResult, cutType, minReuse, setMinReuse, getSheetRemnants, onToggleGroupStatus, onSetAllStatus, +}: { + batchResult: AreaResult | LengthResult | null; + cutType: CutType; + minReuse: number; + setMinReuse: (n: number) => void; + getSheetRemnants: (sheet: Sheet) => RemnantItem[]; + onToggleGroupStatus: (sheetId: number, remIds: string[], status: "keep" | "discard") => void; + onSetAllStatus: (status: "keep" | "discard") => void; +}) { + const rows = useMemo(() => { + if (!batchResult) return []; + if (cutType === "area") { + const r = batchResult as AreaResult; + // sheet별로 자투리 그룹화 → 그룹 단위 row 생성 + type Row = { + sheetId: number; sheetIdx: number; remIds: string[]; + matName: string; spec: string; pieces: number; + totalArea: number; bboxW: number; bboxH: number; + canReuse: boolean; status: "keep" | "discard"; + }; + const list: Row[] = []; + r.sheets.forEach((sheet, si) => { + const rems = getSheetRemnants(sheet); + const groups = computeRemnantGroups(rems); + groups.forEach((g) => { + // 한 그룹이 keep/discard 혼합일 수 있으므로 status 별로 분리하여 row 생성 + (["keep", "discard"] as const).forEach((st) => { + const rects = g.rects.filter((x) => x.status === st); + if (rects.length === 0) return; + const totalArea = rects.reduce((s, x) => s + x.w * x.h, 0); + const minX = Math.min(...rects.map((x) => x.x)); + const minY = Math.min(...rects.map((x) => x.y)); + const maxX = Math.max(...rects.map((x) => x.x + x.w)); + const maxY = Math.max(...rects.map((x) => x.y + x.h)); + const bboxW = maxX - minX; + const bboxH = maxY - minY; + const spec = rects.length === 1 + ? `${rects[0].w}×${rects[0].h}` + : `${bboxW}×${bboxH} (${rects.length}조각 합 ${totalArea.toLocaleString()}mm²)`; + const minDim = rects.length === 1 ? Math.min(rects[0].w, rects[0].h) : Math.min(bboxW, bboxH); + const canReuse = minDim >= minReuse; + list.push({ + sheetId: sheet.id, sheetIdx: si, + remIds: rects.map((x) => x.id), + matName: sheet.matName, + spec, pieces: rects.length, + totalArea, bboxW, bboxH, + canReuse, + status: st, + }); + }); + }); + }); + list.sort((a, b) => b.totalArea - a.totalArea); + return list.map((row, gi) => ({ + no: gi + 1, + sheetId: row.sheetId, + remIds: row.remIds, + range: `Sheet #${row.sheetIdx + 1}`, + count: row.pieces, + matName: row.matName, + spec: row.spec, + remVal: row.totalArea.toLocaleString(), + totalRemVal: row.totalArea.toLocaleString(), + util: "-", + canReuse: row.canReuse, + status: row.status, + })); + } else { + const r = batchResult as LengthResult; + const groups = r.pipeGroups || r.pipes.map((p, pi) => ({ count: 1, repIdx: pi, representative: p, indices: [pi] })); + return groups.map((g, gi) => { + const pipe = g.representative; + const remLen = Math.max(0, pipe.remaining); + const usedLen = pipe.segments.reduce((s, seg) => s + seg.len, 0); + const util = pipe.matLen > 0 ? (usedLen / pipe.matLen) * 100 : 0; + const canReuse = remLen >= minReuse; + const first = Math.min(...g.indices) + 1; + const last = Math.max(...g.indices) + 1; + return { + no: gi + 1, + sheetId: 0, + remIds: [] as string[], + range: g.count > 1 ? `#${first}~#${last}` : `#${first}`, + count: g.count, + matName: pipe.matName, + spec: `${remLen}mm`, + remVal: remLen.toLocaleString(), + totalRemVal: (remLen * g.count).toLocaleString(), + util: util.toFixed(1), + canReuse, + status: "discard" as const, + }; + }); + } + }, [batchResult, cutType, minReuse, getSheetRemnants]); + + const summary = useMemo(() => { + const keep = rows.filter((r) => r.status === "keep"); + const discard = rows.filter((r) => r.status !== "keep"); + return { + keepKinds: keep.length, + keepCount: keep.length, + discardKinds: discard.length, + discardCount: discard.length, + }; + }, [rows]); + + return ( +
+
+
+
+ + 자투리 목록 + {rows.length}개 +
+ {cutType === "area" && rows.length > 0 && ( + <> +
+ + 보관 {summary.keepCount}개 + + + 폐기 {summary.discardCount}개 + + + )} +
+
+ {cutType === "area" && rows.length > 0 && ( + <> + + +
+ + )} + + setMinReuse(+e.target.value)} className="h-7 w-[70px] text-xs" /> + mm 이상 +
+
+
+ {rows.length === 0 ? ( +
+ +

계산 실행 후 자투리 정보가 표시됩니다

+
+ ) : ( + + + + No + {cutType === "length" ? "파이프번호" : "원판"} + 원자재명 + {cutType === "length" ? "잔재 길이" : "자투리 규격"} + {cutType === "length" ? "손실 길이" : "단위 면적"} + {cutType === "length" ? "이용률" : "총 면적"} + 재사용 + {cutType === "area" && 처리} + + + + {rows.map((r) => ( + + {r.no} + + {r.range} + {cutType === "length" && r.count > 1 && ×{r.count}} + + {r.matName} + {r.spec} + + {r.remVal} + + + {cutType === "length" ? ( + <> +
{r.util}%
+
+
+
+ + ) : ( + {r.totalRemVal} + )} + + + + {r.canReuse ? "가능" : "불가"} + + + {cutType === "area" && ( + + + + )} + + ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx index 110b2b4d..c5a2ecda 100644 --- a/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/work-instruction/page.tsx @@ -647,6 +647,7 @@ export default function WorkInstructionPage() { { key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" }, { key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, { key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" }, + { key: "batch_no", label: "배치번호", width: "w-[130px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v ? {v} : -) : "" }, { key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => { const isFirstOfGroup = Number(row.detail_seq) === 1; if (!isFirstOfGroup) return null; @@ -911,6 +912,9 @@ export default function WorkInstructionPage() { 순번 + {editOrder?.batch_no ? ( + 배치번호 + ) : null} 품목코드 품목명 규격 @@ -928,10 +932,13 @@ export default function WorkInstructionPage() { {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} + {editOrder?.batch_no ? ( + {editOrder.batch_no} + ) : null} {item.itemCode} {item.itemName || "-"} {item.spec || "-"} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index b0ec2729..3662876a 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -340,6 +340,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_9/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_9/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_9/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_9/production/plan-management/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_9/production/bom": dynamic(() => import("@/app/(main)/COMPANY_9/production/bom/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_9/production/cutting-plan": dynamic(() => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_9/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_9/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_9/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }), @@ -572,6 +573,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/COMPANY_9/production/work-instruction": () => import("@/app/(main)/COMPANY_9/production/work-instruction/page"), "/COMPANY_9/production/plan-management": () => import("@/app/(main)/COMPANY_9/production/plan-management/page"), "/COMPANY_9/production/bom": () => import("@/app/(main)/COMPANY_9/production/bom/page"), + "/COMPANY_9/production/cutting-plan": () => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"), "/COMPANY_9/equipment/info": () => import("@/app/(main)/COMPANY_9/equipment/info/page"), "/COMPANY_9/equipment/plc-settings": () => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"), "/COMPANY_9/monitoring/production": () => import("@/app/(main)/COMPANY_9/monitoring/production/page"),