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_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_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_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/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/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")} 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"),