From cb4fa2aabaabda2926a4e73c29fbe676a12b803f Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 18:22:54 +0900 Subject: [PATCH 01/15] feat: Implement default version management for routing versions - Added functionality to set and unset default versions for routing items. - Introduced new API endpoints for setting and unsetting default versions. - Enhanced the ItemRoutingComponent to support toggling default versions with user feedback. - Updated database queries to handle default version logic effectively. - Improved the overall user experience by allowing easy management of routing versions. --- .../processWorkStandardController.ts | 165 ++++++- .../src/routes/processWorkStandardRoutes.ts | 4 + .../SplitPanelLayoutComponent.tsx | 23 +- .../v2-item-routing/ItemRoutingComponent.tsx | 103 ++-- .../v2-item-routing/hooks/useItemRouting.ts | 98 +++- .../components/v2-item-routing/types.ts | 1 + .../components/DetailFormModal.tsx | 445 ++++++++++++++++++ .../components/InspectionStandardLookup.tsx | 187 ++++++++ .../components/ItemProcessSelector.tsx | 2 +- .../components/WorkItemDetailList.tsx | 370 +++++---------- .../v2-process-work-standard/config.ts | 8 +- .../hooks/useProcessWorkStandard.ts | 2 +- .../v2-process-work-standard/types.ts | 23 + 13 files changed, 1084 insertions(+), 347 deletions(-) create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx create mode 100644 frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index 7c38d6d9..e72f6b9f 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon if (search) params.push(`%${search}%`); const query = ` - SELECT DISTINCT + SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code + i.${codeColumn} AS item_code, + COUNT(rv.id) AS routing_count FROM ${tableName} i - INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} AND rv.company_code = i.company_code WHERE i.company_code = $1 ${searchCondition} - ORDER BY i.${codeColumn} + GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date + ORDER BY i.created_date DESC NULLS LAST `; const result = await getPool().query(query, params); @@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R // 라우팅 버전 목록 const versionsQuery = ` - SELECT id, version_name, description, created_date + SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default FROM ${routingVersionTable} WHERE ${routingFkColumn} = $1 AND company_code = $2 - ORDER BY created_date DESC + ORDER BY is_default DESC, created_date DESC `; const versionsResult = await getPool().query(versionsQuery, [ itemCode, @@ -127,6 +129,92 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R } } +// ============================================================ +// 기본 버전 설정 +// ============================================================ + +/** + * 라우팅 버전을 기본 버전으로 설정 + * 같은 품목의 다른 버전은 기본 해제 + */ +export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { versionId } = req.params; + const { + routingVersionTable = "item_routing_version", + routingFkColumn = "item_code", + } = req.body; + + await client.query("BEGIN"); + + const versionResult = await client.query( + `SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + if (versionResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); + } + + const itemCode = versionResult.rows[0].item_code; + + await client.query( + `UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`, + [itemCode, companyCode] + ); + + await client.query( + `UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + await client.query("COMMIT"); + + logger.info("기본 버전 설정", { companyCode, versionId, itemCode }); + return res.json({ success: true, message: "기본 버전이 설정되었습니다" }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("기본 버전 설정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 기본 버전 해제 + */ +export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { versionId } = req.params; + const { routingVersionTable = "item_routing_version" } = req.body; + + await getPool().query( + `UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + logger.info("기본 버전 해제", { companyCode, versionId }); + return res.json({ success: true, message: "기본 버전이 해제되었습니다" }); + } catch (error: any) { + logger.error("기본 버전 해제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ============================================================ // 작업 항목 CRUD // ============================================================ @@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons const { workItemId } = req.params; const query = ` - SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date + 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, + created_date FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order, created_date @@ -355,7 +446,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo return res.status(401).json({ success: false, message: "인증 필요" }); } - const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body; + const { + 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, + } = req.body; if (!work_item_id || !content) { return res.status(400).json({ @@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo const query = ` INSERT INTO process_work_item_detail - (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (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) RETURNING * `; @@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo sort_order || 0, remark || null, writer, + inspection_code || null, + inspection_method || null, + unit || null, + lower_limit || null, + upper_limit || null, + duration_minutes || null, + input_type || null, + lookup_target || null, + display_fields || null, ]); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); @@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo } const { id } = req.params; - const { detail_type, content, is_required, sort_order, remark } = req.body; + const { + 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, + } = req.body; const query = ` UPDATE process_work_item_detail @@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo is_required = COALESCE($3, is_required), sort_order = COALESCE($4, sort_order), remark = COALESCE($5, remark), + inspection_code = $8, + inspection_method = $9, + unit = $10, + lower_limit = $11, + upper_limit = $12, + duration_minutes = $13, + input_type = $14, + lookup_target = $15, + display_fields = $16, updated_date = NOW() WHERE id = $6 AND company_code = $7 RETURNING * @@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo remark, id, companyCode, + inspection_code || null, + inspection_method || null, + unit || null, + lower_limit || null, + upper_limit || null, + duration_minutes || null, + input_type || null, + lookup_target || null, + display_fields || null, ]); if (result.rowCount === 0) { @@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { for (const detail of item.details) { await client.query( `INSERT INTO process_work_item_detail - (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + (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)`, [ companyCode, workItemId, @@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { detail.sort_order || 0, detail.remark || null, writer, + detail.inspection_code || null, + detail.inspection_method || null, + detail.unit || null, + detail.lower_limit || null, + detail.upper_limit || null, + detail.duration_minutes || null, + detail.input_type || null, + detail.lookup_target || null, + detail.display_fields || null, ] ); } diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 0c052007..7630b359 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -14,6 +14,10 @@ router.use(authenticateToken); router.get("/items", ctrl.getItemsWithRouting); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); +// 기본 버전 설정/해제 +router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion); +router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion); + // 작업 항목 CRUD router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems); router.post("/work-items", ctrl.createWorkItem); diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index e94b6cce..b7b4191d 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2534,14 +2534,14 @@ export const SplitPanelLayoutComponent: React.FC {group.items.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || idx; + const itemId = item[sourceColumn] || item.id || item.ID; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" @@ -2596,14 +2596,14 @@ export const SplitPanelLayoutComponent: React.FC {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || idx; + const itemId = item[sourceColumn] || item.id || item.ID; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" @@ -2698,7 +2698,8 @@ export const SplitPanelLayoutComponent: React.FC // 재귀 렌더링 함수 const renderTreeItem = (item: any, index: number): React.ReactNode => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || index; + const rawItemId = item[sourceColumn] || item.id || item.ID; + const itemId = rawItemId != null ? rawItemId : index; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); const hasChildren = item.children && item.children.length > 0; @@ -2749,7 +2750,7 @@ export const SplitPanelLayoutComponent: React.FC const displaySubtitle = displayFields[1]?.value || null; return ( - + {/* 현재 항목 */}
return (
{currentTabData.map((item: any, idx: number) => { - const itemId = item.id || idx; + const itemId = item.id ?? idx; const isExpanded = expandedRightItems.has(itemId); // 표시할 컬럼 결정 @@ -3097,7 +3098,7 @@ export const SplitPanelLayoutComponent: React.FC const detailColumns = columnsToShow.slice(summaryCount); return ( -
+
toggleRightItemExpansion(itemId)} @@ -3287,10 +3288,10 @@ export const SplitPanelLayoutComponent: React.FC {filteredData.map((item, idx) => { - const itemId = item.id || item.ID || idx; + const itemId = item.id || item.ID; return ( - + {columnsToShow.map((col, colIdx) => ( return (
{/* 요약 정보 */} diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx index 492f7255..a8f752f9 100644 --- a/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx +++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingComponent.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react"; +import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -51,6 +51,8 @@ export function ItemRoutingComponent({ refreshDetails, deleteDetail, deleteVersion, + setDefaultVersion, + unsetDefaultVersion, } = useItemRouting(configProp || {}); const [searchText, setSearchText] = useState(""); @@ -70,16 +72,21 @@ export function ItemRoutingComponent({ }, [fetchItems]); // 모달 저장 성공 감지 -> 데이터 새로고침 + const refreshVersionsRef = React.useRef(refreshVersions); + const refreshDetailsRef = React.useRef(refreshDetails); + refreshVersionsRef.current = refreshVersions; + refreshDetailsRef.current = refreshDetails; + useEffect(() => { const handleSaveSuccess = () => { - refreshVersions(); - refreshDetails(); + refreshVersionsRef.current(); + refreshDetailsRef.current(); }; window.addEventListener("saveSuccessInModal", handleSaveSuccess); return () => { window.removeEventListener("saveSuccessInModal", handleSaveSuccess); }; - }, [refreshVersions, refreshDetails]); + }, []); // 품목 검색 const handleSearch = useCallback(() => { @@ -156,6 +163,24 @@ export function ItemRoutingComponent({ [config] ); + // 기본 버전 토글 + const handleToggleDefault = useCallback( + async (versionId: string, currentIsDefault: boolean) => { + let success: boolean; + if (currentIsDefault) { + success = await unsetDefaultVersion(versionId); + if (success) toast({ title: "기본 버전이 해제되었습니다" }); + } else { + success = await setDefaultVersion(versionId); + if (success) toast({ title: "기본 버전으로 설정되었습니다" }); + } + if (!success) { + toast({ title: "기본 버전 변경 실패", variant: "destructive" }); + } + }, + [setDefaultVersion, unsetDefaultVersion, toast] + ); + // 삭제 확인 const handleConfirmDelete = useCallback(async () => { if (!deleteTarget) return; @@ -175,12 +200,6 @@ export function ItemRoutingComponent({ setDeleteTarget(null); }, [deleteTarget, deleteVersion, deleteDetail, toast]); - // entity join으로 가져온 공정명 컬럼 이름 추정 - const processNameKey = useMemo(() => { - const ds = config.dataSource; - return `${ds.processTable}_${ds.processNameColumn}`; - }, [config.dataSource]); - const splitRatio = config.splitRatio || 40; if (isPreview) { @@ -295,34 +314,56 @@ export function ItemRoutingComponent({ 버전: {versions.map((ver) => { const isActive = selectedVersionId === ver.id; + const isDefault = ver.is_default === true; return (
selectVersion(ver.id)} > + {isDefault && } {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} {!config.readonly && ( - + <> + + + )}
); @@ -394,11 +435,11 @@ export function ItemRoutingComponent({ {config.processColumns.map((col) => { let cellValue = detail[col.name]; - if ( - col.name === "process_code" && - detail[processNameKey] - ) { - cellValue = `${detail[col.name]} (${detail[processNameKey]})`; + if (cellValue == null) { + const aliasKey = Object.keys(detail).find( + (k) => k.endsWith(`_${col.name}`) + ); + if (aliasKey) cellValue = detail[aliasKey]; } return ( ) { [configKey] ); - // 공정 상세 목록 조회 (특정 버전의 공정들) + // 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함 const fetchDetails = useCallback( async (versionId: string) => { try { setLoading(true); const ds = configRef.current.dataSource; - const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", { - params: { - tableName: ds.routingDetailTable, - searchConditions: JSON.stringify({ - [ds.routingDetailFkColumn]: { - value: versionId, - operator: "equals", - }, - }), - sortColumn: "seq_no", - sortDirection: "ASC", - }, + const searchConditions = { + [ds.routingDetailFkColumn]: { value: versionId, operator: "equals" }, + }; + const params = new URLSearchParams({ + page: "1", + size: "1000", + search: JSON.stringify(searchConditions), + sortBy: "seq_no", + sortOrder: "ASC", + enableEntityJoin: "true", }); + const res = await apiClient.get( + `/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}` + ); if (res.data?.success) { - setDetails(res.data.data || []); + const result = res.data.data; + setDetails(Array.isArray(result) ? result : result?.data || []); } } catch (err) { console.error("공정 상세 조회 실패", err); @@ -136,14 +138,17 @@ export function useItemRouting(configPartial: Partial) { const versionList = await fetchVersions(itemCode); - // 첫번째 버전 자동 선택 - if (config.autoSelectFirstVersion && versionList.length > 0) { - const firstVersion = versionList[0]; - setSelectedVersionId(firstVersion.id); - await fetchDetails(firstVersion.id); + if (versionList.length > 0) { + // 기본 버전 우선, 없으면 첫번째 버전 선택 + const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default); + const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null); + if (targetVersion) { + setSelectedVersionId(targetVersion.id); + await fetchDetails(targetVersion.id); + } } }, - [fetchVersions, fetchDetails, config.autoSelectFirstVersion] + [fetchVersions, fetchDetails] ); // 버전 선택 @@ -181,7 +186,8 @@ export function useItemRouting(configPartial: Partial) { try { const ds = configRef.current.dataSource; const res = await apiClient.delete( - `/table-data/${ds.routingDetailTable}/${detailId}` + `/table-management/tables/${ds.routingDetailTable}/delete`, + { data: [{ id: detailId }] } ); if (res.data?.success) { await refreshDetails(); @@ -201,7 +207,8 @@ export function useItemRouting(configPartial: Partial) { try { const ds = configRef.current.dataSource; const res = await apiClient.delete( - `/table-data/${ds.routingVersionTable}/${versionId}` + `/table-management/tables/${ds.routingVersionTable}/delete`, + { data: [{ id: versionId }] } ); if (res.data?.success) { if (selectedVersionId === versionId) { @@ -219,6 +226,51 @@ export function useItemRouting(configPartial: Partial) { [selectedVersionId, refreshVersions] ); + // 기본 버전 설정 + const setDefaultVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, { + routingVersionTable: ds.routingVersionTable, + routingFkColumn: ds.routingVersionFkColumn, + }); + if (res.data?.success) { + if (selectedItemCode) { + await fetchVersions(selectedItemCode); + } + return true; + } + } catch (err) { + console.error("기본 버전 설정 실패", err); + } + return false; + }, + [selectedItemCode, fetchVersions] + ); + + // 기본 버전 해제 + const unsetDefaultVersion = useCallback( + async (versionId: string) => { + try { + const ds = configRef.current.dataSource; + const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, { + routingVersionTable: ds.routingVersionTable, + }); + if (res.data?.success) { + if (selectedItemCode) { + await fetchVersions(selectedItemCode); + } + return true; + } + } catch (err) { + console.error("기본 버전 해제 실패", err); + } + return false; + }, + [selectedItemCode, fetchVersions] + ); + return { config, items, @@ -235,5 +287,7 @@ export function useItemRouting(configPartial: Partial) { refreshDetails, deleteDetail, deleteVersion, + setDefaultVersion, + unsetDefaultVersion, }; } diff --git a/frontend/lib/registry/components/v2-item-routing/types.ts b/frontend/lib/registry/components/v2-item-routing/types.ts index e5b1aa38..06b108da 100644 --- a/frontend/lib/registry/components/v2-item-routing/types.ts +++ b/frontend/lib/registry/components/v2-item-routing/types.ts @@ -65,6 +65,7 @@ export interface ItemData { export interface RoutingVersionData { id: string; version_name: string; + is_default?: boolean; [key: string]: any; } diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx new file mode 100644 index 00000000..d9828aa0 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/DetailFormModal.tsx @@ -0,0 +1,445 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Search } from "lucide-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 { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types"; +import { InspectionStandardLookup } from "./InspectionStandardLookup"; + +interface DetailFormModalProps { + open: boolean; + onClose: () => void; + onSubmit: (data: Partial) => void; + detailTypes: DetailTypeDefinition[]; + editData?: WorkItemDetail | null; + mode: "add" | "edit"; +} + +const LOOKUP_TARGETS = [ + { value: "equipment", label: "설비정보" }, + { value: "material", label: "자재정보" }, + { value: "worker", label: "작업자정보" }, + { value: "tool", label: "공구정보" }, + { value: "document", label: "문서정보" }, +]; + +const INPUT_TYPES = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "textarea", label: "장문텍스트" }, + { value: "select", label: "선택형" }, +]; + +export function DetailFormModal({ + open, + onClose, + onSubmit, + detailTypes, + editData, + mode, +}: DetailFormModalProps) { + const [formData, setFormData] = useState>({}); + const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false); + const [selectedInspection, setSelectedInspection] = useState(null); + + useEffect(() => { + if (open) { + if (mode === "edit" && editData) { + setFormData({ ...editData }); + if (editData.inspection_code) { + setSelectedInspection({ + id: "", + inspection_code: editData.inspection_code, + inspection_item: editData.content || "", + inspection_method: editData.inspection_method || "", + unit: editData.unit || "", + lower_limit: editData.lower_limit || "", + upper_limit: editData.upper_limit || "", + }); + } + } else { + setFormData({ + detail_type: detailTypes[0]?.value || "", + content: "", + is_required: "Y", + }); + setSelectedInspection(null); + } + } + }, [open, mode, editData, detailTypes]); + + const updateField = (field: string, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleInspectionSelect = (item: InspectionStandard) => { + setSelectedInspection(item); + setFormData((prev) => ({ + ...prev, + inspection_code: item.inspection_code, + content: item.inspection_item, + inspection_method: item.inspection_method, + unit: item.unit, + lower_limit: item.lower_limit || "", + upper_limit: item.upper_limit || "", + })); + }; + + const handleSubmit = () => { + if (!formData.detail_type) return; + + const type = formData.detail_type; + + if (type === "check" && !formData.content?.trim()) return; + if (type === "inspect" && !formData.content?.trim()) return; + if (type === "procedure" && !formData.content?.trim()) return; + if (type === "input" && !formData.content?.trim()) return; + if (type === "info" && !formData.lookup_target) return; + + onSubmit(formData); + onClose(); + }; + + const currentType = formData.detail_type || ""; + + return ( + <> + !v && onClose()}> + + + + 상세 항목 {mode === "add" ? "추가" : "수정"} + + + 상세 항목의 유형을 선택하고 내용을 입력하세요 + + + +
+ {/* 유형 선택 */} +
+ + +
+ + {/* 체크리스트 */} + {currentType === "check" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* 검사항목 */} + {currentType === "inspect" && ( + <> +
+ +
+ + +
+
+ + {selectedInspection && ( +
+

+ 선택된 검사기준 정보 +

+
+

+ 검사코드: {selectedInspection.inspection_code} +

+

+ 검사항목: {selectedInspection.inspection_item} +

+

+ 검사방법: {selectedInspection.inspection_method || "-"} +

+

+ 단위: {selectedInspection.unit || "-"} +

+

+ 하한값: {selectedInspection.lower_limit || "-"} +

+

+ 상한값: {selectedInspection.upper_limit || "-"} +

+
+
+ )} + +
+ + updateField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+ + updateField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + updateField("unit", e.target.value)} + placeholder="예: mm" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + updateField("lower_limit", e.target.value)} + placeholder="예: 7.95" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + updateField("upper_limit", e.target.value)} + placeholder="예: 8.05" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + )} + + {/* 작업절차 */} + {currentType === "procedure" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + updateField( + "duration_minutes", + e.target.value ? Number(e.target.value) : undefined + ) + } + placeholder="예: 5" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* 직접입력 */} + {currentType === "input" && ( + <> +
+ + updateField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+ + )} + + {/* 정보조회 */} + {currentType === "info" && ( + <> +
+ + +
+
+ + updateField("display_fields", e.target.value)} + placeholder="예: 설비명, 설비코드" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + )} + + {/* 필수 여부 (모든 유형 공통) */} + {currentType && ( +
+ + +
+ )} +
+ + + + + +
+
+ + setInspectionLookupOpen(false)} + onSelect={handleInspectionSelect} + /> + + ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx new file mode 100644 index 00000000..75094d58 --- /dev/null +++ b/frontend/lib/registry/components/v2-process-work-standard/components/InspectionStandardLookup.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Search, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { apiClient } from "@/lib/api/client"; +import { InspectionStandard } from "../types"; + +interface InspectionStandardLookupProps { + open: boolean; + onClose: () => void; + onSelect: (item: InspectionStandard) => void; +} + +export function InspectionStandardLookup({ + open, + onClose, + onSelect, +}: InspectionStandardLookupProps) { + const [data, setData] = useState([]); + const [searchText, setSearchText] = useState(""); + const [loading, setLoading] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const search: Record = {}; + if (searchText.trim()) { + search.inspection_item = searchText.trim(); + search.inspection_code = searchText.trim(); + } + const params = new URLSearchParams({ + page: "1", + size: "100", + enableEntityJoin: "true", + ...(searchText.trim() ? { search: JSON.stringify(search) } : {}), + }); + const res = await apiClient.get( + `/table-management/tables/inspection_standard/data-with-joins?${params}` + ); + if (res.data?.success) { + const result = res.data.data; + setData(Array.isArray(result) ? result : result?.data || []); + } + } catch (err) { + console.error("검사기준 조회 실패", err); + } finally { + setLoading(false); + } + }, [searchText]); + + useEffect(() => { + if (open) { + fetchData(); + } + }, [open, fetchData]); + + const handleSelect = (item: any) => { + onSelect({ + id: item.id, + inspection_code: item.inspection_code || "", + inspection_item: item.inspection_item || item.inspection_criteria || "", + inspection_method: item.inspection_method || "", + unit: item.unit || "", + lower_limit: item.lower_limit || "", + upper_limit: item.upper_limit || "", + }); + onClose(); + }; + + return ( + !v && onClose()}> + + + + + 검사기준 조회 + + + 검사기준을 검색하여 선택하세요 + + + +
+
+ setSearchText(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchData()} + className="h-9 text-sm" + /> +
+ +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : data.length === 0 ? ( + + + + ) : ( + data.map((item, idx) => ( + + + + + + + + + + )) + )} + +
+ 검사코드 + + 검사항목 + + 검사방법 + + 하한 + + 상한 + + 단위 + + 선택 +
+ 조회 중... +
+ 검사기준이 없습니다 +
{item.inspection_code || "-"} + {item.inspection_item || item.inspection_criteria || "-"} + {item.inspection_method || "-"} + {item.lower_limit || "-"} + + {item.upper_limit || "-"} + {item.unit || "-"} + +
+
+
+ + + + +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx b/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx index 59ea4f71..689d006d 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx +++ b/frontend/lib/registry/components/v2-process-work-standard/components/ItemProcessSelector.tsx @@ -87,7 +87,7 @@ export function ItemProcessSelector({
) : ( items.map((item) => ( -
+
{/* 품목 헤더 */} - -
- - - ) : ( - <> - - {idx + 1} - - - - {getTypeLabel(detail.detail_type)} - - - {detail.content} - - - {detail.is_required === "Y" ? "필수" : "선택"} - - - {!readonly && ( - -
- - -
- - )} - + + +
+ )} ))} - - {/* 추가 행 */} - {isAdding && ( - - - {details.length + 1} - - - - - - - setNewData((prev) => ({ - ...prev, - content: e.target.value, - })) - } - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - className="h-7 text-xs" - /> - - - - - -
- - -
- - - )} - {details.length === 0 && !isAdding && ( + {details.length === 0 && (

상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요. @@ -375,6 +205,16 @@ export function WorkItemDetailList({

)}
+ + {/* 추가/수정 모달 */} + setModalOpen(false)} + onSubmit={handleSubmit} + detailTypes={detailTypes} + editData={editTarget} + mode={modalMode} + />
); } diff --git a/frontend/lib/registry/components/v2-process-work-standard/config.ts b/frontend/lib/registry/components/v2-process-work-standard/config.ts index bef2ed6d..8c73ffd7 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/config.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/config.ts @@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = { { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 }, ], detailTypes: [ - { value: "CHECK", label: "체크" }, - { value: "INSPECTION", label: "검사" }, - { value: "MEASUREMENT", label: "측정" }, + { value: "check", label: "체크리스트" }, + { value: "inspect", label: "검사항목" }, + { value: "procedure", label: "작업절차" }, + { value: "input", label: "직접입력" }, + { value: "info", label: "정보조회" }, ], splitRatio: 30, leftPanelTitle: "품목 및 공정 선택", diff --git a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts index ca9d8019..759eb9c7 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/hooks/useProcessWorkStandard.ts @@ -11,7 +11,7 @@ import { SelectionState, } from "../types"; -const API_BASE = "/api/process-work-standard"; +const API_BASE = "/process-work-standard"; export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { const [items, setItems] = useState([]); diff --git a/frontend/lib/registry/components/v2-process-work-standard/types.ts b/frontend/lib/registry/components/v2-process-work-standard/types.ts index f2668ada..6d2b0bea 100644 --- a/frontend/lib/registry/components/v2-process-work-standard/types.ts +++ b/frontend/lib/registry/components/v2-process-work-standard/types.ts @@ -87,6 +87,29 @@ export interface WorkItemDetail { sort_order: number; remark?: string; created_date?: string; + // 검사항목 전용 + inspection_code?: string; + inspection_method?: string; + unit?: string; + lower_limit?: string; + upper_limit?: string; + // 작업절차 전용 + duration_minutes?: number; + // 직접입력 전용 + input_type?: string; + // 정보조회 전용 + lookup_target?: string; + display_fields?: string; +} + +export interface InspectionStandard { + id: string; + inspection_code: string; + inspection_item: string; + inspection_method: string; + unit: string; + lower_limit?: string; + upper_limit?: string; } // ============================================================ -- 2.43.0 From 3ca511924e05e55e4ff7a5d57207a006748c913a Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Feb 2026 18:40:36 +0900 Subject: [PATCH 02/15] feat: Implement company-specific NOT NULL constraint validation for table data - Added validation for NOT NULL constraints in the add and edit table data functions, ensuring that required fields are not empty based on company-specific settings. - Enhanced the toggleColumnNullable function to check for existing NULL values before changing the NOT NULL status, providing appropriate error messages. - Introduced a new service method to validate NOT NULL constraints against company-specific configurations, improving data integrity in a multi-tenancy environment. --- .../controllers/tableManagementController.ts | 106 +++++++++++++++--- .../src/services/tableManagementService.ts | 73 +++++++++++- 2 files changed, 160 insertions(+), 19 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 320ab74b..5657010f 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -921,6 +921,24 @@ export async function addTableData( } } + // 회사별 NOT NULL 소프트 제약조건 검증 + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + data, + companyCode || "*" + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } + // 데이터 추가 await tableManagementService.addTableData(tableName, data); @@ -1003,6 +1021,25 @@ export async function editTableData( } const tableManagementService = new TableManagementService(); + const companyCode = req.user?.companyCode || "*"; + + // 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상) + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + updatedData, + companyCode + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } // 데이터 수정 await tableManagementService.editTableData( @@ -2652,8 +2689,11 @@ export async function toggleTableIndex( } /** - * NOT NULL 토글 + * NOT NULL 토글 (회사별 소프트 제약조건) * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + * + * DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다. + * 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다. */ export async function toggleColumnNullable( req: AuthenticatedRequest, @@ -2662,6 +2702,7 @@ export async function toggleColumnNullable( try { const { tableName, columnName } = req.params; const { nullable } = req.body; + const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof nullable !== "boolean") { res.status(400).json({ @@ -2671,18 +2712,54 @@ export async function toggleColumnNullable( return; } - if (nullable) { - // NOT NULL 해제 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; - logger.info(`NOT NULL 해제: ${sql}`); - await query(sql); - } else { - // NOT NULL 설정 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; - logger.info(`NOT NULL 설정: ${sql}`); - await query(sql); + // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL + const isNullableValue = nullable ? "Y" : "N"; + + if (!nullable) { + // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const nullCheckQuery = companyCode === "*" + ? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL` + : `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`; + const nullCheckParams = companyCode === "*" ? [] : [companyCode]; + + const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams); + const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10); + + if (nullCount > 0) { + logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, { + companyCode, + nullCount, + }); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`, + }); + return; + } + } } + // table_type_columns에 회사별 is_nullable 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_nullable = $3, updated_date = NOW()`, + [tableName, columnName, isNullableValue, companyCode] + ); + + logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, { + companyCode, + }); + res.status(200).json({ success: true, message: nullable @@ -2692,14 +2769,9 @@ export async function toggleColumnNullable( } catch (error: any) { logger.error("NOT NULL 토글 오류:", error); - // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 - const errorMsg = error.message?.includes("contains null values") - ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." - : "NOT NULL 설정 중 오류가 발생했습니다."; - res.status(500).json({ success: false, - message: errorMsg, + message: "NOT NULL 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e0f3944..c19c2631 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -199,7 +199,11 @@ export class TableManagementService { cl.input_type as "cl_input_type", COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", COALESCE(ttc.description, cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL + THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -241,7 +245,11 @@ export class TableManagementService { COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings::text, '') as "detailSettings", COALESCE(cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN cl.is_nullable IS NOT NULL + THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -2431,6 +2439,67 @@ export class TableManagementService { return value; } + /** + * 회사별 NOT NULL 소프트 제약조건 검증 + * table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다. + */ + async validateNotNullConstraints( + tableName: string, + data: Record, + companyCode: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + const notNullColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (notNullColumns.length === 0 && companyCode !== "*") { + const globalNotNull = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + notNullColumns.push(...globalNotNull); + } + + if (notNullColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of notNullColumns) { + const value = data[col.column_name]; + // NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리 + if (value === null || value === undefined || value === "") { + violations.push(col.column_label); + } + } + + return violations; + } catch (error) { + logger.error(`NOT NULL 검증 오류: ${tableName}`, error); + return []; + } + } + /** * 테이블에 데이터 추가 * @returns 무시된 컬럼 정보 (디버깅용) -- 2.43.0 From 2b175a21f4ff73332ffe724741b9b8a2d122bf90 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 25 Feb 2026 11:45:28 +0900 Subject: [PATCH 03/15] feat: Enhance entity options retrieval with additional fields support - Updated the `getEntityOptions` function to accept an optional `fields` parameter, allowing clients to specify additional columns to be retrieved. - Implemented logic to dynamically include extra columns in the SQL query based on the provided `fields`, improving flexibility in data retrieval. - Enhanced the response to indicate whether extra fields were included, facilitating better client-side handling of the data. - Added logging for authentication failures in the `AuthGuard` component to improve debugging and user experience. - Integrated auto-fill functionality in the `V2Select` component to automatically populate fields based on selected entity references, enhancing user interaction. - Updated the `ItemSearchModal` to support multi-selection of items, improving usability in item management scenarios. --- .../src/controllers/entitySearchController.ts | 17 +- frontend/components/auth/AuthGuard.tsx | 3 + frontend/components/screen/ScreenDesigner.tsx | 22 +- frontend/components/v2/V2Select.tsx | 93 +++++- .../v2/config-panels/V2SelectConfigPanel.tsx | 9 + frontend/hooks/useAuth.ts | 21 +- frontend/hooks/useMenu.ts | 5 +- frontend/lib/api/client.ts | 43 ++- frontend/lib/authLogger.ts | 225 +++++++++++++ .../lib/registry/DynamicComponentRenderer.tsx | 106 +++++- .../BomItemEditorComponent.tsx | 310 ++++++++++++++---- .../v2-bom-tree/BomTreeComponent.tsx | 35 +- .../components/v2-select/V2SelectRenderer.tsx | 9 +- frontend/lib/utils/buttonActions.ts | 57 +++- frontend/types/v2-components.ts | 2 + 15 files changed, 828 insertions(+), 129 deletions(-) create mode 100644 frontend/lib/authLogger.ts diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index bbc42568..3ece2ce7 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name" } = req.query; + const { value = "id", label = "name", fields } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // autoFill용 추가 컬럼 처리 + let extraColumns = ""; + if (fields && typeof fields === "string") { + const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean); + const validExtra = requestedFields.filter( + (f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn + ); + if (validExtra.length > 0) { + extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", "); + } + } + // 쿼리 실행 (최대 500개) const query = ` - SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label + SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns} FROM ${tableName} ${whereClause} ORDER BY ${effectiveLabelColumn} ASC @@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) labelColumn: effectiveLabelColumn, companyCode, rowCount: result.rowCount, + extraFields: extraColumns ? true : false, }); res.json({ diff --git a/frontend/components/auth/AuthGuard.tsx b/frontend/components/auth/AuthGuard.tsx index efb8cd25..3b3eb182 100644 --- a/frontend/components/auth/AuthGuard.tsx +++ b/frontend/components/auth/AuthGuard.tsx @@ -3,6 +3,7 @@ import { useEffect, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; +import { AuthLogger } from "@/lib/authLogger"; import { Loader2 } from "lucide-react"; interface AuthGuardProps { @@ -41,11 +42,13 @@ export function AuthGuard({ } if (requireAuth && !isLoggedIn) { + AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } if (requireAdmin && !isAdmin) { + AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 8dfe9ae4..fcb5b100 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -3968,10 +3968,10 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + parentId: formContainerId, + componentType: v2Mapping.componentType, position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -3995,12 +3995,11 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } else { - return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 + return; } } else { // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 @@ -4036,9 +4035,9 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + componentType: v2Mapping.componentType, position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -4062,8 +4061,7 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 4fd27cb0..fe21b790 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -622,6 +622,7 @@ export const V2Select = forwardRef( config: configProp, value, onChange, + onFormDataChange, tableName, columnName, isDesignMode, // 🔧 디자인 모드 (클릭 방지) @@ -630,6 +631,9 @@ export const V2Select = forwardRef( // config가 없으면 기본값 사용 const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] }; + // 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지 + const allComponents = (props as any).allComponents as any[] | undefined; + const [options, setOptions] = useState(config.options || []); const [loading, setLoading] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false); @@ -742,10 +746,7 @@ export const V2Select = forwardRef( const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { - value: valueCol, - label: labelCol, - }, + params: { value: valueCol, label: labelCol }, }); const data = response.data; if (data.success && data.data) { @@ -819,6 +820,70 @@ export const V2Select = forwardRef( loadOptions(); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 + const autoFillTargets = useMemo(() => { + if (source !== "entity" || !entityTable || !allComponents) return []; + + const targets: Array<{ sourceField: string; targetColumnName: string }> = []; + for (const comp of allComponents) { + if (comp.id === id) continue; + + // overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음) + const ov = (comp as any).overrides || {}; + const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || ""; + + // 방법1: entityJoinTable 속성이 있는 경우 + const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable; + const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn; + if (joinTable === entityTable && joinColumn) { + targets.push({ sourceField: joinColumn, targetColumnName: compColumnName }); + continue; + } + + // 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit) + if (compColumnName.includes(".")) { + const [prefix, actualColumn] = compColumnName.split("."); + if (prefix === entityTable && actualColumn) { + targets.push({ sourceField: actualColumn, targetColumnName: compColumnName }); + } + } + } + return targets; + }, [source, entityTable, allComponents, id]); + + // 엔티티 autoFill 적용 래퍼 + const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => { + onChange?.(newValue); + + if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return; + + const selectedKey = typeof newValue === "string" ? newValue : newValue[0]; + if (!selectedKey) return; + + const valueCol = entityValueColumn || "id"; + + apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, { + params: { + page: 1, + size: 1, + search: JSON.stringify({ [valueCol]: selectedKey }), + autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }), + }, + }).then((res) => { + const responseData = res.data?.data; + const rows = responseData?.data || responseData?.rows || []; + if (rows.length > 0) { + const fullData = rows[0]; + for (const target of autoFillTargets) { + const sourceValue = fullData[target.sourceField]; + if (sourceValue !== undefined) { + onFormDataChange(target.targetColumnName, sourceValue); + } + } + } + }).catch((err) => console.error("autoFill 조회 실패:", err)); + }, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]); + // 모드별 컴포넌트 렌더링 const renderSelect = () => { if (loading) { @@ -876,12 +941,12 @@ export const V2Select = forwardRef( switch (config.mode) { case "dropdown": - case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운 + case "combobox": return ( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); case "check": - case "checkbox": // 🔧 기존 저장된 값 호환 + case "checkbox": return ( @@ -919,7 +984,7 @@ export const V2Select = forwardRef( @@ -930,7 +995,7 @@ export const V2Select = forwardRef( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); @@ -953,7 +1018,7 @@ export const V2Select = forwardRef( @@ -964,7 +1029,7 @@ export const V2Select = forwardRef( diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index ce3b3dbd..f808ecf1 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -302,6 +302,15 @@ export const V2SelectConfigPanel: React.FC = ({ config 테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.

)} + + {/* 자동 채움 안내 */} + {config.entityTable && entityColumns.length > 0 && ( +
+

+ 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 채워집니다. +

+
+ )}
)} diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 737710d3..d03aab29 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { apiCall } from "@/lib/api/client"; +import { AuthLogger } from "@/lib/authLogger"; interface UserInfo { userId: string; @@ -161,13 +162,15 @@ export const useAuth = () => { const token = TokenManager.getToken(); if (!token || TokenManager.isTokenExpired(token)) { + AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); return; } - // 토큰이 유효하면 우선 인증된 상태로 설정 + AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작"); + setAuthStatus({ isLoggedIn: true, isAdmin: false, @@ -186,15 +189,16 @@ export const useAuth = () => { }; setAuthStatus(finalAuthStatus); + AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`); - // API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리) if (!finalAuthStatus.isLoggedIn) { + AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); } } else { - // userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지 + AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도"); try { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser: UserInfo = { @@ -210,14 +214,14 @@ export const useAuth = () => { isAdmin: tempUser.isAdmin, }); } catch { - // 토큰 파싱도 실패하면 비인증 상태로 전환 + AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); } } } catch { - // API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도 + AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도"); try { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser: UserInfo = { @@ -233,6 +237,7 @@ export const useAuth = () => { isAdmin: tempUser.isAdmin, }); } catch { + AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); @@ -408,19 +413,19 @@ export const useAuth = () => { const token = TokenManager.getToken(); if (token && !TokenManager.isTokenExpired(token)) { - // 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인 + AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: true, isAdmin: false, }); refreshUserData(); } else if (token && TokenManager.isTokenExpired(token)) { - // 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리) + AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`); TokenManager.removeToken(); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); } else { - // 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리) + AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); } diff --git a/frontend/hooks/useMenu.ts b/frontend/hooks/useMenu.ts index 32fb3d4e..59ddab02 100644 --- a/frontend/hooks/useMenu.ts +++ b/frontend/hooks/useMenu.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { MenuItem, MenuState } from "@/types/menu"; import { apiClient } from "@/lib/api/client"; +import { AuthLogger } from "@/lib/authLogger"; /** * 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅 @@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => { } else { setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - } catch { - // API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) + } catch (err: any) { + AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`); setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } }, [convertToUpperCaseKeys, buildMenuTree]); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7abe856c..2338ad63 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,4 +1,14 @@ import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { AuthLogger } from "@/lib/authLogger"; + +const authLog = (event: string, detail: string) => { + if (typeof window === "undefined") return; + try { + AuthLogger.log(event as any, detail); + } catch { + // 로거 실패해도 앱 동작에 영향 없음 + } +}; // API URL 동적 설정 - 환경변수 우선 사용 const getApiBaseUrl = (): string => { @@ -149,9 +159,12 @@ const refreshToken = async (): Promise => { try { const currentToken = TokenManager.getToken(); if (!currentToken) { + authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음"); return null; } + authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}분`); + const response = await axios.post( `${API_BASE_URL}/auth/refresh`, {}, @@ -165,10 +178,13 @@ const refreshToken = async (): Promise => { if (response.data?.success && response.data?.data?.token) { const newToken = response.data.data.token; TokenManager.setToken(newToken); + authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료"); return newToken; } + authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`); return null; - } catch { + } catch (err: any) { + authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`); return null; } }; @@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => { document.addEventListener("visibilitychange", () => { if (!document.hidden) { const token = TokenManager.getToken(); - if (!token) return; + if (!token) { + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음"); + return; + } if (TokenManager.isTokenExpired(token)) { - // 만료됐으면 갱신 시도 + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도"); refreshToken().then((newToken) => { if (!newToken) { + authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트"); redirectToLogin(); } }); } else if (TokenManager.isTokenExpiringSoon(token)) { + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도"); refreshToken(); } } @@ -268,6 +289,7 @@ const redirectToLogin = (): void => { if (isRedirecting) return; if (window.location.pathname === "/login") return; + authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`); isRedirecting = true; TokenManager.removeToken(); window.location.href = "/login"; @@ -301,15 +323,13 @@ apiClient.interceptors.request.use( if (token) { if (!TokenManager.isTokenExpired(token)) { - // 유효한 토큰 → 그대로 사용 config.headers.Authorization = `Bearer ${token}`; } else { - // 만료된 토큰 → 갱신 시도 후 사용 + authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`); const newToken = await refreshToken(); if (newToken) { config.headers.Authorization = `Bearer ${newToken}`; } - // 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리) } } @@ -378,12 +398,16 @@ apiClient.interceptors.response.use( // 401 에러 처리 (핵심 개선) if (status === 401 && typeof window !== "undefined") { - const errorData = error.response?.data as { error?: { code?: string } }; + const errorData = error.response?.data as { error?: { code?: string; details?: string } }; const errorCode = errorData?.error?.code; + const errorDetails = errorData?.error?.details; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`); + // 이미 재시도한 요청이면 로그인으로 if (originalRequest?._retry) { + authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } @@ -395,6 +419,7 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { + authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`); const newToken = await refreshToken(); if (newToken) { isRefreshing = false; @@ -404,17 +429,18 @@ apiClient.interceptors.response.use( } else { isRefreshing = false; onRefreshFailed(new Error("토큰 갱신 실패")); + authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { isRefreshing = false; onRefreshFailed(refreshError as Error); + authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } else { - // 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도 try { const newToken = await waitForTokenRefresh(); originalRequest._retry = true; @@ -427,6 +453,7 @@ apiClient.interceptors.response.use( } // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 + authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`); redirectToLogin(); } diff --git a/frontend/lib/authLogger.ts b/frontend/lib/authLogger.ts new file mode 100644 index 00000000..f30284ab --- /dev/null +++ b/frontend/lib/authLogger.ts @@ -0,0 +1,225 @@ +/** + * 인증 이벤트 로거 + * - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록 + * - localStorage에 저장하여 브라우저에서 확인 가능 + * - 콘솔에서 window.__AUTH_LOG.show() 로 조회 + */ + +const STORAGE_KEY = "auth_debug_log"; +const MAX_ENTRIES = 200; + +export type AuthEventType = + | "TOKEN_SET" + | "TOKEN_REMOVED" + | "TOKEN_EXPIRED_DETECTED" + | "TOKEN_REFRESH_START" + | "TOKEN_REFRESH_SUCCESS" + | "TOKEN_REFRESH_FAIL" + | "REDIRECT_TO_LOGIN" + | "API_401_RECEIVED" + | "API_401_RETRY" + | "AUTH_CHECK_START" + | "AUTH_CHECK_SUCCESS" + | "AUTH_CHECK_FAIL" + | "AUTH_GUARD_BLOCK" + | "AUTH_GUARD_PASS" + | "MENU_LOAD_FAIL" + | "VISIBILITY_CHANGE" + | "MIDDLEWARE_REDIRECT"; + +interface AuthLogEntry { + timestamp: string; + event: AuthEventType; + detail: string; + tokenStatus: string; + url: string; + stack?: string; +} + +function getTokenSummary(): string { + if (typeof window === "undefined") return "SSR"; + + const token = localStorage.getItem("authToken"); + if (!token) return "없음"; + + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp * 1000; + const now = Date.now(); + const remainMs = exp - now; + + if (remainMs <= 0) { + return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`; + } + + const remainMin = Math.round(remainMs / 60000); + const remainHour = Math.floor(remainMin / 60); + const min = remainMin % 60; + + return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`; + } catch { + return "파싱실패"; + } +} + +function getCallStack(): string { + try { + const stack = new Error().stack || ""; + const lines = stack.split("\n").slice(3, 7); + return lines.map((l) => l.trim()).join(" <- "); + } catch { + return ""; + } +} + +function writeLog(event: AuthEventType, detail: string) { + if (typeof window === "undefined") return; + + const entry: AuthLogEntry = { + timestamp: new Date().toISOString(), + event, + detail, + tokenStatus: getTokenSummary(), + url: window.location.pathname + window.location.search, + stack: getCallStack(), + }; + + // 콘솔 출력 (그룹) + const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event); + const logFn = isError ? console.warn : console.debug; + logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`); + + // localStorage에 저장 + try { + const stored = localStorage.getItem(STORAGE_KEY); + const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : []; + logs.push(entry); + + // 최대 개수 초과 시 오래된 것 제거 + while (logs.length > MAX_ENTRIES) { + logs.shift(); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(logs)); + } catch { + // localStorage 공간 부족 등의 경우 무시 + } +} + +/** + * 저장된 로그 조회 + */ +function getLogs(): AuthLogEntry[] { + if (typeof window === "undefined") return []; + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +/** + * 로그 초기화 + */ +function clearLogs() { + if (typeof window === "undefined") return; + localStorage.removeItem(STORAGE_KEY); +} + +/** + * 로그를 테이블 형태로 콘솔에 출력 + */ +function showLogs(filter?: AuthEventType | "ERROR") { + const logs = getLogs(); + + if (logs.length === 0) { + console.log("[AuthLog] 저장된 로그가 없습니다."); + return; + } + + let filtered = logs; + if (filter === "ERROR") { + filtered = logs.filter((l) => + ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event) + ); + } else if (filter) { + filtered = logs.filter((l) => l.event === filter); + } + + console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`); + console.log("─".repeat(120)); + + filtered.forEach((entry, i) => { + const time = entry.timestamp.replace("T", " ").split(".")[0]; + console.log( + `${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n` + ); + }); +} + +/** + * 마지막 리다이렉트 원인 조회 + */ +function getLastRedirectReason(): AuthLogEntry | null { + const logs = getLogs(); + for (let i = logs.length - 1; i >= 0; i--) { + if (logs[i].event === "REDIRECT_TO_LOGIN") { + return logs[i]; + } + } + return null; +} + +/** + * 로그를 텍스트 파일로 다운로드 + */ +function downloadLogs() { + if (typeof window === "undefined") return; + + const logs = getLogs(); + if (logs.length === 0) { + console.log("[AuthLog] 저장된 로그가 없습니다."); + return; + } + + const text = logs + .map((entry, i) => { + const time = entry.timestamp.replace("T", " ").split(".")[0]; + return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`; + }) + .join("\n\n"); + + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`; + a.click(); + URL.revokeObjectURL(url); + + console.log("[AuthLog] 로그 파일 다운로드 완료"); +} + +// 전역 접근 가능하게 등록 +if (typeof window !== "undefined") { + (window as any).__AUTH_LOG = { + show: showLogs, + errors: () => showLogs("ERROR"), + clear: clearLogs, + download: downloadLogs, + lastRedirect: getLastRedirectReason, + raw: getLogs, + }; +} + +export const AuthLogger = { + log: writeLog, + getLogs, + clearLogs, + showLogs, + downloadLogs, + getLastRedirectReason, +}; + +export default AuthLogger; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index e6b13067..e79d9f83 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter"; // 통합 폼 시스템 import import { useV2FormOptional } from "@/components/v2/V2FormContext"; +import { apiClient } from "@/lib/api/client"; + +// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) +const columnMetaCache: Record> = {}; +const columnMetaLoading: Record> = {}; + +async function loadColumnMeta(tableName: string): Promise { + if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return; + + columnMetaLoading[tableName] = (async () => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + const map: Record = {}; + for (const col of columns) { + const name = col.column_name || col.columnName; + if (name) map[name] = col; + } + columnMetaCache[tableName] = map; + } catch { + columnMetaCache[tableName] = {}; + } finally { + delete columnMetaLoading[tableName]; + } + })(); + + await columnMetaLoading[tableName]; +} + +// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) +function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { + if (!tableName || !columnName) return componentConfig; + + const meta = columnMetaCache[tableName]?.[columnName]; + if (!meta) return componentConfig; + + const inputType = meta.input_type || meta.inputType; + if (!inputType) return componentConfig; + + // 이미 source가 올바르게 설정된 경우 건드리지 않음 + const existingSource = componentConfig?.source; + if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") { + return componentConfig; + } + + const merged = { ...componentConfig }; + + // source가 미설정/기본값일 때만 DB 메타데이터로 보완 + if (inputType === "entity") { + const refTable = meta.reference_table || meta.referenceTable; + const refColumn = meta.reference_column || meta.referenceColumn; + const displayCol = meta.display_column || meta.displayColumn; + if (refTable && !merged.entityTable) { + merged.source = "entity"; + merged.entityTable = refTable; + merged.entityValueColumn = refColumn || "id"; + merged.entityLabelColumn = displayCol || "name"; + } + } else if (inputType === "category" && !existingSource) { + merged.source = "category"; + } else if (inputType === "select" && !existingSource) { + const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {}); + if (detail.options && !merged.options?.length) { + merged.options = detail.options; + } + } + + return merged; +} // 컴포넌트 렌더러 인터페이스 export interface ComponentRenderer { @@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { + // 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드) + const screenTableName = props.tableName || (component as any).tableName; + const [, forceUpdate] = React.useState(0); + React.useEffect(() => { + if (screenTableName && !columnMetaCache[screenTableName]) { + loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); + } + }, [screenTableName]); + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { @@ -550,24 +629,34 @@ export const DynamicComponentRenderer: React.FC = height: finalStyle.height, }; + // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) + const isEntityJoinColumn = fieldName?.includes("."); + const baseColumnName = isEntityJoinColumn ? undefined : fieldName; + const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); + + // 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제 + const effectiveComponent = isEntityJoinColumn + ? { ...component, componentConfig: mergedComponentConfig, readonly: false } + : { ...component, componentConfig: mergedComponentConfig }; + const rendererProps = { - component, + component: effectiveComponent, isSelected, onClick, onDragStart, onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, - config: component.componentConfig, - componentConfig: component.componentConfig, + config: mergedComponentConfig, + componentConfig: mergedComponentConfig, // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) - ...(component.componentConfig || {}), + ...(mergedComponentConfig || {}), // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) style: mergedStyle, // 🆕 라벨 표시 (labelDisplay가 true일 때만) label: effectiveLabel, - // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 - inputType: (component as any).inputType || component.componentConfig?.inputType, + // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) + inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, value: currentValue, // formData에서 추출한 현재 값 전달 // 새로운 기능들 전달 @@ -607,9 +696,8 @@ export const DynamicComponentRenderer: React.FC = // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) mode: component.componentConfig?.mode || mode, isInModal, - readonly: component.readonly, - // 🆕 disabledFields 체크 또는 기존 readonly - disabled: disabledFields?.includes(fieldName) || component.readonly, + readonly: isEntityJoinColumn ? false : component.readonly, + disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly), originalData, allComponents, onUpdateLayout, diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 16aebd59..95f9987e 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -13,6 +13,7 @@ import { import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogFooter, } from "@/components/ui/dialog"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { apiClient } from "@/lib/api/client"; @@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`; interface ItemSearchModalProps { open: boolean; onClose: () => void; - onSelect: (item: ItemInfo) => void; + onSelect: (items: ItemInfo[]) => void; companyCode?: string; } @@ -94,6 +96,7 @@ function ItemSearchModal({ }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); + const [selectedItems, setSelectedItems] = useState>(new Set()); const [loading, setLoading] = useState(false); const searchItems = useCallback( @@ -109,7 +112,7 @@ function ItemSearchModal({ enableEntityJoin: true, companyCodeOverride: companyCode, }); - setItems(result.data || []); + setItems((result.data || []) as ItemInfo[]); } catch (error) { console.error("[BomItemEditor] 품목 검색 실패:", error); } finally { @@ -122,6 +125,7 @@ function ItemSearchModal({ useEffect(() => { if (open) { setSearchText(""); + setSelectedItems(new Set()); searchItems(""); } }, [open, searchItems]); @@ -180,6 +184,15 @@ function ItemSearchModal({ + @@ -191,11 +204,31 @@ function ItemSearchModal({ { - onSelect(item); - onClose(); + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); }} - className="hover:bg-accent cursor-pointer border-t transition-colors" + className={cn( + "cursor-pointer border-t transition-colors", + selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", + )} > + @@ -208,6 +241,25 @@ function ItemSearchModal({
+ 0 && selectedItems.size === items.length} + onCheckedChange={(checked) => { + if (checked) setSelectedItems(new Set(items.map((i) => i.id))); + else setSelectedItems(new Set()); + }} + /> + 품목코드 품목명 구분
e.stopPropagation()}> + { + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + {item.item_number}
)}
+ + {selectedItems.size > 0 && ( + + + {selectedItems.size}개 선택됨 + + + + )} ); @@ -227,6 +279,10 @@ interface TreeNodeRowProps { onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: string) => void; onAddChild: (parentTempId: string) => void; + onDragStart: (e: React.DragEvent, tempId: string) => void; + onDragOver: (e: React.DragEvent, tempId: string) => void; + onDrop: (e: React.DragEvent, tempId: string) => void; + isDragOver?: boolean; } function TreeNodeRow({ @@ -241,6 +297,10 @@ function TreeNodeRow({ onFieldChange, onDelete, onAddChild, + onDragStart, + onDragOver, + onDrop, + isDragOver, }: TreeNodeRowProps) { const indentPx = depth * 32; const visibleColumns = columns.filter((c) => c.visible !== false); @@ -319,8 +379,13 @@ function TreeNodeRow({ "group flex items-center gap-2 rounded-md border px-2 py-1.5", "transition-colors hover:bg-accent/30", depth > 0 && "ml-2 border-l-2 border-l-primary/20", + isDragOver && "border-primary bg-primary/5 border-dashed", )} style={{ marginLeft: `${indentPx}px` }} + draggable + onDragStart={(e) => onDragStart(e, node.tempId)} + onDragOver={(e) => onDragOver(e, node.tempId)} + onDrop={(e) => onDrop(e, node.tempId)} > @@ -409,7 +474,7 @@ export function BomItemEditorComponent({ // 설정값 추출 const cfg = useMemo(() => component?.componentConfig || {}, [component]); const mainTableName = cfg.mainTableName || "bom_detail"; - const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id"; + const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id"; const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]); const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]); const fkColumn = cfg.foreignKeyColumn || "bom_id"; @@ -431,7 +496,14 @@ export function BomItemEditorComponent({ for (const col of categoryColumns) { const categoryRef = `${mainTableName}.${col.key}`; - if (categoryOptionsMap[categoryRef]) continue; + + const alreadyLoaded = await new Promise((resolve) => { + setCategoryOptionsMap((prev) => { + resolve(!!prev[categoryRef]); + return prev; + }); + }); + if (alreadyLoaded) continue; try { const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); @@ -455,11 +527,23 @@ export function BomItemEditorComponent({ // ─── 데이터 로드 ─── + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + const sourceTable = cfg.dataSource?.sourceTable || "item_info"; + const loadBomDetails = useCallback( async (id: string) => { if (!id) return; setLoading(true); try { + // isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청 + const displayCols = columns.filter((c) => c.isSourceDisplay); + const additionalJoinColumns = displayCols.map((col) => ({ + sourceTable, + sourceColumn: sourceFk, + joinAlias: `${sourceFk}_${col.key}`, + referenceTable: sourceTable, + })); + const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { page: 1, size: 500, @@ -467,9 +551,20 @@ export function BomItemEditorComponent({ sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, + additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, + }); + + const rows = (result.data || []).map((row: Record) => { + const mapped = { ...row }; + for (const key of Object.keys(row)) { + if (key.startsWith(`${sourceFk}_`)) { + const shortKey = key.replace(`${sourceFk}_`, ""); + if (!mapped[shortKey]) mapped[shortKey] = row[key]; + } + } + return mapped; }); - const rows = result.data || []; const tree = buildTree(rows); setTreeData(tree); @@ -483,7 +578,7 @@ export function BomItemEditorComponent({ setLoading(false); } }, - [mainTableName, fkColumn], + [mainTableName, fkColumn, sourceFk, sourceTable, columns], ); useEffect(() => { @@ -548,10 +643,13 @@ export function BomItemEditorComponent({ id: node.id, tempId: node.tempId, [parentKeyColumn]: parentId, + [fkColumn]: bomId, seq_no: String(idx + 1), level: String(level), _isNew: node._isNew, _targetTable: mainTableName, + _fkColumn: fkColumn, + _deferSave: true, }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); @@ -560,7 +658,7 @@ export function BomItemEditorComponent({ }; traverse(nodes, null, 0); return result; - }, [parentKeyColumn, mainTableName]); + }, [parentKeyColumn, mainTableName, fkColumn, bomId]); // 트리 변경 시 부모에게 알림 const notifyChange = useCallback( @@ -627,53 +725,56 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 품목 선택 후 추가 (동적 데이터) + // 품목 선택 후 추가 (다중 선택 지원) const handleItemSelect = useCallback( - (item: ItemInfo) => { - // 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) - const sourceData: Record = {}; - const sourceTable = cfg.dataSource?.sourceTable; - if (sourceTable) { - const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; - sourceData[sourceFk] = item.id; - // 소스 표시 컬럼의 데이터 병합 - Object.keys(item).forEach((key) => { - sourceData[`_display_${key}`] = (item as any)[key]; - sourceData[key] = (item as any)[key]; - }); + (selectedItemsList: ItemInfo[]) => { + let newTree = [...treeData]; + + for (const item of selectedItemsList) { + const sourceData: Record = {}; + const sourceTable = cfg.dataSource?.sourceTable; + if (sourceTable) { + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + sourceData[sourceFk] = item.id; + Object.keys(item).forEach((key) => { + sourceData[`_display_${key}`] = (item as any)[key]; + sourceData[key] = (item as any)[key]; + }); + } + + const newNode: BomItemNode = { + tempId: generateTempId(), + parent_detail_id: null, + seq_no: 0, + level: 0, + children: [], + _isNew: true, + data: { + ...sourceData, + quantity: "1", + loss_rate: "0", + remark: "", + }, + }; + + if (addTargetParentId === null) { + newNode.seq_no = newTree.length + 1; + newNode.level = 0; + newTree = [...newTree, newNode]; + } else { + newTree = findAndUpdate(newTree, addTargetParentId, (parent) => { + newNode.parent_detail_id = parent.id || parent.tempId; + newNode.seq_no = parent.children.length + 1; + newNode.level = parent.level + 1; + return { + ...parent, + children: [...parent.children, newNode], + }; + }); + } } - const newNode: BomItemNode = { - tempId: generateTempId(), - parent_detail_id: null, - seq_no: 0, - level: 0, - children: [], - _isNew: true, - data: { - ...sourceData, - quantity: "1", - loss_rate: "0", - remark: "", - }, - }; - - let newTree: BomItemNode[]; - - if (addTargetParentId === null) { - newNode.seq_no = treeData.length + 1; - newNode.level = 0; - newTree = [...treeData, newNode]; - } else { - newTree = findAndUpdate(treeData, addTargetParentId, (parent) => { - newNode.parent_detail_id = parent.id || parent.tempId; - newNode.seq_no = parent.children.length + 1; - newNode.level = parent.level + 1; - return { - ...parent, - children: [...parent.children, newNode], - }; - }); + if (addTargetParentId !== null) { setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); } @@ -692,6 +793,101 @@ export function BomItemEditorComponent({ }); }, []); + // ─── 드래그 재정렬 ─── + const [dragId, setDragId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + + // 트리에서 노드를 제거하고 반환 + const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => { + const result: BomItemNode[] = []; + let removed: BomItemNode | null = null; + for (const node of nodes) { + if (node.tempId === tempId) { + removed = node; + } else { + const childResult = removeNode(node.children, tempId); + if (childResult.removed) removed = childResult.removed; + result.push({ ...node, children: childResult.tree }); + } + } + return { tree: result, removed }; + }; + + // 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지) + const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => { + const find = (list: BomItemNode[]): BomItemNode | null => { + for (const n of list) { + if (n.tempId === parentId) return n; + const found = find(n.children); + if (found) return found; + } + return null; + }; + const parent = find(nodes); + if (!parent) return false; + const check = (children: BomItemNode[]): boolean => { + for (const c of children) { + if (c.tempId === childId) return true; + if (check(c.children)) return true; + } + return false; + }; + return check(parent.children); + }; + + const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => { + setDragId(tempId); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", tempId); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverId(tempId); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => { + e.preventDefault(); + setDragOverId(null); + if (!dragId || dragId === targetTempId) return; + + // 자기 자신의 하위로 드래그 방지 + if (isDescendant(treeData, dragId, targetTempId)) return; + + const { tree: treeWithout, removed } = removeNode(treeData, dragId); + if (!removed) return; + + // 대상 노드 바로 뒤에 같은 레벨로 삽입 + const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => { + const result: BomItemNode[] = []; + let inserted = false; + for (const n of nodes) { + result.push(n); + if (n.tempId === afterId) { + result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id }); + inserted = true; + } else if (!inserted) { + const childResult = insertAfter(n.children, afterId, node); + if (childResult.inserted) { + result[result.length - 1] = { ...n, children: childResult.result }; + inserted = true; + } + } + } + return { result, inserted }; + }; + + const { result, inserted } = insertAfter(treeWithout, targetTempId, removed); + if (inserted) { + const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] => + nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) })); + notifyChange(reindex(result)); + } + + setDragId(null); + }, [dragId, treeData, notifyChange]); + // ─── 재귀 렌더링 ─── const renderNodes = (nodes: BomItemNode[], depth: number) => { @@ -711,6 +907,10 @@ export function BomItemEditorComponent({ onFieldChange={handleFieldChange} onDelete={handleDelete} onAddChild={handleAddChild} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + isDragOver={dragOverId === node.tempId} /> {isExpanded && node.children.length > 0 && @@ -898,7 +1098,7 @@ export function BomItemEditorComponent({
{/* 트리 목록 */} -
+
{loading ? (
로딩 중... diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 536c1ddc..9573472d 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -84,30 +84,45 @@ export function BomTreeComponent({ return null; }, [formData, selectedRowsData]); - // 선택된 BOM 헤더 정보 추출 + // 선택된 BOM 헤더 정보 추출 (조인 필드명 매핑 포함) const selectedHeaderData = useMemo(() => { - if (selectedRowsData && selectedRowsData.length > 0) { - return selectedRowsData[0] as BomHeaderInfo; - } - if (formData?.id) return formData as unknown as BomHeaderInfo; - return null; + const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); + if (!raw) return null; + return { + ...raw, + item_name: raw.item_id_item_name || raw.item_name || "", + item_code: raw.item_id_item_number || raw.item_code || "", + item_type: raw.item_id_division || raw.item_id_type || raw.item_type || "", + } as BomHeaderInfo; }, [formData, selectedRowsData]); // BOM 디테일 데이터 로드 + const detailTable = config.detailTable || "bom_detail"; + const foreignKey = config.foreignKey || "bom_id"; + const sourceFk = "child_item_id"; + const loadBomDetails = useCallback(async (bomId: string) => { if (!bomId) return; setLoading(true); try { - const result = await entityJoinApi.getTableDataWithJoins("bom_detail", { + const result = await entityJoinApi.getTableDataWithJoins(detailTable, { page: 1, size: 500, - search: { bom_id: bomId }, + search: { [foreignKey]: bomId }, sortBy: "seq_no", sortOrder: "asc", enableEntityJoin: true, }); - const rows = result.data || []; + const rows = (result.data || []).map((row: Record) => { + const mapped = { ...row }; + // 엔티티 조인 필드 매핑: child_item_id_item_name → child_item_name 등 + mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || ""; + mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || ""; + mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || ""; + return mapped; + }); + const tree = buildTree(rows); setTreeData(tree); const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); @@ -117,7 +132,7 @@ export function BomTreeComponent({ } finally { setLoading(false); } - }, []); + }, [detailTable, foreignKey]); // 평면 데이터 -> 트리 구조 변환 const buildTree = (flatData: any[]): BomTreeNode[] => { diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx index 18898198..b389cff7 100644 --- a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { static componentDefinition = V2SelectDefinition; render(): React.ReactElement { - const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any; // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; @@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer { // 디버깅 필요시 주석 해제 // console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize }); - // 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함) - const { style: _style, size: _size, ...restPropsClean } = restProps as any; + const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any; return ( = { - ...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터 - ...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선! + ...dataToSave, + ...commonFields, created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, @@ -1781,6 +1791,45 @@ export class ButtonActionExecutor { // 🔧 formData를 리피터에 전달하여 각 행에 병합 저장 const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; + // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 메인 저장 후 디테일 저장) + if (savedId) { + for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { + let parsedData = fieldValue; + if (typeof fieldValue === "string" && fieldValue.startsWith("[")) { + try { parsedData = JSON.parse(fieldValue); } catch { continue; } + } + if (!Array.isArray(parsedData) || parsedData.length === 0) continue; + if (!parsedData[0]?._deferSave) continue; + + const targetTable = parsedData[0]?._targetTable; + if (!targetTable) continue; + + for (const item of parsedData) { + const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId: ___, ...data } = item; + if (!data.id || data.id === "") delete data.id; + + // FK 주입 + if (fkCol) data[fkCol] = savedId; + + // 시스템 필드 추가 + data.created_by = context.userId; + data.updated_by = context.userId; + data.company_code = context.companyCode; + + try { + const isNew = _isNew || !item.id || item.id === ""; + if (isNew) { + await apiClient.post(`/table-management/tables/${targetTable}/add`, data); + } else { + await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data); + } + } catch (err: any) { + console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message); + } + } + } + } + // 메인 폼 데이터 구성 (사용자 정보 포함) const mainFormData = { ...formData, diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index a7543b24..88ac1691 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -182,6 +182,8 @@ export interface V2SelectProps extends V2BaseProps { config: V2SelectConfig; value?: string | string[]; onChange?: (value: string | string[]) => void; + onFormDataChange?: (fieldName: string, value: any) => void; + formData?: Record; } // ===== V2Date ===== -- 2.43.0 From 60b1ac1442b576808f1652aaa7616c341e37f2c2 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Feb 2026 12:25:30 +0900 Subject: [PATCH 04/15] feat: Enhance numbering rule service with separator handling - Introduced functionality to extract and manage individual separators for numbering rule parts. - Added methods to join parts with their respective separators, improving code generation flexibility. - Updated the numbering rule service to utilize the new separator logic during part processing. - Enhanced the frontend components to support custom separators for each part, allowing for more granular control over numbering formats. --- .../src/services/numberingRuleService.ts | 85 +++++++-- .../src/services/tableManagementService.ts | 42 +++- .../numbering-rule/NumberingRuleCard.tsx | 2 +- .../numbering-rule/NumberingRuleDesigner.tsx | 180 ++++++++++-------- .../numbering-rule/NumberingRulePreview.tsx | 122 ++++++------ .../table-list/TableListComponent.tsx | 22 ++- .../v2-table-list/TableListComponent.tsx | 22 ++- frontend/lib/utils/buttonActions.ts | 41 ++-- frontend/types/numbering-rule.ts | 3 + 9 files changed, 328 insertions(+), 191 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index a8765d18..2888a1f3 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -14,6 +14,35 @@ interface NumberingRulePart { autoConfig?: any; manualConfig?: any; generatedValue?: string; + separatorAfter?: string; +} + +/** + * 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출 + */ +function extractSeparatorAfterFromParts(parts: any[]): any[] { + return parts.map((part) => { + if (part.autoConfig?.separatorAfter !== undefined) { + part.separatorAfter = part.autoConfig.separatorAfter; + } + return part; + }); +} + +/** + * 파트별 개별 구분자를 사용하여 코드 결합 + * 마지막 파트의 separatorAfter는 무시됨 + */ +function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; + result += sep; + } + }); + return result; } interface NumberingRuleConfig { @@ -141,7 +170,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { @@ -274,7 +303,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; @@ -381,7 +410,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, @@ -517,7 +546,7 @@ class NumberingRuleService { companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, { @@ -633,7 +662,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); return rule; } @@ -708,17 +737,25 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + // auto_config에 separatorAfter 포함 + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + // autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동 + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } await client.query("COMMIT"); @@ -820,17 +857,23 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } } @@ -1053,7 +1096,8 @@ class NumberingRuleService { } })); - const previewCode = parts.join(rule.separator || ""); + const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, @@ -1164,8 +1208,8 @@ class NumberingRuleService { } })); - const separator = rule.separator || ""; - const previewTemplate = previewParts.join(separator); + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 @@ -1382,7 +1426,8 @@ class NumberingRuleService { } })); - const allocatedCode = parts.join(rule.separator || ""); + const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); + const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); // 순번이 있는 경우에만 증가 const hasSequence = rule.parts.some( @@ -1541,7 +1586,7 @@ class NumberingRuleService { rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info("[테스트] 채번 규칙 목록 조회 완료", { @@ -1634,7 +1679,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { ruleId: rule.ruleId, @@ -1754,12 +1799,14 @@ class NumberingRuleService { auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); @@ -1914,7 +1961,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("카테고리 조건 매칭 채번 규칙 찾음", { ruleId: rule.ruleId, @@ -1973,7 +2020,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { ruleId: rule.ruleId, @@ -2056,7 +2103,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index c19c2631..fc83165a 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1607,7 +1607,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator ); case "entity": @@ -1620,7 +1621,14 @@ export class TableManagementService { ); default: - // 기본 문자열 검색 (actualValue 사용) + // operator에 따라 정확 일치 또는 부분 일치 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(actualValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${actualValue}%`], @@ -1634,10 +1642,19 @@ export class TableManagementService { ); // 오류 시 기본 검색으로 폴백 let fallbackValue = value; + let fallbackOperator = "contains"; if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; + fallbackOperator = value.operator || "contains"; } + if (fallbackOperator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(fallbackValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${fallbackValue}%`], @@ -1784,7 +1801,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" ): Promise<{ whereClause: string; values: any[]; @@ -1794,7 +1812,14 @@ export class TableManagementService { const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { - // 코드 타입이 아니면 기본 검색 + // 코드 타입이 아니면 operator에 따라 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${value}%`], @@ -1802,6 +1827,15 @@ export class TableManagementService { }; } + // select 필터(equals)인 경우 정확한 코드값 매칭만 수행 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } + if (typeof value === "string" && value.trim() !== "") { // 코드값 또는 코드명으로 검색 return { diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index d1444d4e..e9731017 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC = ({ isPreview = false, }) => { return ( - +
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 9320f00e..8b521fe0 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC = ({ const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); - // 구분자 관련 상태 - const [separatorType, setSeparatorType] = useState("-"); - const [customSeparator, setCustomSeparator] = useState(""); + // 구분자 관련 상태 (개별 파트 사이 구분자) + const [separatorTypes, setSeparatorTypes] = useState>({}); + const [customSeparators, setCustomSeparators] = useState>({}); // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 interface CategoryOption { @@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); - // currentRule이 변경될 때 구분자 상태 동기화 + // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { - if (currentRule) { - const sep = currentRule.separator ?? "-"; - // 빈 문자열이면 "none" - if (sep === "") { - setSeparatorType("none"); - setCustomSeparator(""); - return; - } - // 미리 정의된 구분자인지 확인 (none, custom 제외) - const predefinedOption = SEPARATOR_OPTIONS.find( - opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep - ); - if (predefinedOption) { - setSeparatorType(predefinedOption.value); - setCustomSeparator(""); - } else { - // 직접 입력된 구분자 - setSeparatorType("custom"); - setCustomSeparator(sep); - } + if (currentRule && currentRule.parts.length > 0) { + const newSepTypes: Record = {}; + const newCustomSeps: Record = {}; + + currentRule.parts.forEach((part) => { + const sep = part.separatorAfter ?? currentRule.separator ?? "-"; + if (sep === "") { + newSepTypes[part.order] = "none"; + newCustomSeps[part.order] = ""; + } else { + const predefinedOption = SEPARATOR_OPTIONS.find( + opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + ); + if (predefinedOption) { + newSepTypes[part.order] = predefinedOption.value; + newCustomSeps[part.order] = ""; + } else { + newSepTypes[part.order] = "custom"; + newCustomSeps[part.order] = sep; + } + } + }); + + setSeparatorTypes(newSepTypes); + setCustomSeparators(newCustomSeps); } - }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + }, [currentRule?.ruleId]); - // 구분자 변경 핸들러 - const handleSeparatorChange = useCallback((type: SeparatorType) => { - setSeparatorType(type); + // 개별 파트 구분자 변경 핸들러 + const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { + setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); if (type !== "custom") { const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const newSeparator = option?.displayValue ?? ""; - setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); - setCustomSeparator(""); + setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part + ), + }; + }); } }, []); - // 직접 입력 구분자 변경 핸들러 - const handleCustomSeparatorChange = useCallback((value: string) => { - // 최대 2자 제한 + // 개별 파트 직접 입력 구분자 변경 핸들러 + const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); - setCustomSeparator(trimmedValue); - setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part + ), + }; + }); }, []); const handleAddPart = useCallback(() => { @@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC = ({ partType: "text", generationMethod: "auto", autoConfig: { textValue: "CODE" }, + separatorAfter: "-", }; setCurrentRule((prev) => { @@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC = ({ return { ...prev, parts: [...prev.parts, newPart] }; }); + // 새 파트의 구분자 상태 초기화 + setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); + setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); @@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 구분자 설정 */} -
-
- - -
- {separatorType === "custom" && ( -
- - handleCustomSeparatorChange(e.target.value)} - className="h-9" - placeholder="최대 2자" - maxLength={2} - /> -
- )} -

- 규칙 사이에 들어갈 문자입니다 -

-
@@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC = ({

규칙을 추가하여 코드를 구성하세요

) : ( -
+
{currentRule.parts.map((part, index) => ( - handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - /> + +
+ handleUpdatePart(part.order, updates)} + onDelete={() => handleDeletePart(part.order)} + isPreview={isPreview} + /> + {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} + {index < currentRule.parts.length - 1 && ( +
+ 뒤 구분자 + + {separatorTypes[part.order] === "custom" && ( + handlePartCustomSeparatorChange(part.order, e.target.value)} + className="h-6 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+
))}
)} diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index a9179959..eff551a1 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC = ({ return "규칙을 추가해주세요"; } - const parts = config.parts - .sort((a, b) => a.order - b.order) - .map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; + const sortedParts = config.parts.sort((a, b) => a.order - b.order); + + const partValues = sortedParts.map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - // 1. 순번 (자동 증가) - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - - // 2. 숫자 (고정 자릿수) - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - - // 3. 날짜 - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - - // 컬럼 기준 생성인 경우 placeholder 표시 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - // 형식에 맞는 placeholder 반환 - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - - // 현재 날짜 기준 생성 - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; + case "YYYY": return "[YYYY]"; + case "YY": return "[YY]"; + case "YYYYMM": return "[YYYYMM]"; + case "YYMM": return "[YYMM]"; + case "YYYYMMDD": return "[YYYYMMDD]"; + case "YYMMDD": return "[YYMMDD]"; + default: return "[DATE]"; } } - - // 4. 문자 - case "text": - return autoConfig.textValue || "TEXT"; - - default: - return "XXX"; + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } } - }); + case "text": + return autoConfig.textValue || "TEXT"; + default: + return "XXX"; + } + }); - return parts.join(config.separator || ""); + // 파트별 개별 구분자로 결합 + const globalSep = config.separator ?? "-"; + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? globalSep; + result += sep; + } + }); + return result; }, [config]); if (compact) { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index d0f9d5aa..20f37f8f 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -940,23 +940,35 @@ export const TableListComponent: React.FC = ({ } } - // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 + // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback + } + + // fallback: 현재 로드된 데이터에서 고유 값 추출 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValuesMap = new Map(); // value -> label + const uniqueValuesMap = new Map(); data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); - // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 59cb47fa..1f922188 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1015,23 +1015,35 @@ export const TableListComponent: React.FC = ({ } } - // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 + // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch (error: any) { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback + } + + // fallback: 현재 로드된 데이터에서 고유 값 추출 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValuesMap = new Map(); // value -> label + const uniqueValuesMap = new Map(); data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); - // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 8d35f119..b56d563c 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2054,11 +2054,11 @@ export class ButtonActionExecutor { const { tableName, screenId } = context; // 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음) + // initializeForm에서 __tableSection_ (더블), 수정 시 _tableSection_ (싱글) 사용 const universalFormModalKey = Object.keys(formData).find((key) => { const value = formData[key]; if (!value || typeof value !== "object" || Array.isArray(value)) return false; - // _tableSection_ 키가 있는지 확인 - return Object.keys(value).some((k) => k.startsWith("_tableSection_")); + return Object.keys(value).some((k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_")); }); if (!universalFormModalKey) { @@ -2117,11 +2117,18 @@ export class ButtonActionExecutor { const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; for (const [key, value] of Object.entries(modalData)) { - if (key.startsWith("_tableSection_")) { - const sectionId = key.replace("_tableSection_", ""); - tableSectionData[sectionId] = value as any[]; + // initializeForm: __tableSection_ (더블), 수정 시: _tableSection_ (싱글) → 통일 처리 + if (key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) { + if (Array.isArray(value)) { + const normalizedKey = key.startsWith("__tableSection_") + ? key.replace("__tableSection_", "") + : key.replace("_tableSection_", ""); + // 싱글 언더스코어 키(수정된 데이터)가 더블 언더스코어 키(초기 데이터)보다 우선 + if (!tableSectionData[normalizedKey] || key.startsWith("_tableSection_")) { + tableSectionData[normalizedKey] = value as any[]; + } + } } else if (!key.startsWith("_")) { - // _로 시작하지 않는 필드는 공통 필드로 처리 commonFieldsData[key] = value; } } @@ -2306,10 +2313,11 @@ export class ButtonActionExecutor { // originalGroupedData 전달이 누락된 경우를 처리 console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`); - // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 - // item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록 - // 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터) - const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo }; + // 마스터/디테일 테이블 분리 시: commonFieldsData 병합하지 않음 + // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 + const rowToUpdate = hasSeparateTargetTable + ? { ...item, ...userInfo } + : { ...commonFieldsData, ...item, ...userInfo }; Object.keys(rowToUpdate).forEach((key) => { if (key.startsWith("_")) { delete rowToUpdate[key]; @@ -2330,17 +2338,20 @@ export class ButtonActionExecutor { continue; } - // 변경 사항 확인 (공통 필드 포함) - // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀) - const currentDataWithCommon = { ...item, ...commonFieldsData }; - const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); + // 변경 사항 확인 + // 마스터/디테일 테이블이 분리된 경우(hasSeparateTargetTable): + // 마스터 필드(commonFieldsData)를 디테일에 병합하지 않음 + // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 + // 같은 테이블인 경우: 공통 필드 병합 유지 (공유 필드 업데이트 필요) + const dataForComparison = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item }; + const hasChanges = this.checkForChanges(originalItem, dataForComparison); if (hasChanges) { // 변경된 필드만 추출하여 부분 업데이트 const updateResult = await DynamicFormApi.updateFormDataPartial( item.id, originalItem, - currentDataWithCommon, + dataForComparison, saveTableName, ); diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 49264541..3b14a6bc 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -52,6 +52,9 @@ export interface NumberingRulePart { partType: CodePartType; // 파트 유형 generationMethod: GenerationMethod; // 생성 방식 + // 이 파트 뒤에 붙을 구분자 (마지막 파트는 무시됨) + separatorAfter?: string; + // 자동 생성 설정 autoConfig?: { // 순번용 -- 2.43.0 From 38dda2f807baade1c247e36ade6544b2ed93499a Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Feb 2026 13:53:20 +0900 Subject: [PATCH 05/15] fix: Improve TableListComponent and UniversalFormModalComponent for better data handling - Updated TableListComponent to use flex-nowrap and overflow-hidden for better badge rendering. - Enhanced UniversalFormModalComponent to maintain the latest formData using a ref, preventing stale closures during form save events. - Improved data merging logic in UniversalFormModalComponent to ensure accurate updates and maintain original data integrity. - Refactored buttonActions to streamline table section data collection and merging, ensuring proper handling of modified and original data during save operations. --- .../table-list/TableListComponent.tsx | 6 +- .../UniversalFormModalComponent.tsx | 62 ++++++--- .../v2-table-list/TableListComponent.tsx | 6 +- frontend/lib/utils/buttonActions.ts | 124 +++++++++++------- 4 files changed, 125 insertions(+), 73 deletions(-) diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 20f37f8f..3a3d4e12 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4319,7 +4319,7 @@ export const TableListComponent: React.FC = ({ // 다중 값인 경우: 여러 배지 렌더링 return ( -
+
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; @@ -4328,7 +4328,7 @@ export const TableListComponent: React.FC = ({ // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 if (!displayColor || displayColor === "none" || !categoryData) { return ( - + {displayLabel} {idx < values.length - 1 && ", "} @@ -4342,7 +4342,7 @@ export const TableListComponent: React.FC = ({ backgroundColor: displayColor, borderColor: displayColor, }} - className="text-white" + className="shrink-0 whitespace-nowrap text-white" > {displayLabel} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 26acaf34..c806e0df 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -247,6 +247,10 @@ export function UniversalFormModalComponent({ // 폼 데이터 상태 const [formData, setFormData] = useState({}); + // formDataRef: 항상 최신 formData를 유지하는 ref + // React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서 + // 클로저의 formData가 오래된 값을 참조하는 문제를 방지 + const formDataRef = useRef({}); const [, setOriginalData] = useState>({}); // 반복 섹션 데이터 @@ -398,18 +402,19 @@ export function UniversalFormModalComponent({ console.log("[UniversalFormModal] beforeFormSave 이벤트 수신"); console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields)); + // formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지 + const latestFormData = formDataRef.current; + // 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용) - // - 신규 등록: formData.id가 없으므로 영향 없음 - // - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용 - if (formData.id !== undefined && formData.id !== null && formData.id !== "") { - event.detail.formData.id = formData.id; - console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id); + if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") { + event.detail.formData.id = latestFormData.id; + console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id); } // UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함) // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 // (UniversalFormModal이 해당 필드의 주인이므로) - for (const [key, value] of Object.entries(formData)) { + for (const [key, value] of Object.entries(latestFormData)) { // 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합 const isConfiguredField = configuredFields.has(key); const isNumberingRuleId = key.endsWith("_numberingRuleId"); @@ -432,17 +437,13 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) - // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), - // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 - for (const [key, value] of Object.entries(formData)) { - // 싱글/더블 언더스코어 모두 처리 + // formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장 + for (const [key, value] of Object.entries(latestFormData)) { + // _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달 + // buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합 if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { - // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "_tableSection_") - : key; - event.detail.formData[normalizedKey] = value; - console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); + event.detail.formData[key] = value; + console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`); } // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) @@ -457,6 +458,22 @@ export function UniversalFormModalComponent({ event.detail.formData._originalGroupedData = originalGroupedData; console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`); } + + // 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트 + // onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트 + for (const parentKey of Object.keys(event.detail.formData)) { + const parentValue = event.detail.formData[parentKey]; + if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) { + const hasTableSection = Object.keys(parentValue).some( + (k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"), + ); + if (hasTableSection) { + event.detail.formData[parentKey] = { ...latestFormData }; + console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`); + break; + } + } + } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); @@ -482,10 +499,11 @@ export function UniversalFormModalComponent({ // 테이블 섹션 데이터 설정 const tableSectionKey = `_tableSection_${tableSection.id}`; - setFormData((prev) => ({ - ...prev, - [tableSectionKey]: _groupedData, - })); + setFormData((prev) => { + const newData = { ...prev, [tableSectionKey]: _groupedData }; + formDataRef.current = newData; + return newData; + }); groupedDataInitializedRef.current = true; }, [_groupedData, config.sections]); @@ -965,6 +983,7 @@ export function UniversalFormModalComponent({ } setFormData(newFormData); + formDataRef.current = newFormData; setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); setActivatedOptionalFieldGroups(newActivatedGroups); @@ -1132,6 +1151,9 @@ export function UniversalFormModalComponent({ console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`); } + // ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능) + formDataRef.current = newData; + // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) if (onChange) { setTimeout(() => onChange(newData), 0); diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 1f922188..b4c16e64 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -4267,7 +4267,7 @@ export const TableListComponent: React.FC = ({ // 다중 값인 경우: 여러 배지 렌더링 return ( -
+
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; @@ -4276,7 +4276,7 @@ export const TableListComponent: React.FC = ({ // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 if (!displayColor || displayColor === "none" || !categoryData) { return ( - + {displayLabel} {idx < values.length - 1 && ", "} @@ -4290,7 +4290,7 @@ export const TableListComponent: React.FC = ({ backgroundColor: displayColor, borderColor: displayColor, }} - className="text-white" + className="shrink-0 whitespace-nowrap text-white" > {displayLabel} diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index b56d563c..6009e13f 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2108,31 +2108,72 @@ export class ButtonActionExecutor { const sections: any[] = modalComponentConfig?.sections || []; const saveConfig = modalComponentConfig?.saveConfig || {}; - // _tableSection_ 데이터 추출 + // 테이블 섹션 데이터 수집: DB 전체 데이터를 베이스로, 수정 데이터를 오버라이드 const tableSectionData: Record = {}; const commonFieldsData: Record = {}; - // 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) - // modalData 내부 또는 최상위 formData에서 찾음 + // 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; + // 1단계: DB 데이터(__tableSection_)와 수정 데이터(_tableSection_)를 별도로 수집 + const dbSectionData: Record = {}; + const modifiedSectionData: Record = {}; + + // 1-1: modalData(부모의 중첩 객체)에서 수집 for (const [key, value] of Object.entries(modalData)) { - // initializeForm: __tableSection_ (더블), 수정 시: _tableSection_ (싱글) → 통일 처리 - if (key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) { - if (Array.isArray(value)) { - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "") - : key.replace("_tableSection_", ""); - // 싱글 언더스코어 키(수정된 데이터)가 더블 언더스코어 키(초기 데이터)보다 우선 - if (!tableSectionData[normalizedKey] || key.startsWith("_tableSection_")) { - tableSectionData[normalizedKey] = value as any[]; - } - } + if (key.startsWith("__tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("__tableSection_", ""); + dbSectionData[sectionId] = value; + } else if (key.startsWith("_tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("_tableSection_", ""); + modifiedSectionData[sectionId] = value; } else if (!key.startsWith("_")) { commonFieldsData[key] = value; } } + // 1-2: top-level formData에서도 수집 (handleBeforeFormSave가 직접 설정한 최신 데이터) + // modalData(중첩 객체)가 아직 업데이트되지 않았을 수 있으므로 보완 + for (const [key, value] of Object.entries(formData)) { + if (key === universalFormModalKey) continue; + if (key.startsWith("__tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("__tableSection_", ""); + if (!dbSectionData[sectionId]) { + dbSectionData[sectionId] = value; + } + } else if (key.startsWith("_tableSection_") && Array.isArray(value)) { + const sectionId = key.replace("_tableSection_", ""); + if (!modifiedSectionData[sectionId]) { + modifiedSectionData[sectionId] = value; + } + } + } + + // 2단계: DB 데이터를 베이스로, 수정 데이터를 아이템별로 병합하여 전체 데이터 구성 + // - DB 데이터(__tableSection_): initializeForm에서 로드한 전체 컬럼 데이터 + // - 수정 데이터(_tableSection_): _groupedData 또는 사용자 UI 수정을 통해 설정된 데이터 (일부 컬럼만 포함 가능) + // - 병합: { ...dbItem, ...modItem } → DB 전체 컬럼 유지 + 수정된 필드만 오버라이드 + const allSectionIds = new Set([...Object.keys(dbSectionData), ...Object.keys(modifiedSectionData)]); + + for (const sectionId of allSectionIds) { + const dbItems = dbSectionData[sectionId] || []; + const modItems = modifiedSectionData[sectionId]; + + if (modItems) { + tableSectionData[sectionId] = modItems.map((modItem) => { + if (modItem.id) { + const dbItem = dbItems.find((db) => String(db.id) === String(modItem.id)); + if (dbItem) { + return { ...dbItem, ...modItem }; + } + } + return modItem; + }); + } else { + tableSectionData[sectionId] = dbItems; + } + } + // 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음 const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0); if (!hasTableSectionData && originalGroupedData.length === 0) { @@ -2262,28 +2303,26 @@ export class ButtonActionExecutor { // 각 테이블 섹션 처리 for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { - // 🆕 해당 섹션의 설정 찾기 const sectionConfig = sections.find((s) => s.id === sectionId); const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable; - - // 🆕 실제 저장할 테이블 결정 - // - targetTable이 있으면 해당 테이블에 저장 - // - targetTable이 없으면 메인 테이블에 저장 const saveTableName = targetTableName || tableName!; + // 섹션별 DB 원본 데이터 조회 (전체 컬럼 보장) + // _originalTableSectionData_: initializeForm에서 DB 로드 시 저장한 원본 데이터 + const sectionOriginalKey = `_originalTableSectionData_${sectionId}`; + const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || []; + // 1️⃣ 신규 품목 INSERT (id가 없는 항목) const newItems = currentItems.filter((item) => !item.id); for (const item of newItems) { const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; - // 내부 메타데이터 제거 Object.keys(rowToSave).forEach((key) => { if (key.startsWith("_")) { delete rowToSave[key]; } }); - // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; } @@ -2303,28 +2342,30 @@ export class ButtonActionExecutor { insertedCount++; } - // 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만) + // 2️⃣ 기존 품목 UPDATE (id가 있는 항목) + // 전체 데이터 기반 저장: DB 데이터(__tableSection_)를 베이스로 수정 데이터가 병합된 완전한 item 사용 const existingItems = currentItems.filter((item) => item.id); for (const item of existingItems) { - const originalItem = originalGroupedData.find((orig) => orig.id === item.id); + // DB 원본 데이터 우선 사용 (전체 컬럼 보장), 없으면 originalGroupedData에서 탐색 + const originalItem = + sectionOriginalData.find((orig) => String(orig.id) === String(item.id)) || + originalGroupedData.find((orig) => String(orig.id) === String(item.id)); + + // 마스터/디테일 분리 시: 디테일 데이터만 사용 (마스터 필드 병합 안 함) + // 같은 테이블 시: 공통 필드도 병합 (공유 필드 업데이트 필요) + const dataToSave = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item }; if (!originalItem) { - // 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도 - // originalGroupedData 전달이 누락된 경우를 처리 - console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`); + // 원본 없음: 전체 데이터로 UPDATE 실행 + console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - 전체 데이터로 UPDATE: id=${item.id}`); - // 마스터/디테일 테이블 분리 시: commonFieldsData 병합하지 않음 - // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 - const rowToUpdate = hasSeparateTargetTable - ? { ...item, ...userInfo } - : { ...commonFieldsData, ...item, ...userInfo }; + const rowToUpdate = { ...dataToSave, ...userInfo }; Object.keys(rowToUpdate).forEach((key) => { if (key.startsWith("_")) { delete rowToUpdate[key]; } }); - // id를 유지하고 UPDATE 실행 const updateResult = await DynamicFormApi.updateFormData(item.id, { tableName: saveTableName, data: rowToUpdate, @@ -2338,20 +2379,14 @@ export class ButtonActionExecutor { continue; } - // 변경 사항 확인 - // 마스터/디테일 테이블이 분리된 경우(hasSeparateTargetTable): - // 마스터 필드(commonFieldsData)를 디테일에 병합하지 않음 - // → 동명 컬럼(예: memo)이 마스터 값으로 덮어씌워지는 문제 방지 - // 같은 테이블인 경우: 공통 필드 병합 유지 (공유 필드 업데이트 필요) - const dataForComparison = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item }; - const hasChanges = this.checkForChanges(originalItem, dataForComparison); + // 변경 사항 확인: 원본(DB) vs 현재(병합된 전체 데이터) + const hasChanges = this.checkForChanges(originalItem, dataToSave); if (hasChanges) { - // 변경된 필드만 추출하여 부분 업데이트 const updateResult = await DynamicFormApi.updateFormDataPartial( item.id, originalItem, - dataForComparison, + dataToSave, saveTableName, ); @@ -2360,16 +2395,11 @@ export class ButtonActionExecutor { } updatedCount++; - } else { } } // 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) - // 🆕 테이블 섹션별 원본 데이터 사용 (우선), 없으면 전역 originalGroupedData 사용 - const sectionOriginalKey = `_originalTableSectionData_${sectionId}`; - const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || []; - - // 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용 + // 섹션별 DB 원본 데이터 사용 (위에서 이미 조회), 없으면 전역 originalGroupedData 사용 const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData; // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지) -- 2.43.0 From ed9e36c213b5cb4b642a8ab6f4eb4a13a8437392 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 25 Feb 2026 13:59:51 +0900 Subject: [PATCH 06/15] feat: Enhance table data addition with inserted ID response - Updated the `addTableData` method in `TableManagementService` to return the inserted ID after adding data to the table. - Modified the `addTableData` controller to log the inserted ID and include it in the API response, improving client-side data handling. - Enhanced the `BomTreeComponent` to support additional configurations and improve data loading logic. - Updated the `ButtonActionExecutor` to handle deferred saves with level-based grouping, ensuring proper ID mapping during master-detail saves. --- .../controllers/tableManagementController.ts | 7 +- .../src/services/tableManagementService.ts | 10 +- .../screen/panels/V2PropertiesPanel.tsx | 3 +- .../v2/config-panels/V2BomTreeConfigPanel.tsx | 935 ++++++++++++++++++ .../v2-bom-tree/BomTreeComponent.tsx | 724 ++++++++------ frontend/lib/utils/buttonActions.ts | 56 +- 6 files changed, 1413 insertions(+), 322 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 320ab74b..430ccfa0 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -922,13 +922,14 @@ export async function addTableData( } // 데이터 추가 - await tableManagementService.addTableData(tableName, data); + const result = await tableManagementService.addTableData(tableName, data); - logger.info(`테이블 데이터 추가 완료: ${tableName}`); + logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); - const response: ApiResponse = { + const response: ApiResponse<{ id: string | null }> = { success: true, message: "테이블 데이터를 성공적으로 추가했습니다.", + data: { id: result.insertedId }, }; res.status(201).json(response); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e0f3944..252c5a89 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2438,7 +2438,7 @@ export class TableManagementService { async addTableData( tableName: string, data: Record - ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { + ): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); @@ -2551,19 +2551,21 @@ export class TableManagementService { const insertQuery = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) + RETURNING id `; logger.info(`실행할 쿼리: ${insertQuery}`); logger.info(`쿼리 파라미터:`, values); - await query(insertQuery, values); + const insertResult = await query(insertQuery, values) as any[]; + const insertedId = insertResult?.[0]?.id ?? null; - logger.info(`테이블 데이터 추가 완료: ${tableName}`); + logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`); - // 무시된 컬럼과 저장된 컬럼 정보 반환 return { skippedColumns, savedColumns: existingColumns, + insertedId, }; } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cb0ca751..2999ed74 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC = ({ "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, "v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel, + "v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel, }; const V2ConfigPanel = v2ConfigPanels[componentId]; @@ -240,7 +241,7 @@ export const V2PropertiesPanel: React.FC = ({ if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } - if (componentId === "v2-bom-item-editor") { + if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; } diff --git a/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx new file mode 100644 index 00000000..499d0a93 --- /dev/null +++ b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx @@ -0,0 +1,935 @@ +"use client"; + +/** + * BOM 트리 뷰 설정 패널 + * + * V2BomItemEditorConfigPanel 구조 기반: + * - 기본 탭: 디테일 테이블 + 엔티티 선택 + 트리 설정 + * - 컬럼 탭: 소스 표시 컬럼 + 디테일 컬럼 + 선택된 컬럼 상세 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + Trash2, + GripVertical, + ArrowRight, + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Check, + ChevronsUpDown, + GitBranch, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +interface TableRelation { + tableName: string; + tableLabel: string; + foreignKeyColumn: string; + referenceColumn: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface TreeColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + hidden?: boolean; + isSourceDisplay?: boolean; +} + +interface BomTreeConfig { + detailTable?: string; + foreignKey?: string; + parentKey?: string; + + dataSource?: { + sourceTable?: string; + foreignKey?: string; + referenceKey?: string; + displayColumn?: string; + }; + + columns: TreeColumnConfig[]; + + features?: { + showExpandAll?: boolean; + showHeader?: boolean; + showQuantity?: boolean; + showLossRate?: boolean; + }; +} + +interface V2BomTreeConfigPanelProps { + config: BomTreeConfig; + onChange: (config: BomTreeConfig) => void; + currentTableName?: string; + screenTableName?: string; +} + +export function V2BomTreeConfigPanel({ + config: propConfig, + onChange, + currentTableName: propCurrentTableName, + screenTableName, +}: V2BomTreeConfigPanelProps) { + const currentTableName = screenTableName || propCurrentTableName; + + const config: BomTreeConfig = useMemo( + () => ({ + columns: [], + ...propConfig, + dataSource: { ...propConfig?.dataSource }, + features: { + showExpandAll: true, + showHeader: true, + showQuantity: true, + showLossRate: true, + ...propConfig?.features, + }, + }), + [propConfig], + ); + + const [detailTableColumns, setDetailTableColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]); + const [relatedTables, setRelatedTables] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingRelations, setLoadingRelations] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [expandedColumn, setExpandedColumn] = useState(null); + + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateFeatures = useCallback( + (field: string, value: any) => { + updateConfig({ features: { ...config.features, [field]: value } }); + }, + [config.features, updateConfig], + ); + + // 전체 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 연관 테이블 로드 + useEffect(() => { + const loadRelatedTables = async () => { + const baseTable = currentTableName; + if (!baseTable) { + setRelatedTables([]); + return; + } + setLoadingRelations(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/table-management/columns/${baseTable}/referenced-by`, + ); + if (response.data.success && response.data.data) { + setRelatedTables( + response.data.data.map((rel: any) => ({ + tableName: rel.tableName || rel.table_name, + tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + })), + ); + } + } catch (error) { + console.error("연관 테이블 로드 실패:", error); + setRelatedTables([]); + } finally { + setLoadingRelations(false); + } + }; + loadRelatedTables(); + }, [currentTableName]); + + // 디테일 테이블 선택 + const handleDetailTableSelect = useCallback( + (tableName: string) => { + const relation = relatedTables.find((r) => r.tableName === tableName); + updateConfig({ + detailTable: tableName, + foreignKey: relation?.foreignKeyColumn || config.foreignKey, + }); + }, + [relatedTables, config.foreignKey, updateConfig], + ); + + // 디테일 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.detailTable) { + setDetailTableColumns([]); + setEntityColumns([]); + return; + } + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(config.detailTable); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + let detailSettings: any = null; + if (c.detailSettings) { + try { + detailSettings = + typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; + } catch { + // ignore + } + } + + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, + }; + cols.push(col); + + if (col.inputType === "entity") { + const refTable = detailSettings?.referenceTable || c.referenceTable; + if (refTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable: refTable, + referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id", + displayColumn: detailSettings?.displayColumn || c.displayColumn, + }); + } + } + } + + setDetailTableColumns(cols); + setEntityColumns(entityCols); + } catch (error) { + console.error("컬럼 로드 실패:", error); + setDetailTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.detailTable]); + + // 소스(엔티티) 테이블 컬럼 로드 + useEffect(() => { + const loadSourceColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + setSourceTableColumns( + columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })), + ); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + loadSourceColumns(); + }, [config.dataSource?.sourceTable]); + + // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + }); + } + }; + + // 컬럼 토글 + const toggleDetailColumn = (column: ColumnOption) => { + const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay); + if (exists >= 0) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); + if (exists) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const isColumnAdded = (columnName: string) => + config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + + const isSourceColumnSelected = (columnName: string) => + config.columns.some((c) => c.key === columnName && c.isSourceDisplay); + + const updateColumnProp = (key: string, field: keyof TreeColumnConfig, value: any) => { + updateConfig({ + columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)), + }); + }; + + // FK/시스템 컬럼 제외한 표시 가능 컬럼 + const displayableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + const systemCols = ["id", "created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter( + (col) => col.columnName !== fkColumn && col.inputType !== "entity" && !systemCols.includes(col.columnName), + ); + }, [detailTableColumns, config.dataSource?.foreignKey]); + + // FK 후보 컬럼 + const fkCandidateColumns = useMemo(() => { + const systemCols = ["created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter((c) => !systemCols.includes(c.columnName)); + }, [detailTableColumns]); + + return ( +
+ + + + 기본 + + + 컬럼 + + + + {/* ─── 기본 설정 탭 ─── */} + + {/* 디테일 테이블 */} +
+ + +
+
+ +
+

+ {config.detailTable + ? allTables.find((t) => t.tableName === config.detailTable)?.displayName || config.detailTable + : "미설정"} +

+ {config.detailTable && config.foreignKey && ( +

+ FK: {config.foreignKey} → {currentTableName || "메인 테이블"}.id +

+ )} +
+
+
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {relatedTables.length > 0 && ( + + {relatedTables.map((rel) => ( + { + handleDetailTableSelect(rel.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {rel.tableLabel} + + ({rel.foreignKeyColumn}) + + + ))} + + )} + + + {allTables + .filter((t) => !relatedTables.some((r) => r.tableName === t.tableName)) + .map((table) => ( + { + handleDetailTableSelect(table.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + +
+ + + + {/* 트리 구조 설정 */} +
+
+ + +
+

+ 메인 FK와 부모-자식 계층 FK를 선택하세요 +

+ + {fkCandidateColumns.length > 0 ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "디테일 테이블을 먼저 선택하세요"} +

+
+ )} +
+ + + + {/* 엔티티 선택 (품목 참조) */} +
+ +

+ 트리 노드에 표시할 품목 정보의 소스 엔티티 +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns + ? "로딩 중..." + : !config.detailTable + ? "디테일 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

참조 테이블: {config.dataSource.sourceTable}

+

FK 컬럼: {config.dataSource.foreignKey}

+
+
+ )} +
+ + + + {/* 표시 옵션 */} +
+ +
+
+ updateFeatures("showExpandAll", !!checked)} + /> + +
+
+ updateFeatures("showHeader", !!checked)} + /> + +
+
+ updateFeatures("showQuantity", !!checked)} + /> + +
+
+ updateFeatures("showLossRate", !!checked)} + /> + +
+
+
+ + {/* 메인 화면 테이블 참고 */} + {currentTableName && ( + <> + +
+ +
+

{currentTableName}

+

+ 컬럼 {detailTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+
+ + )} +
+ + {/* ─── 컬럼 설정 탭 ─── */} + +
+ +

+ 트리 노드에 표시할 소스/디테일 컬럼을 선택하세요 +

+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
+ {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + 표시 +
+ ))} +
+ )} + + )} + + {/* 디테일 테이블 컬럼 */} +
+ + 디테일 테이블 ({config.detailTable || "미선택"}) - 직접 컬럼 +
+ {loadingColumns ? ( +

로딩 중...

+ ) : displayableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {displayableColumns.map((column) => ( +
toggleDetailColumn(column)} + > + toggleDetailColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + {column.inputType} +
+ ))} +
+ )} +
+ + {/* 선택된 컬럼 상세 */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col, index) => ( +
+
e.dataTransfer.setData("columnIndex", String(index))} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + + {!col.isSourceDisplay && ( + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {!col.isSourceDisplay && ( + + )} + + +
+ + {/* 확장 상세 */} + {!col.isSourceDisplay && expandedColumn === col.key && ( +
+
+ + updateColumnProp(col.key, "width", e.target.value)} + placeholder="auto, 100px, 20%" + className="h-6 text-xs" + /> +
+
+ )} +
+ ))} +
+
+ + )} +
+
+
+ ); +} + +V2BomTreeConfigPanel.displayName = "V2BomTreeConfigPanel"; + +export default V2BomTreeConfigPanel; diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index 9573472d..4b5e263c 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -1,47 +1,39 @@ "use client"; import React, { useState, useEffect, useCallback, useMemo } from "react"; -import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react"; +import { + ChevronRight, + ChevronDown, + Package, + Layers, + Box, + AlertCircle, + Expand, + Shrink, + Loader2, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { entityJoinApi } from "@/lib/api/entityJoin"; +import { Button } from "@/components/ui/button"; -/** - * BOM 트리 노드 데이터 - */ interface BomTreeNode { id: string; - bom_id: string; - parent_detail_id: string | null; - seq_no: string; - level: string; - child_item_id: string; - child_item_code: string; - child_item_name: string; - child_item_type: string; - quantity: string; - unit: string; - loss_rate: string; - remark: string; + [key: string]: any; children: BomTreeNode[]; } -/** - * BOM 헤더 정보 - */ interface BomHeaderInfo { id: string; - bom_number: string; - item_code: string; - item_name: string; - item_type: string; - base_qty: string; - unit: string; - version: string; - revision: string; - status: string; - effective_date: string; - expired_date: string; - remark: string; + [key: string]: any; +} + +interface TreeColumnDef { + key: string; + title: string; + width?: string; + visible?: boolean; + hidden?: boolean; + isSourceDisplay?: boolean; } interface BomTreeComponentProps { @@ -54,10 +46,9 @@ interface BomTreeComponentProps { [key: string]: any; } -/** - * BOM 트리 컴포넌트 - * 좌측 패널에서 BOM 헤더 선택 시 계층 구조로 BOM 디테일을 표시 - */ +// 컬럼은 설정 패널에서만 추가 (하드코딩 금지) +const EMPTY_COLUMNS: TreeColumnDef[] = []; + export function BomTreeComponent({ component, formData, @@ -73,18 +64,14 @@ export function BomTreeComponent({ const [selectedNodeId, setSelectedNodeId] = useState(null); const config = component?.componentConfig || {}; + const overrides = component?.overrides || {}; - // 선택된 BOM 헤더에서 bom_id 추출 const selectedBomId = useMemo(() => { - // SplitPanel에서 좌측 선택 시 formData나 selectedRowsData로 전달됨 - if (selectedRowsData && selectedRowsData.length > 0) { - return selectedRowsData[0]?.id; - } + if (selectedRowsData && selectedRowsData.length > 0) return selectedRowsData[0]?.id; if (formData?.id) return formData.id; return null; }, [formData, selectedRowsData]); - // 선택된 BOM 헤더 정보 추출 (조인 필드명 매핑 포함) const selectedHeaderData = useMemo(() => { const raw = selectedRowsData?.[0] || (formData?.id ? formData : null); if (!raw) return null; @@ -96,12 +83,45 @@ export function BomTreeComponent({ } as BomHeaderInfo; }, [formData, selectedRowsData]); - // BOM 디테일 데이터 로드 - const detailTable = config.detailTable || "bom_detail"; - const foreignKey = config.foreignKey || "bom_id"; - const sourceFk = "child_item_id"; + const detailTable = overrides.detailTable || config.detailTable || "bom_detail"; + const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id"; + const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id"; + const sourceFk = config.dataSource?.foreignKey || "child_item_id"; - const loadBomDetails = useCallback(async (bomId: string) => { + const displayColumns = useMemo(() => { + const configured = config.columns as TreeColumnDef[] | undefined; + if (configured && configured.length > 0) return configured.filter((c) => !c.hidden); + return EMPTY_COLUMNS; + }, [config.columns]); + + const features = config.features || {}; + + // ─── 데이터 로드 ─── + + // BOM 헤더 데이터로 가상 0레벨 루트 노드 생성 + const buildVirtualRoot = useCallback((headerData: BomHeaderInfo | null, children: BomTreeNode[]): BomTreeNode | null => { + if (!headerData) return null; + return { + id: `__root_${headerData.id}`, + _isVirtualRoot: true, + level: "0", + child_item_name: headerData.item_name || "", + child_item_code: headerData.item_code || headerData.bom_number || "", + child_item_type: headerData.item_type || "", + item_name: headerData.item_name || "", + item_number: headerData.item_code || "", + quantity: "-", + base_qty: headerData.base_qty || "", + unit: headerData.unit || "", + revision: headerData.revision || "", + loss_rate: "", + process_type: "", + remark: headerData.remark || "", + children, + }; + }, []); + + const loadBomDetails = useCallback(async (bomId: string, headerData: BomHeaderInfo | null) => { if (!bomId) return; setLoading(true); try { @@ -116,72 +136,73 @@ export function BomTreeComponent({ const rows = (result.data || []).map((row: Record) => { const mapped = { ...row }; - // 엔티티 조인 필드 매핑: child_item_id_item_name → child_item_name 등 + for (const key of Object.keys(row)) { + if (key.startsWith(`${sourceFk}_`)) { + const shortKey = key.replace(`${sourceFk}_`, ""); + const aliasKey = `child_${shortKey}`; + if (!mapped[aliasKey]) mapped[aliasKey] = row[key]; + if (!mapped[shortKey]) mapped[shortKey] = row[key]; + } + } mapped.child_item_name = row[`${sourceFk}_item_name`] || row.child_item_name || ""; mapped.child_item_code = row[`${sourceFk}_item_number`] || row.child_item_code || ""; mapped.child_item_type = row[`${sourceFk}_type`] || row[`${sourceFk}_division`] || row.child_item_type || ""; return mapped; }); - const tree = buildTree(rows); - setTreeData(tree); - const firstLevelIds = new Set(tree.map((n: BomTreeNode) => n.id)); - setExpandedNodes(firstLevelIds); + const detailTree = buildTree(rows); + + // BOM 헤더를 가상 0레벨 루트로 삽입 + const virtualRoot = buildVirtualRoot(headerData, detailTree); + if (virtualRoot) { + setTreeData([virtualRoot]); + setExpandedNodes(new Set([virtualRoot.id])); + } else { + setTreeData(detailTree); + const firstLevelIds = new Set(detailTree.map((n: BomTreeNode) => n.id)); + setExpandedNodes(firstLevelIds); + } } catch (error) { console.error("[BomTree] 데이터 로드 실패:", error); } finally { setLoading(false); } - }, [detailTable, foreignKey]); + }, [detailTable, foreignKey, sourceFk, buildVirtualRoot]); - // 평면 데이터 -> 트리 구조 변환 const buildTree = (flatData: any[]): BomTreeNode[] => { const nodeMap = new Map(); const roots: BomTreeNode[] = []; - - // 모든 노드를 맵에 등록 - flatData.forEach((item) => { - nodeMap.set(item.id, { ...item, children: [] }); - }); - - // 부모-자식 관계 설정 + flatData.forEach((item) => nodeMap.set(item.id, { ...item, children: [] })); flatData.forEach((item) => { const node = nodeMap.get(item.id)!; - if (item.parent_detail_id && nodeMap.has(item.parent_detail_id)) { - nodeMap.get(item.parent_detail_id)!.children.push(node); + if (item[parentKey] && nodeMap.has(item[parentKey])) { + nodeMap.get(item[parentKey])!.children.push(node); } else { roots.push(node); } }); - return roots; }; - // 선택된 BOM 변경 시 데이터 로드 useEffect(() => { if (selectedBomId) { setHeaderInfo(selectedHeaderData); - loadBomDetails(selectedBomId); + loadBomDetails(selectedBomId, selectedHeaderData); } else { setHeaderInfo(null); setTreeData([]); } }, [selectedBomId, selectedHeaderData, loadBomDetails]); - // 노드 펼치기/접기 토글 const toggleNode = useCallback((nodeId: string) => { setExpandedNodes((prev) => { const next = new Set(prev); - if (next.has(nodeId)) { - next.delete(nodeId); - } else { - next.add(nodeId); - } + if (next.has(nodeId)) next.delete(nodeId); + else next.add(nodeId); return next; }); }, []); - // 전체 펼치기 const expandAll = useCallback(() => { const allIds = new Set(); const collectIds = (nodes: BomTreeNode[]) => { @@ -194,270 +215,381 @@ export function BomTreeComponent({ setExpandedNodes(allIds); }, [treeData]); - // 전체 접기 - const collapseAll = useCallback(() => { - setExpandedNodes(new Set()); - }, []); + const collapseAll = useCallback(() => setExpandedNodes(new Set()), []); + + // ─── 유틸 ─── - // 품목 구분 라벨 const getItemTypeLabel = (type: string) => { - switch (type) { - case "product": return "제품"; - case "semi": return "반제품"; - case "material": return "원자재"; - case "part": return "부품"; - default: return type || "-"; - } + const map: Record = { product: "제품", semi: "반제품", material: "원자재", part: "부품" }; + return map[type] || type || "-"; }; - // 품목 구분 아이콘 & 색상 - const getItemTypeStyle = (type: string) => { - switch (type) { - case "product": - return { icon: Package, color: "text-blue-600", bg: "bg-blue-50" }; - case "semi": - return { icon: Layers, color: "text-amber-600", bg: "bg-amber-50" }; - case "material": - return { icon: Box, color: "text-emerald-600", bg: "bg-emerald-50" }; - default: - return { icon: Box, color: "text-gray-500", bg: "bg-gray-50" }; - } + const getItemTypeBadge = (type: string) => { + const map: Record = { + product: "bg-blue-50 text-blue-600 ring-blue-200", + semi: "bg-amber-50 text-amber-600 ring-amber-200", + material: "bg-emerald-50 text-emerald-600 ring-emerald-200", + part: "bg-purple-50 text-purple-600 ring-purple-200", + }; + return map[type] || "bg-gray-50 text-gray-500 ring-gray-200"; }; - // 디자인 모드 미리보기 + const getItemIcon = (type: string) => { + const map: Record = { product: Package, semi: Layers }; + return map[type] || Box; + }; + + const getItemIconColor = (type: string) => { + const map: Record = { + product: "text-blue-500", + semi: "text-amber-500", + material: "text-emerald-500", + part: "text-purple-500", + }; + return map[type] || "text-gray-400"; + }; + + // ─── 셀 렌더링 ─── + + const renderCellValue = (node: BomTreeNode, col: TreeColumnDef, depth: number) => { + const value = node[col.key]; + + if (col.key === "child_item_type" || col.key === "item_type") { + const label = getItemTypeLabel(String(value || "")); + return ( + + {label} + + ); + } + + if (col.key === "level") { + return ( + + {value ?? depth} + + ); + } + + if (col.key === "child_item_code") { + return {value || "-"}; + } + + if (col.key === "child_item_name") { + return {value || "-"}; + } + + if (col.key === "quantity" || col.key === "base_qty") { + return ( + + {value != null && value !== "" && value !== "0" ? value : "-"} + + ); + } + + if (col.key === "loss_rate") { + const num = Number(value); + if (!num) return -; + return {value}%; + } + + if (col.key === "revision") { + return ( + + {value != null && value !== "" && value !== "0" ? value : "-"} + + ); + } + + if (col.key === "unit") { + return {value || "-"}; + } + + return {value ?? "-"}; + }; + + // ─── 디자인 모드 ─── + if (isDesignMode) { + const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden); + return ( -
-
- - BOM 트리 뷰 +
+
+ + BOM 트리 뷰 + {detailTable} + {config.dataSource?.sourceTable && ( + + {config.dataSource.sourceTable} + + )}
-
-
- - - 완제품 A (제품) - 수량: 1 + + {configuredColumns.length === 0 ? ( +
+ +

컬럼 미설정

+

설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요

-
- - - 반제품 B (반제품) - 수량: 2 + ) : ( +
+ + + + + {configuredColumns.map((col: TreeColumnDef) => ( + + ))} + + + + + + {configuredColumns.map((col: TreeColumnDef, i: number) => ( + + ))} + + + + {configuredColumns.map((col: TreeColumnDef, i: number) => ( + + ))} + + +
+ {col.title || col.key} +
+ + + {col.key === "level" ? "0" : col.key.includes("type") ? ( + 제품 + ) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`} +
+ + + {col.key === "level" ? "1" : col.key.includes("type") ? ( + 반제품 + ) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`} +
-
- - - 원자재 C (원자재) - 수량: 5 -
-
+ )}
); } - // 선택 안 된 상태 + // ─── 미선택 상태 ─── + if (!selectedBomId) { return ( -
-
-

좌측에서 BOM을 선택하세요

-

선택한 BOM의 구성 정보가 트리로 표시됩니다

+
+
+ +

BOM을 선택해주세요

+

좌측 목록에서 BOM을 선택하면 구성이 표시됩니다

); } + // ─── 트리 평탄화 ─── + + const flattenedRows = useMemo(() => { + const rows: { node: BomTreeNode; depth: number }[] = []; + const traverse = (nodes: BomTreeNode[], depth: number) => { + for (const node of nodes) { + rows.push({ node, depth }); + if (node.children.length > 0 && expandedNodes.has(node.id)) { + traverse(node.children, depth + 1); + } + } + }; + traverse(treeData, 0); + return rows; + }, [treeData, expandedNodes]); + + // ─── 메인 렌더링 ─── + return ( -
+
{/* 헤더 정보 */} - {headerInfo && ( -
-
- -

{headerInfo.item_name || "-"}

- - {headerInfo.bom_number || "-"} - - +
+
- {headerInfo.status === "active" ? "사용" : "미사용"} - -
-
- 품목코드: {headerInfo.item_code || "-"} - 구분: {getItemTypeLabel(headerInfo.item_type)} - 기준수량: {headerInfo.base_qty || "1"} {headerInfo.unit || ""} - 버전: v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"}) + +
+
+
+

+ {headerInfo.item_name || "-"} +

+ + {getItemTypeLabel(headerInfo.item_type)} + + + {headerInfo.status === "active" ? "사용" : "미사용"} + +
+
+ 품목코드 {headerInfo.item_code || "-"} + 기준수량 {headerInfo.base_qty || "1"} + 버전 v{headerInfo.version || "1"} (차수 {headerInfo.revision || "1"}) +
+
)} - {/* 트리 툴바 */} -
- BOM 구성 - - {treeData.length}건 + {/* 툴바 */} +
+ BOM 구성 + + {flattenedRows.length} -
- - -
+ {features.showExpandAll !== false && ( +
+ + +
+ )}
- {/* 트리 컨텐츠 */} -
+ {/* 테이블 */} +
{loading ? ( -
-
로딩 중...
+
+ +
+ ) : displayColumns.length === 0 ? ( +
+ +

표시할 컬럼이 설정되지 않았습니다

+

디자인 모드에서 컬럼을 추가하세요

) : treeData.length === 0 ? ( -
- -

등록된 하위 품목이 없습니다

+
+ +

등록된 하위 품목이 없습니다

) : ( -
- {treeData.map((node) => ( - - ))} -
+ + + + + {displayColumns.map((col) => { + const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); + return ( + + ); + })} + + + + {flattenedRows.map(({ node, depth }, rowIdx) => { + const hasChildren = node.children.length > 0; + const isExpanded = expandedNodes.has(node.id); + const isSelected = selectedNodeId === node.id; + const isRoot = !!node._isVirtualRoot; + const itemType = node.child_item_type || node.item_type || ""; + const ItemIcon = getItemIcon(itemType); + + return ( + { + setSelectedNodeId(node.id); + if (hasChildren) toggleNode(node.id); + }} + > + + + {displayColumns.map((col) => { + const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key); + return ( + + ); + })} + + ); + })} + +
+ {col.title} +
+
+ + {hasChildren ? ( + isExpanded ? ( + + ) : ( + + ) + ) : ( + + )} + + + + +
+
+ {renderCellValue(node, col, depth)} +
)}
); } -/** - * 트리 노드 행 (재귀 렌더링) - */ -interface TreeNodeRowProps { - node: BomTreeNode; - depth: number; - expandedNodes: Set; - selectedNodeId: string | null; - onToggle: (id: string) => void; - onSelect: (id: string) => void; - getItemTypeLabel: (type: string) => string; - getItemTypeStyle: (type: string) => { icon: any; color: string; bg: string }; -} - -function TreeNodeRow({ - node, - depth, - expandedNodes, - selectedNodeId, - onToggle, - onSelect, - getItemTypeLabel, - getItemTypeStyle, -}: TreeNodeRowProps) { - const isExpanded = expandedNodes.has(node.id); - const hasChildren = node.children.length > 0; - const isSelected = selectedNodeId === node.id; - const style = getItemTypeStyle(node.child_item_type); - const ItemIcon = style.icon; - - return ( - <> -
{ - onSelect(node.id); - if (hasChildren) onToggle(node.id); - }} - > - {/* 펼치기/접기 화살표 */} - - {hasChildren ? ( - isExpanded ? ( - - ) : ( - - ) - ) : ( - - )} - - - {/* 품목 타입 아이콘 */} - - - - - {/* 품목 정보 */} -
- - {node.child_item_name || "-"} - - - {node.child_item_code || ""} - - - {getItemTypeLabel(node.child_item_type)} - -
- - {/* 수량/단위 */} -
- - 수량: {node.quantity || "0"} {node.unit || ""} - - {node.loss_rate && node.loss_rate !== "0" && ( - - 로스: {node.loss_rate}% - - )} -
-
- - {/* 하위 노드 재귀 렌더링 */} - {hasChildren && isExpanded && ( -
- {node.children.map((child) => ( - - ))} -
- )} - - ); -} +export default BomTreeComponent; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 2a5907fc..2c4af6e3 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1791,7 +1791,7 @@ export class ButtonActionExecutor { // 🔧 formData를 리피터에 전달하여 각 행에 병합 저장 const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; - // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 메인 저장 후 디테일 저장) + // _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑) if (savedId) { for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { let parsedData = fieldValue; @@ -1804,27 +1804,47 @@ export class ButtonActionExecutor { const targetTable = parsedData[0]?._targetTable; if (!targetTable) continue; - for (const item of parsedData) { - const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId: ___, ...data } = item; - if (!data.id || data.id === "") delete data.id; + // 레벨별 그룹핑 (레벨 0 먼저 저장 → 레벨 1 → ...) + const maxLevel = Math.max(...parsedData.map((item: any) => Number(item.level) || 0)); + const tempIdToRealId = new Map(); - // FK 주입 - if (fkCol) data[fkCol] = savedId; + for (let lvl = 0; lvl <= maxLevel; lvl++) { + const levelItems = parsedData.filter((item: any) => (Number(item.level) || 0) === lvl); - // 시스템 필드 추가 - data.created_by = context.userId; - data.updated_by = context.userId; - data.company_code = context.companyCode; + for (const item of levelItems) { + const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId, ...data } = item; + if (!data.id || data.id === "") delete data.id; - try { - const isNew = _isNew || !item.id || item.id === ""; - if (isNew) { - await apiClient.post(`/table-management/tables/${targetTable}/add`, data); - } else { - await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data); + // FK 주입 (bom_id 등) + if (fkCol) data[fkCol] = savedId; + + // parent_detail_id의 temp 참조를 실제 ID로 교체 + if (data.parent_detail_id && tempIdToRealId.has(data.parent_detail_id)) { + data.parent_detail_id = tempIdToRealId.get(data.parent_detail_id); + } + + // 시스템 필드 추가 + data.created_by = context.userId; + data.updated_by = context.userId; + data.company_code = context.companyCode; + + try { + const isNew = _isNew || !item.id || item.id === ""; + if (isNew) { + const res = await apiClient.post(`/table-management/tables/${targetTable}/add`, data); + const newId = res.data?.data?.id || res.data?.id; + if (newId && tempId) { + tempIdToRealId.set(tempId, newId); + } + } else { + await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data); + if (item.id && tempId) { + tempIdToRealId.set(tempId, item.id); + } + } + } catch (err: any) { + console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message); } - } catch (err: any) { - console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message); } } } -- 2.43.0 From 262221e300dd2befe9234795a0f0aa310829610f Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Feb 2026 14:42:42 +0900 Subject: [PATCH 07/15] fix: Refine ExcelUploadModal and TableListComponent for improved data handling - Updated ExcelUploadModal to automatically generate numbering codes when Excel values are empty, enhancing user experience during data uploads. - Modified TableListComponent to display only the first image in case of multiple images, ensuring clarity in image representation. - Improved data handling logic in TableListComponent to prevent unnecessary processing of string values. --- .../controllers/tableManagementController.ts | 182 +++++++++++++++++- .../src/routes/tableManagementRoutes.ts | 7 + .../src/services/tableManagementService.ts | 95 +++++++++ .../admin/systemMng/tableMngList/page.tsx | 57 ++++-- .../components/common/ExcelUploadModal.tsx | 23 ++- .../table-list/TableListComponent.tsx | 5 +- .../v2-table-list/TableListComponent.tsx | 8 +- 7 files changed, 342 insertions(+), 35 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 5657010f..a74ba8d6 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -939,6 +939,24 @@ export async function addTableData( return; } + // 회사별 UNIQUE 소프트 제약조건 검증 + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + data, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } + // 데이터 추가 await tableManagementService.addTableData(tableName, data); @@ -1041,6 +1059,26 @@ export async function editTableData( return; } + // 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외) + const excludeId = originalData?.id ? String(originalData.id) : undefined; + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + updatedData, + companyCode, + excludeId + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } + // 데이터 수정 await tableManagementService.editTableData( tableName, @@ -2653,8 +2691,22 @@ export async function toggleTableIndex( logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); if (action === "create") { + let indexColumns = `"${columnName}"`; + + // 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장) + if (indexType === "unique") { + const hasCompanyCode = await query( + `SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + if (hasCompanyCode.length > 0) { + indexColumns = `"company_code", "${columnName}"`; + logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`); + } + } + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; - const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`; logger.info(`인덱스 생성: ${sql}`); await query(sql); } else if (action === "drop") { @@ -2675,15 +2727,45 @@ export async function toggleTableIndex( } catch (error: any) { logger.error("인덱스 토글 오류:", error); - // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 - const errorMsg = error.message?.includes("duplicate key") - ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." - : "인덱스 설정 중 오류가 발생했습니다."; + const errMsg = error.message || ""; + let userMessage = "인덱스 설정 중 오류가 발생했습니다."; + let duplicates: any[] = []; + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 + if ( + errMsg.includes("could not create unique index") || + errMsg.includes("duplicate key") + ) { + const { columnName, tableName } = { ...req.params, ...req.body }; + try { + duplicates = await query( + `SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { + try { + duplicates = await query( + `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { /* 중복 조회 실패 시 무시 */ } + } + + const dupDetails = duplicates.length > 0 + ? duplicates.map((d: any) => { + const company = d.company_code ? `[${d.company_code}] ` : ""; + return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`; + }).join(", ") + : ""; + + userMessage = dupDetails + ? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}` + : `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`; + } res.status(500).json({ success: false, - message: errorMsg, - error: error instanceof Error ? error.message : "Unknown error", + message: userMessage, + error: errMsg, + duplicates, }); } } @@ -2776,3 +2858,89 @@ export async function toggleColumnNullable( }); } } + +/** + * UNIQUE 토글 (회사별 소프트 제약조건) + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + * + * DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다. + * 저장 시 앱 레벨에서 중복 검증을 수행한다. + */ +export async function toggleColumnUnique( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { unique } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !columnName || typeof unique !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, unique(boolean)이 필요합니다.", + }); + return; + } + + const isUniqueValue = unique ? "Y" : "N"; + + if (unique) { + // UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const dupQuery = companyCode === "*" + ? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10` + : `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`; + const dupParams = companyCode === "*" ? [] : [companyCode]; + + const dupResult = await query(dupQuery, dupParams); + + if (dupResult.length > 0) { + const dupDetails = dupResult + .map((d: any) => `"${d[columnName]}" (${d.cnt}건)`) + .join(", "); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`, + }); + return; + } + } + } + + // table_type_columns에 회사별 is_unique 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_unique = $3, updated_date = NOW()`, + [tableName, columnName, isUniqueValue, companyCode] + ); + + logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, { + companyCode, + }); + + res.status(200).json({ + success: true, + message: unique + ? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.` + : `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`, + }); + } catch (error: any) { + logger.error("UNIQUE 토글 오류:", error); + + res.status(500).json({ + success: false, + message: "UNIQUE 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d02a5615..a8964e99 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -32,6 +32,7 @@ import { setTablePrimaryKey, // 🆕 PK 설정 toggleTableIndex, // 🆕 인덱스 토글 toggleColumnNullable, // 🆕 NOT NULL 토글 + toggleColumnUnique, // 🆕 UNIQUE 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex); */ router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); +/** + * UNIQUE 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + */ +router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index fc83165a..76459ec6 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -204,6 +204,10 @@ export class TableManagementService { THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END ELSE c.is_nullable END as "isNullable", + CASE + WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -250,6 +254,10 @@ export class TableManagementService { THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END ELSE c.is_nullable END as "isNullable", + CASE + WHEN cl.is_unique = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -2534,6 +2542,93 @@ export class TableManagementService { } } + /** + * 회사별 UNIQUE 소프트 제약조건 검증 + * table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다. + * @param excludeId 수정 시 자기 자신은 제외 + */ + async validateUniqueConstraints( + tableName: string, + data: Record, + companyCode: string, + excludeId?: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + let uniqueColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (uniqueColumns.length === 0 && companyCode !== "*") { + const globalUnique = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + uniqueColumns = globalUnique; + } + + if (uniqueColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of uniqueColumns) { + const value = data[col.column_name]; + if (value === null || value === undefined || value === "") continue; + + // 해당 회사 내에서 같은 값이 이미 존재하는지 확인 + const hasCompanyCode = await query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + let dupQuery: string; + let dupParams: any[]; + + if (hasCompanyCode.length > 0 && companyCode !== "*") { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`; + dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode]; + } else { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`; + dupParams = excludeId ? [value, excludeId] : [value]; + } + + const dupResult = await query(dupQuery, dupParams); + if (dupResult.length > 0) { + violations.push(`${col.column_label} (${value})`); + } + } + + return violations; + } catch (error) { + logger.error(`UNIQUE 검증 오류: ${tableName}`, error); + return []; + } + } + /** * 테이블에 데이터 추가 * @returns 무시된 컬럼 정보 (디버깅용) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index cf89df73..e1869351 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -63,6 +63,7 @@ interface ColumnTypeInfo { detailSettings: string; description: string; isNullable: string; + isUnique: string; defaultValue?: string; maxLength?: number; numericPrecision?: number; @@ -382,10 +383,11 @@ export default function TableManagementPage() { return { ...col, - inputType: col.inputType || "text", // 기본값: text - numberingRuleId, // 🆕 채번규칙 ID - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - hierarchyRole, // 계층구조 역할 + inputType: col.inputType || "text", + isUnique: col.isUnique || "NO", + numberingRuleId, + categoryMenus: col.categoryMenus || [], + hierarchyRole, }; }); @@ -1091,9 +1093,9 @@ export default function TableManagementPage() { } }; - // 인덱스 토글 핸들러 + // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) const handleIndexToggle = useCallback( - async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + async (columnName: string, indexType: "index", checked: boolean) => { if (!selectedTable) return; const action = checked ? "create" : "drop"; try { @@ -1122,14 +1124,41 @@ export default function TableManagementPage() { const hasIndex = constraints.indexes.some( (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, ); - const hasUnique = constraints.indexes.some( - (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, - ); - return { isPk, hasIndex, hasUnique }; + return { isPk, hasIndex }; }, [constraints], ); + // UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴) + const handleUniqueToggle = useCallback( + async (columnName: string, currentIsUnique: string) => { + if (!selectedTable) return; + const isCurrentlyUnique = currentIsUnique === "YES"; + const newUnique = !isCurrentlyUnique; + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/unique`, + { unique: newUnique }, + ); + if (response.data.success) { + toast.success(response.data.message); + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isUnique: newUnique ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "UNIQUE 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable], + ); + // NOT NULL 토글 핸들러 const handleNullableToggle = useCallback( async (columnName: string, currentIsNullable: string) => { @@ -2029,12 +2058,12 @@ export default function TableManagementPage() { aria-label={`${column.columnName} 인덱스 설정`} />
- {/* UQ 체크박스 */} + {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
- handleIndexToggle(column.columnName, "unique", checked as boolean) + checked={column.isUnique === "YES"} + onCheckedChange={() => + handleUniqueToggle(column.columnName, column.isUnique) } aria-label={`${column.columnName} 유니크 설정`} /> diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 4797a34a..81b5ed61 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { - try { - const { apiClient } = await import("@/lib/api/client"); - const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); - const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; - if (numberingResponse.data?.success && generatedCode) { - dataToSave[numberingInfo.columnName] = generatedCode; + const existingValue = dataToSave[numberingInfo.columnName]; + const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; + + if (!hasExcelValue) { + try { + const { apiClient } = await import("@/lib/api/client"); + const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); + const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; + if (numberingResponse.data?.success && generatedCode) { + dataToSave[numberingInfo.columnName] = generatedCode; + } + } catch (numError) { + console.error("채번 오류:", numError); } - } catch (numError) { - console.error("채번 오류:", numError); } } diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 3a3d4e12..2e8ca106 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4204,9 +4204,10 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - // 🖼️ 이미지 타입: 작은 썸네일 표시 + // 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만) if (inputType === "image" && value && typeof value === "string") { - const imageUrl = getFullImageUrl(value); + const firstImage = value.includes(",") ? value.split(",")[0].trim() : value; + const imageUrl = getFullImageUrl(firstImage); return ( = React.memo(({ value }) => { React.useEffect(() => { let mounted = true; - const strValue = String(value); + // 다중 이미지인 경우 대표 이미지(첫 번째)만 사용 + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; const isObjid = /^\d+$/.test(strValue); if (isObjid) { @@ -89,8 +91,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { style={{ maxWidth: "40px", maxHeight: "40px" }} onClick={(e) => { e.stopPropagation(); - // objid인 경우 preview URL로 열기, 아니면 full URL로 열기 - const strValue = String(value); + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; const isObjid = /^\d+$/.test(strValue); const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); window.open(openUrl, "_blank"); -- 2.43.0 From 18cf5e32694f6e3c690f3068383f57cb4684b08b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 25 Feb 2026 14:50:51 +0900 Subject: [PATCH 08/15] feat: Add BOM management features and enhance BOM tree component - Integrated BOM routes into the backend for managing BOM history and versions. - Enhanced the V2BomTreeConfigPanel to include options for history and version table management. - Updated the BomTreeComponent to support viewing BOM data in both tree and level formats, with modals for editing BOM details, viewing history, and managing versions. - Improved user interaction with new buttons for accessing BOM history and version management directly from the BOM tree view. --- backend-node/src/app.ts | 2 + backend-node/src/controllers/bomController.ts | 111 +++++++ backend-node/src/routes/bomRoutes.ts | 23 ++ backend-node/src/services/bomService.ts | 181 +++++++++++ .../v2/config-panels/V2BomTreeConfigPanel.tsx | 139 ++++++++ .../v2-bom-tree/BomDetailEditModal.tsx | 212 +++++++++++++ .../v2-bom-tree/BomHistoryModal.tsx | 147 +++++++++ .../v2-bom-tree/BomTreeComponent.tsx | 296 ++++++++++++++++-- .../v2-bom-tree/BomVersionModal.tsx | 221 +++++++++++++ 9 files changed, 1311 insertions(+), 21 deletions(-) create mode 100644 backend-node/src/controllers/bomController.ts create mode 100644 backend-node/src/routes/bomRoutes.ts create mode 100644 backend-node/src/services/bomService.ts create mode 100644 frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx create mode 100644 frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx create mode 100644 frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 30e684d5..4e6da57e 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 +import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 @@ -288,6 +289,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 +app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts new file mode 100644 index 00000000..870833fc --- /dev/null +++ b/backend-node/src/controllers/bomController.ts @@ -0,0 +1,111 @@ +/** + * BOM 이력/버전 관리 컨트롤러 + */ + +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import * as bomService from "../services/bomService"; + +// ─── 이력 (History) ───────────────────────────── + +export async function getBomHistory(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const tableName = (req.query.tableName as string) || undefined; + + const data = await bomService.getBomHistory(bomId, companyCode, tableName); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 이력 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function addBomHistory(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const changedBy = (req as any).user?.userName || (req as any).user?.userId || ""; + + const { change_type, change_description, revision, version, tableName } = req.body; + if (!change_type) { + res.status(400).json({ success: false, message: "change_type은 필수입니다" }); + return; + } + + const result = await bomService.addBomHistory(bomId, companyCode, { + change_type, + change_description, + revision, + version, + changed_by: changedBy, + }, tableName); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 이력 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 버전 (Version) ───────────────────────────── + +export async function getBomVersions(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const tableName = (req.query.tableName as string) || undefined; + + const data = await bomService.getBomVersions(bomId, companyCode, tableName); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 버전 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createBomVersion(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; + const { tableName, detailTable } = req.body || {}; + + const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 생성 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function loadBomVersion(req: Request, res: Response) { + try { + const { bomId, versionId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const { tableName, detailTable } = req.body || {}; + + const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 불러오기 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteBomVersion(req: Request, res: Response) { + try { + const { bomId, versionId } = req.params; + const tableName = (req.query.tableName as string) || undefined; + + const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName); + if (!deleted) { + res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); + return; + } + res.json({ success: true }); + } catch (error: any) { + logger.error("BOM 버전 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts new file mode 100644 index 00000000..a6d4fa10 --- /dev/null +++ b/backend-node/src/routes/bomRoutes.ts @@ -0,0 +1,23 @@ +/** + * BOM 이력/버전 관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as bomController from "../controllers/bomController"; + +const router = Router(); + +router.use(authenticateToken); + +// 이력 +router.get("/:bomId/history", bomController.getBomHistory); +router.post("/:bomId/history", bomController.addBomHistory); + +// 버전 +router.get("/:bomId/versions", bomController.getBomVersions); +router.post("/:bomId/versions", bomController.createBomVersion); +router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); +router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); + +export default router; diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts new file mode 100644 index 00000000..687326df --- /dev/null +++ b/backend-node/src/services/bomService.ts @@ -0,0 +1,181 @@ +/** + * BOM 이력 및 버전 관리 서비스 + * 설정 패널에서 지정한 테이블명을 동적으로 사용 + */ + +import { query, queryOne, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// SQL 인젝션 방지: 테이블명은 알파벳, 숫자, 언더스코어만 허용 +function safeTableName(name: string, fallback: string): string { + if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback; + return name; +} + +// ─── 이력 (History) ───────────────────────────── + +export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_history"); + const sql = companyCode === "*" + ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC` + : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`; + const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; + return query(sql, params); +} + +export async function addBomHistory( + bomId: string, + companyCode: string, + data: { + revision?: string; + version?: string; + change_type: string; + change_description?: string; + changed_by?: string; + }, + tableName?: string, +) { + const table = safeTableName(tableName || "", "bom_history"); + const sql = ` + INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + return queryOne(sql, [ + bomId, + data.revision || null, + data.version || null, + data.change_type, + data.change_description || null, + data.changed_by || null, + companyCode, + ]); +} + +// ─── 버전 (Version) ───────────────────────────── + +export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_version"); + const sql = companyCode === "*" + ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY created_date DESC` + : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY created_date DESC`; + const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; + return query(sql, params); +} + +export async function createBomVersion( + bomId: string, companyCode: string, createdBy: string, + versionTableName?: string, detailTableName?: string, +) { + const vTable = safeTableName(versionTableName || "", "bom_version"); + const dTable = safeTableName(detailTableName || "", "bom_detail"); + + return transaction(async (client) => { + const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]); + if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); + const bomData = bomRow.rows[0]; + + const detailRows = await client.query( + `SELECT * FROM ${dTable} WHERE bom_id = $1 ORDER BY parent_detail_id NULLS FIRST, id`, + [bomId], + ); + + const lastVersion = await client.query( + `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, + [bomId], + ); + let nextVersionNum = 1; + if (lastVersion.rows.length > 0) { + const parsed = parseFloat(lastVersion.rows[0].version_name); + if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1; + } + const versionName = `${nextVersionNum}.0`; + + const snapshot = { + bom: bomData, + details: detailRows.rows, + detailTable: dTable, + created_at: new Date().toISOString(), + }; + + const insertSql = ` + INSERT INTO ${vTable} (bom_id, version_name, revision, status, snapshot_data, created_by, company_code) + VALUES ($1, $2, $3, 'developing', $4, $5, $6) + RETURNING * + `; + const result = await client.query(insertSql, [ + bomId, + versionName, + bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0, + JSON.stringify(snapshot), + createdBy, + companyCode, + ]); + + logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable }); + return result.rows[0]; + }); +} + +export async function loadBomVersion( + bomId: string, versionId: string, companyCode: string, + versionTableName?: string, detailTableName?: string, +) { + const vTable = safeTableName(versionTableName || "", "bom_version"); + const dTable = safeTableName(detailTableName || "", "bom_detail"); + + return transaction(async (client) => { + const verRow = await client.query( + `SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`, + [versionId, bomId], + ); + if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); + + const snapshot = verRow.rows[0].snapshot_data; + if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다"); + + // 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용 + const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable); + + await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]); + + const b = snapshot.bom; + await client.query( + `UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`, + [b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId], + ); + + const oldToNew: Record = {}; + for (const d of snapshot.details || []) { + const insertResult = await client.query( + `INSERT INTO ${snapshotDetailTable} (bom_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`, + [ + bomId, + d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null, + d.child_item_id, + d.quantity, + d.unit, + d.process_type, + d.loss_rate, + d.remark, + d.level, + d.base_qty, + d.revision, + companyCode, + ], + ); + oldToNew[d.id] = insertResult.rows[0].id; + } + + logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable }); + return { restored: true, versionName: verRow.rows[0].version_name }; + }); +} + +export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_version"); + const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`; + const result = await query(sql, [versionId, bomId]); + return result.length > 0; +} diff --git a/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx index 499d0a93..7c8c3ed1 100644 --- a/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx @@ -95,6 +95,9 @@ interface BomTreeConfig { foreignKey?: string; parentKey?: string; + historyTable?: string; + versionTable?: string; + dataSource?: { sourceTable?: string; foreignKey?: string; @@ -109,6 +112,8 @@ interface BomTreeConfig { showHeader?: boolean; showQuantity?: boolean; showLossRate?: boolean; + showHistory?: boolean; + showVersion?: boolean; }; } @@ -661,6 +666,140 @@ export function V2BomTreeConfigPanel({ + {/* 이력/버전 테이블 설정 */} +
+ +

+ BOM 변경 이력과 버전 관리에 사용할 테이블을 선택하세요 +

+ +
+
+
+ updateFeatures("showHistory", !!checked)} + /> + +
+ {(config.features?.showHistory ?? true) && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {allTables.map((table) => ( + updateConfig({ historyTable: table.tableName })} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + )} +
+ +
+
+ updateFeatures("showVersion", !!checked)} + /> + +
+ {(config.features?.showVersion ?? true) && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {allTables.map((table) => ( + updateConfig({ versionTable: table.tableName })} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + )} +
+
+
+ + + {/* 표시 옵션 */}
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx new file mode 100644 index 00000000..04ce8ebb --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx @@ -0,0 +1,212 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Loader2 } from "lucide-react"; +import apiClient from "@/lib/api/client"; + +interface BomDetailEditModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: Record | null; + isRootNode?: boolean; + tableName: string; + onSaved?: () => void; +} + +export function BomDetailEditModal({ + open, + onOpenChange, + node, + isRootNode = false, + tableName, + onSaved, +}: BomDetailEditModalProps) { + const [formData, setFormData] = useState>({}); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (node && open) { + if (isRootNode) { + setFormData({ + base_qty: node.base_qty || "", + unit: node.unit || "", + remark: node.remark || "", + }); + } else { + setFormData({ + quantity: node.quantity || "", + unit: node.unit || node.detail_unit || "", + process_type: node.process_type || "", + base_qty: node.base_qty || "", + loss_rate: node.loss_rate || "", + remark: node.remark || "", + }); + } + } + }, [node, open, isRootNode]); + + const handleChange = (field: string, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSave = async () => { + if (!node) return; + setSaving(true); + try { + const targetTable = isRootNode ? "bom" : tableName; + const realId = isRootNode ? node.id?.replace("__root_", "") : node.id; + await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData); + onSaved?.(); + onOpenChange(false); + } catch (error) { + console.error("[BomDetailEdit] 저장 실패:", error); + } finally { + setSaving(false); + } + }; + + if (!node) return null; + + const itemCode = isRootNode + ? node.child_item_code || node.item_code || node.bom_number || "-" + : node.child_item_code || "-"; + const itemName = isRootNode + ? node.child_item_name || node.item_name || "-" + : node.child_item_name || "-"; + + return ( + + + + + {isRootNode ? "BOM 헤더 수정" : "품목 수정"} + + + {isRootNode + ? "BOM 기본 정보를 수정합니다" + : "선택한 품목의 BOM 구성 정보를 수정합니다"} + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + handleChange("unit", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {!isRootNode && ( + <> +
+
+ + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + handleChange("loss_rate", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + )} + +
+ +