From 173b85b4765c84595720bfe105ab05f7ebf482e7 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 17 Apr 2026 13:11:01 +0900 Subject: [PATCH] feat: Implement copy functionality for item inspection information - Added a modal for copying inspection information from a selected item to multiple target items. - Implemented search and selection logic for target items to facilitate the copying process. - Included validation to ensure a source item is selected and that target items are valid before proceeding with the copy operation. - Enhanced user feedback with toast notifications for successful and error states during the copy process. - Updated BOM management to include unit label handling for better clarity in item representation. --- .../tableCategoryValueController.ts | 4 +- .../src/services/categoryTreeService.ts | 5 +- .../src/services/tableCategoryValueService.ts | 7 +- .../COMPANY_10/master-data/options/page.tsx | 97 +++++++- .../(main)/COMPANY_10/production/bom/page.tsx | 13 +- .../COMPANY_10/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../COMPANY_16/master-data/options/page.tsx | 99 +++++++- .../(main)/COMPANY_16/production/bom/page.tsx | 13 +- .../COMPANY_16/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../COMPANY_29/master-data/options/page.tsx | 97 +++++++- .../(main)/COMPANY_29/production/bom/page.tsx | 13 +- .../COMPANY_29/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../COMPANY_30/master-data/options/page.tsx | 97 +++++++- .../(main)/COMPANY_30/production/bom/page.tsx | 13 +- .../COMPANY_30/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../(main)/COMPANY_30/sales/customer/page.tsx | 2 +- .../(main)/COMPANY_30/sales/order/page.tsx | 2 +- .../COMPANY_7/master-data/options/page.tsx | 97 +++++++- .../(main)/COMPANY_7/production/bom/page.tsx | 13 +- .../COMPANY_7/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../COMPANY_8/master-data/options/page.tsx | 97 +++++++- .../(main)/COMPANY_8/production/bom/page.tsx | 13 +- .../COMPANY_8/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../COMPANY_9/master-data/options/page.tsx | 97 +++++++- .../(main)/COMPANY_9/production/bom/page.tsx | 13 +- .../COMPANY_9/quality/inspection/page.tsx | 141 ++++++----- .../quality/item-inspection/page.tsx | 230 +++++++++++++++++- .../(main)/COMPANY_9/sales/customer/page.tsx | 2 +- .../app/(main)/COMPANY_9/sales/order/page.tsx | 2 +- frontend/components/common/EDataTable.tsx | 4 +- frontend/lib/api/tableCategoryValue.ts | 6 +- 37 files changed, 2944 insertions(+), 459 deletions(-) diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index cff7ccfa..508b3159 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response const includeInactive = req.query.includeInactive === "true"; const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined; const filterCompanyCode = req.query.filterCompanyCode as string | undefined; + const topLevelOnly = req.query.topLevelOnly === "true"; // 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용 const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode) @@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response columnName, effectiveCompanyCode, includeInactive, - menuObjid + menuObjid, + topLevelOnly ); return res.json({ diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 462a5191..b236a7f5 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -223,13 +223,14 @@ class CategoryTreeService { const query = ` INSERT INTO category_values ( - table_name, column_name, value_code, value_label, value_order, + value_id, table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, path, description, color, icon, is_active, is_default, company_code, created_by, updated_by ) VALUES ( + (SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15 ) - RETURNING + RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 16bc75a2..712bf646 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -167,7 +167,8 @@ class TableCategoryValueService { columnName: string, companyCode: string, includeInactive: boolean = false, - menuObjid?: number + menuObjid?: number, + topLevelOnly: boolean = false ): Promise { try { logger.info("카테고리 값 목록 조회 (메뉴 스코프)", { @@ -235,6 +236,10 @@ class TableCategoryValueService { query += ` AND is_active = true`; } + if (topLevelOnly) { + query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`; + } + query += ` ORDER BY value_order, value_label`; const result = await pool.query(query, params); diff --git a/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx index 7506ad94..6053f27f 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/options/page.tsx @@ -5,7 +5,12 @@ import { Settings2, Tags, Hash } from "lucide-react"; import { cn } from "@/lib/utils"; import { CategoryColumnList } from "@/components/table-category/CategoryColumnList"; import { CategoryValueManager } from "@/components/table-category/CategoryValueManager"; +import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree"; import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; const TABS = [ { id: "category", label: "카테고리 설정", icon: Tags }, @@ -21,6 +26,12 @@ export default function OptionsSettingPage() { const [selectedColumnLabel, setSelectedColumnLabel] = useState(""); const [selectedTableName, setSelectedTableName] = useState(""); + const [useHierarchy, setUseHierarchy] = useState(false); + const [hasChildRows, setHasChildRows] = useState(false); + const [detectingHierarchy, setDetectingHierarchy] = useState(false); + + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const [leftWidth, setLeftWidth] = useState(340); const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); @@ -51,6 +62,71 @@ export default function OptionsSettingPage() { }; }, [isDragging]); + useEffect(() => { + if (!selectedColumn || !selectedTableName) { + setUseHierarchy(false); + setHasChildRows(false); + return; + } + const columnNameOnly = selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn; + let cancelled = false; + setDetectingHierarchy(true); + (async () => { + const res = await getCategoryValues(selectedTableName, columnNameOnly, true); + if (cancelled) return; + const values = (res as any)?.data || []; + const hasChild = Array.isArray(values) + ? values.some( + (v: any) => + (typeof v.depth === "number" && v.depth > 1) || + (v.parentValueId !== null && v.parentValueId !== undefined), + ) + : false; + setHasChildRows(hasChild); + setUseHierarchy(hasChild); + setDetectingHierarchy(false); + })(); + return () => { + cancelled = true; + }; + }, [selectedColumn, selectedTableName]); + + const handleToggleHierarchy = useCallback( + async (checked: boolean) => { + if (!checked && hasChildRows) { + const ok = await confirm( + "이미 등록된 하위분류(중/소분류)가 있습니다.\n하위분류 사용을 해제해도 기존 데이터는 삭제되지 않으며, 다시 사용 설정 시 그대로 복원됩니다.\n계속하시겠습니까?", + { variant: "destructive", confirmText: "해제" }, + ); + if (!ok) return; + } + setUseHierarchy(checked); + }, + [hasChildRows, confirm], + ); + + const columnNameOnly = selectedColumn + ? selectedColumn.includes(".") + ? selectedColumn.split(".").pop()! + : selectedColumn + : ""; + + const headerRight = selectedColumn ? ( +
+ + +
+ ) : null; + return (
@@ -108,11 +184,21 @@ export default function OptionsSettingPage() {
{selectedColumn && selectedTableName ? ( - + useHierarchy ? ( + + ) : ( + + ) ) : (
@@ -131,6 +217,7 @@ export default function OptionsSettingPage() {
)}
+ {ConfirmDialogComponent}
); } diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index 8c7b582b..c8618672 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -497,12 +497,14 @@ export default function BomManagementPage() { const c = code.trim(); return categoryOptions["division"]?.find((o) => o.code === c)?.label || c; }).filter((v: string) => v && v !== "s").join(", "); + const rawUnit = d.unit || item?.inventory_unit || ""; + const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit; return { ...d, item_number: item?.item_number || "", item_name: item?.item_name || "", item_type: divisionLabel, - unit: d.unit || item?.inventory_unit || "", + unit: unitLabel, spec: item?.size || item?.spec || "", writer: d.writer || "", updated_date: d.updated_at || d.updated_date || "", @@ -818,6 +820,15 @@ export default function BomManagementPage() { return; } + // 같은 레벨(같은 부모) 중복 품목 체크 + const siblings = addTargetParentId + ? (findNodeById(editingTree, addTargetParentId)?.children || []) + : editingTree; + if (siblings.some((n) => n.child_item_id === item.id)) { + toast.error("같은 레벨에 이미 동일 품목이 존재합니다"); + return; + } + const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0; diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx index 4602a719..b1998a64 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx @@ -31,6 +31,7 @@ import { Settings2, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { ImageUpload } from "@/components/common/ImageUpload"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; @@ -59,11 +60,12 @@ const DEFECT_TABLE = "defect_standard_mng"; const EQUIPMENT_TABLE = "inspection_equipment_mng"; /* ───── 카테고리 flatten ───── */ -const flattenCategories = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; +type CatOption = { code: string; label: string; depth: number; parentCode?: string }; +const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => { + const result: CatOption[] = []; for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flattenCategories(v.children)); + result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode }); + if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode)); } return result; }; @@ -113,13 +115,13 @@ export default function InspectionManagementPage() { const [previewCode, setPreviewCode] = useState(null); /* ───── 카테고리 옵션 ───── */ - const [catOptions, setCatOptions] = useState>({}); + const [catOptions, setCatOptions] = useState>({}); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); /* ═══════════════════ 카테고리 로드 ═══════════════════ */ useEffect(() => { const load = async () => { - const optMap: Record = {}; + const optMap: Record = {}; const catList = [ { table: INSPECTION_TABLE, col: "inspection_type" }, { table: INSPECTION_TABLE, col: "apply_type" }, @@ -867,7 +869,7 @@ export default function InspectionManagementPage() { .filter(Boolean) .map((t: string) => ( - {t} + {getCatLabel(DEFECT_TABLE, "inspection_type", t)} )) : "-"} @@ -945,6 +947,9 @@ export default function InspectionManagementPage() { onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map((r) => r.id) : [])} /> + + 이미지 + 장비코드 @@ -980,13 +985,13 @@ export default function InspectionManagementPage() { {eqLoading ? ( - + ) : filteredEquipments.length === 0 ? ( - +

등록된 검사장비가 없어요

@@ -1015,6 +1020,18 @@ export default function InspectionManagementPage() { } />
+ + {row.image_path ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : ( +
+ )} + {row.equipment_code || "-"} {row.equipment_name || "-"} @@ -1421,24 +1438,26 @@ export default function InspectionManagementPage() { 검사유형 * (다중선택)
- {(catOptions[`${DEFECT_TABLE}.inspection_type`] || []).map((o) => { - const types: string[] = defForm.inspection_type - ? defForm.inspection_type.split(",").filter(Boolean) - : []; - const checked = types.includes(o.code); - return ( -
- { - const next = v ? [...types, o.code] : types.filter((t) => t !== o.code); - setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" })); - }} - /> - -
- ); - })} + {(catOptions[`${DEFECT_TABLE}.inspection_type`] || []) + .filter((o) => o.depth === 1) + .map((o) => { + const types: string[] = defForm.inspection_type + ? defForm.inspection_type.split(",").filter(Boolean) + : []; + const checked = types.includes(o.code); + return ( +
+ { + const next = v ? [...types, o.code] : types.filter((t) => t !== o.code); + setDefForm((p) => ({ ...p, inspection_type: next.join(","), apply_target: "" })); + }} + /> + +
+ ); + })}
{/* 적용대상 (다중선택, 검사유형별 동적) */} @@ -1451,38 +1470,37 @@ export default function InspectionManagementPage() { : []; if (selectedTypes.length === 0) return

검사유형을 먼저 선택하세요

; - const typeTargetMap: Record = {}; const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || []; - for (const code of selectedTypes) { - const label = defInspOpts.find((o) => o.code === code)?.label || ""; - if (label.includes("수입")) - typeTargetMap[label] = ["구매입고", "외주입고", "반품입고", "무상입고", "기타입고"]; - else if (label.includes("공정")) - typeTargetMap[label] = ["가공", "조립", "도장", "열처리", "표면처리", "용접"]; - else if (label.includes("출하")) - typeTargetMap[label] = ["국내출하", "수출출하", "반품출하", "샘플출하"]; - else if (label.includes("최종")) typeTargetMap[label] = ["완제품", "반제품", "부품"]; - } const targets: string[] = defForm.apply_target ? defForm.apply_target.split(",").filter(Boolean) : []; - return Object.entries(typeTargetMap).map(([typeName, opts]) => ( -
-

{typeName}

-
- {opts.map((t) => ( -
- { - const next = v ? [...targets, t] : targets.filter((x) => x !== t); - setDefForm((p) => ({ ...p, apply_target: next.join(",") })); - }} - /> - + return selectedTypes.map((parentCode: string) => { + const parentLabel = defInspOpts.find((o) => o.code === parentCode)?.label || parentCode; + const children = defInspOpts.filter((o) => o.parentCode === parentCode); + return ( +
+

{parentLabel}

+ {children.length === 0 ? ( +

+ 하위분류가 없습니다. 옵션설정에서 "{parentLabel}"의 하위분류를 등록해주세요. +

+ ) : ( +
+ {children.map((t) => ( +
+ { + const next = v ? [...targets, t.code] : targets.filter((x) => x !== t.code); + setDefForm((p) => ({ ...p, apply_target: next.join(",") })); + }} + /> + +
+ ))}
- ))} + )}
-
- )); + ); + }); })()}
@@ -1710,7 +1728,18 @@ export default function InspectionManagementPage() {
- {/* Row 5: 비고 (full width) */} + {/* Row 5: 이미지 (full width) */} +
+ + setEqForm((p) => ({ ...p, image_path: v }))} + tableName={EQUIPMENT_TABLE} + recordId={eqForm.id} + columnName="image_path" + /> +
+ {/* Row 6: 비고 (full width) */}