From df6c4795898e2c573ffedf976843871bdef76723 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Mar 2026 15:18:38 +0900 Subject: [PATCH] Update project memory and enhance table settings functionality - Updated project memory configuration to reflect recent access counts and timestamps for various components. - Modified SQL queries in the processInfoController to utilize the correct equipment management table for improved data retrieval. - Enhanced the TableManagementService to automatically fill display columns for entity types during both creation and update processes. - Introduced new TableSettingsModal components across multiple pages for better user control over table configurations. - Improved the DynamicSearchFilter component to accept external filter configurations, enhancing the filtering capabilities for various data grids. --- .omc/project-memory.json | 18 +- .../src/controllers/processInfoController.ts | 6 +- .../src/services/tableManagementService.ts | 73 +++ ...037169c7-72ba-4843-8e9a-417ca1423715.jsonl | 14 + frontend/.omc/state/idle-notif-cooldown.json | 2 +- frontend/.omc/state/last-tool-error.json | 8 +- frontend/.omc/state/mission-state.json | 174 +++++- frontend/.omc/state/subagent-tracking.json | 69 ++- frontend/app/(main)/equipment/info/page.tsx | 26 +- .../(main)/master-data/department/page.tsx | 26 +- .../outsourcing/subcontractor-item/page.tsx | 26 +- .../(main)/outsourcing/subcontractor/page.tsx | 26 +- .../production/plan-management/page.tsx | 25 + frontend/app/(main)/sales/customer/page.tsx | 26 +- frontend/app/(main)/sales/order/page.tsx | 51 +- frontend/app/(main)/sales/sales-item/page.tsx | 30 +- .../components/common/DynamicSearchFilter.tsx | 82 ++- .../components/common/TableSettingsModal.tsx | 569 ++++++++++++++++++ .../V2SplitPanelLayoutConfigPanel.tsx | 91 +++ .../SplitPanelLayoutComponent.tsx | 43 +- .../SplitPanelLayoutConfigPanel.tsx | 44 +- .../components/split-panel-layout/types.ts | 6 + 22 files changed, 1401 insertions(+), 34 deletions(-) create mode 100644 frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl create mode 100644 frontend/components/common/TableSettingsModal.tsx diff --git a/.omc/project-memory.json b/.omc/project-memory.json index b780218b..80e41159 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -354,8 +354,8 @@ "hotPaths": [ { "path": "frontend/app/(main)/sales/order/page.tsx", - "accessCount": 16, - "lastAccessed": 1774313958064, + "accessCount": 19, + "lastAccessed": 1774408850812, "type": "file" }, { @@ -366,14 +366,14 @@ }, { "path": "frontend/components/common/DataGrid.tsx", - "accessCount": 3, - "lastAccessed": 1774313504763, + "accessCount": 4, + "lastAccessed": 1774408732451, "type": "file" }, { "path": "frontend/components/common/DynamicSearchFilter.tsx", - "accessCount": 2, - "lastAccessed": 1774313460662, + "accessCount": 3, + "lastAccessed": 1774408732309, "type": "file" }, { @@ -435,6 +435,12 @@ "accessCount": 1, "lastAccessed": 1774313925751, "type": "file" + }, + { + "path": "frontend/components/common/TableSettingsModal.tsx", + "accessCount": 1, + "lastAccessed": 1774409034693, + "type": "file" } ], "userDirectives": [] diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index a8d99fb1..3b64928b 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo const { processCode } = req.params; const result = await pool.query( - `SELECT pe.*, ei.equipment_name + `SELECT pe.*, em.equipment_name FROM process_equipment pe - LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code + LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code WHERE pe.process_code = $1 AND pe.company_code = $2 ORDER BY pe.equipment_code`, [processCode, companyCode] @@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response) const params = companyCode === "*" ? [] : [companyCode]; const result = await pool.query( - `SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`, + `SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`, params ); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index cbb40203..7f5c5f2e 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2717,6 +2717,43 @@ export class TableManagementService { logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); } + // entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name) + try { + const companyCode = data.company_code || "*"; + const entityColsResult = await query( + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'entity' + AND reference_table IS NOT NULL AND reference_table != '' + AND display_column IS NOT NULL AND display_column != '' + AND company_code IN ($2, '*') + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + [tableName, companyCode] + ); + + for (const ec of entityColsResult) { + const srcVal = data[ec.column_name]; + const displayCol = ec.display_column; + // display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회 + if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) { + try { + const refResult = await query( + `SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`, + [srcVal, companyCode] + ); + if (refResult.length > 0 && refResult[0][displayCol]) { + data[displayCol] = refResult[0][displayCol]; + logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`); + } + } catch (refErr: any) { + logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`); + } + } + } + } catch (entityErr: any) { + logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`); + } + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) const skippedColumns: string[] = []; const existingColumns = Object.keys(data).filter((col) => { @@ -2868,6 +2905,42 @@ export class TableManagementService { logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`); } + // entity 컬럼의 display_column 자동 채우기 (수정 시) + try { + const companyCode = updatedData.company_code || originalData.company_code || "*"; + const entityColsResult = await query( + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'entity' + AND reference_table IS NOT NULL AND reference_table != '' + AND display_column IS NOT NULL AND display_column != '' + AND company_code IN ($2, '*') + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + [tableName, companyCode] + ); + + for (const ec of entityColsResult) { + const srcVal = updatedData[ec.column_name]; + const displayCol = ec.display_column; + if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) { + try { + const refResult = await query( + `SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`, + [srcVal, companyCode] + ); + if (refResult.length > 0 && refResult[0][displayCol]) { + updatedData[displayCol] = refResult[0][displayCol]; + logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`); + } + } catch (refErr: any) { + logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`); + } + } + } + } catch (entityErr: any) { + logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`); + } + // SET 절 생성 (수정할 데이터) - 먼저 생성 // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) const setConditions: string[] = []; diff --git a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl new file mode 100644 index 00000000..eeffca86 --- /dev/null +++ b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl @@ -0,0 +1,14 @@ +{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167} +{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548} +{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997} +{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528} +{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641} +{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980} +{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json index 0a83ceb2..9b6eaa2a 100644 --- a/frontend/.omc/state/idle-notif-cooldown.json +++ b/frontend/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-03-25T01:37:37.051Z" + "lastSentAt": "2026-03-25T05:06:13.529Z" } \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json index 4ee2ec12..cc6d2569 100644 --- a/frontend/.omc/state/last-tool-error.json +++ b/frontend/.omc/state/last-tool-error.json @@ -1,7 +1,7 @@ { - "tool_name": "Read", - "tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}", - "error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.", - "timestamp": "2026-03-25T01:36:58.910Z", + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}", + "error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx", + "timestamp": "2026-03-25T05:00:38.410Z", "retry_count": 1 } \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json index 900ee157..a46a9962 100644 --- a/frontend/.omc/state/mission-state.json +++ b/frontend/.omc/state/mission-state.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-03-25T01:37:19.659Z", + "updatedAt": "2026-03-25T05:06:35.487Z", "missions": [ { "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", @@ -104,6 +104,178 @@ "sourceKey": "session-stop:a9a231d40fd5a150b" } ] + }, + { + "id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-25T04:59:24.101Z", + "updatedAt": "2026-03-25T05:06:35.487Z", + "status": "done", + "workerCount": 7, + "taskCounts": { + "total": 7, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 7, + "failed": 0 + }, + "agents": [ + { + "name": "executor:a32b34c", + "role": "executor", + "ownership": "a32b34c341b854da5", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:18.081Z" + }, + { + "name": "executor:ad2c89c", + "role": "executor", + "ownership": "ad2c89cf14936ea42", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:02:45.524Z" + }, + { + "name": "executor:a2c140c", + "role": "executor", + "ownership": "a2c140c5a5adb0719", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:05:13.388Z" + }, + { + "name": "executor:a2e5213", + "role": "executor", + "ownership": "a2e52136ea8f04385", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:03:53.163Z" + }, + { + "name": "executor:a3735bf", + "role": "executor", + "ownership": "a3735bf51a74d6fc8", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:01:33.817Z" + }, + { + "name": "executor:a77742b", + "role": "executor", + "ownership": "a77742ba65fd2451c", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:09.324Z" + }, + { + "name": "executor:a4eb932", + "role": "executor", + "ownership": "a4eb932c438b898c0", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:35.487Z" + } + ], + "timeline": [ + { + "id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z", + "at": "2026-03-25T04:59:43.650Z", + "kind": "update", + "agent": "executor:a3735bf", + "detail": "started executor:a3735bf", + "sourceKey": "session-start:a3735bf51a74d6fc8" + }, + { + "id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z", + "at": "2026-03-25T04:59:48.683Z", + "kind": "update", + "agent": "executor:a77742b", + "detail": "started executor:a77742b", + "sourceKey": "session-start:a77742ba65fd2451c" + }, + { + "id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z", + "at": "2026-03-25T04:59:53.841Z", + "kind": "update", + "agent": "executor:a4eb932", + "detail": "started executor:a4eb932", + "sourceKey": "session-start:a4eb932c438b898c0" + }, + { + "id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z", + "at": "2026-03-25T05:01:33.817Z", + "kind": "completion", + "agent": "executor:a3735bf", + "detail": "completed", + "sourceKey": "session-stop:a3735bf51a74d6fc8" + }, + { + "id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z", + "at": "2026-03-25T05:02:45.524Z", + "kind": "completion", + "agent": "executor:ad2c89c", + "detail": "completed", + "sourceKey": "session-stop:ad2c89cf14936ea42" + }, + { + "id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z", + "at": "2026-03-25T05:03:53.163Z", + "kind": "completion", + "agent": "executor:a2e5213", + "detail": "completed", + "sourceKey": "session-stop:a2e52136ea8f04385" + }, + { + "id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z", + "at": "2026-03-25T05:05:13.388Z", + "kind": "completion", + "agent": "executor:a2c140c", + "detail": "completed", + "sourceKey": "session-stop:a2c140c5a5adb0719" + }, + { + "id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z", + "at": "2026-03-25T05:06:09.324Z", + "kind": "completion", + "agent": "executor:a77742b", + "detail": "completed", + "sourceKey": "session-stop:a77742ba65fd2451c" + }, + { + "id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z", + "at": "2026-03-25T05:06:18.081Z", + "kind": "completion", + "agent": "executor:a32b34c", + "detail": "completed", + "sourceKey": "session-stop:a32b34c341b854da5" + }, + { + "id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z", + "at": "2026-03-25T05:06:35.487Z", + "kind": "completion", + "agent": "executor:a4eb932", + "detail": "completed", + "sourceKey": "session-stop:a4eb932c438b898c0" + } + ] } ] } \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json index 32d6a63e..355a60d1 100644 --- a/frontend/.omc/state/subagent-tracking.json +++ b/frontend/.omc/state/subagent-tracking.json @@ -44,10 +44,73 @@ "status": "completed", "completed_at": "2026-03-25T01:37:19.659Z", "duration_ms": 139427 + }, + { + "agent_id": "a32b34c341b854da5", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:24.101Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:18.081Z", + "duration_ms": 413980 + }, + { + "agent_id": "ad2c89cf14936ea42", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:28.976Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:02:45.524Z", + "duration_ms": 196548 + }, + { + "agent_id": "a2c140c5a5adb0719", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:33.860Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:05:13.388Z", + "duration_ms": 339528 + }, + { + "agent_id": "a2e52136ea8f04385", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:39.166Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:03:53.163Z", + "duration_ms": 253997 + }, + { + "agent_id": "a3735bf51a74d6fc8", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:43.650Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:01:33.817Z", + "duration_ms": 110167 + }, + { + "agent_id": "a77742ba65fd2451c", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:48.683Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:09.324Z", + "duration_ms": 380641 + }, + { + "agent_id": "a4eb932c438b898c0", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:53.841Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:35.487Z", + "duration_ms": 401646 } ], - "total_spawned": 5, - "total_completed": 5, + "total_spawned": 12, + "total_completed": 12, "total_failed": 0, - "last_updated": "2026-03-25T01:37:19.762Z" + "last_updated": "2026-03-25T05:06:35.589Z" } \ No newline at end of file diff --git a/frontend/app/(main)/equipment/info/page.tsx b/frontend/app/(main)/equipment/info/page.tsx index fb82e1f2..9bb5fede 100644 --- a/frontend/app/(main)/equipment/info/page.tsx +++ b/frontend/app/(main)/equipment/info/page.tsx @@ -20,13 +20,14 @@ import { Checkbox } from "@/components/ui/checkbox"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Wrench, ClipboardCheck, Package, Copy, Info, + Wrench, ClipboardCheck, Package, Copy, Info, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; @@ -78,6 +79,8 @@ export default function EquipmentInfoPage() { const [equipLoading, setEquipLoading] = useState(false); const [equipCount, setEquipCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedEquipId, setSelectedEquipId] = useState(null); // 우측 탭 @@ -119,6 +122,15 @@ export default function EquipmentInfoPage() { const [excelChainConfig, setExcelChainConfig] = useState(null); const [excelDetecting, setExcelDetecting] = useState(false); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("equipment-info"); + if (saved) applyTableSettings(saved); + }, []); + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -395,8 +407,12 @@ export default function EquipmentInfoPage() { return (
+
); diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/master-data/department/page.tsx index 09153b6e..4e943810 100644 --- a/frontend/app/(main)/master-data/department/page.tsx +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -21,13 +21,14 @@ import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Building2, Users, + Building2, Users, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; @@ -64,6 +65,8 @@ export default function DepartmentPage() { const [deptCount, setDeptCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [selectedDeptId, setSelectedDeptId] = useState(null); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); // 우측: 사원 const [members, setMembers] = useState([]); @@ -84,6 +87,15 @@ export default function DepartmentPage() { // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("department"); + if (saved) applyTableSettings(saved); + }, []); + // 부서 조회 const fetchDepts = useCallback(async () => { setDeptLoading(true); @@ -272,8 +284,12 @@ export default function DepartmentPage() { filterId="department" onFilterChange={setSearchFilters} dataCount={deptCount} + externalFilterConfig={filterConfig} extraActions={
+ @@ -469,6 +485,14 @@ export default function DepartmentPage() { /> {ConfirmDialogComponent} + +
); } diff --git a/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx index d66e5e46..08c8f7bb 100644 --- a/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx @@ -20,13 +20,14 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react"; +import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; @@ -68,6 +69,8 @@ export default function SubcontractorItemPage() { const [itemLoading, setItemLoading] = useState(false); const [itemCount, setItemCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedItemId, setSelectedItemId] = useState(null); // 우측: 외주업체 @@ -92,6 +95,15 @@ export default function SubcontractorItemPage() { // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("subcontractor-item"); + if (saved) applyTableSettings(saved); + }, []); + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -296,8 +308,12 @@ export default function SubcontractorItemPage() { filterId="subcontractor-item" onFilterChange={setSearchFilters} dataCount={itemCount} + externalFilterConfig={filterConfig} extraActions={
+ @@ -504,6 +520,14 @@ export default function SubcontractorItemPage() { onSuccess={() => fetchItems()} /> + + {ConfirmDialogComponent}
); diff --git a/frontend/app/(main)/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/outsourcing/subcontractor/page.tsx index f586c838..eadb3a2a 100644 --- a/frontend/app/(main)/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/outsourcing/subcontractor/page.tsx @@ -24,13 +24,14 @@ import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, - Wrench, Package, Search, X, + Wrench, Package, Search, X, Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; @@ -79,6 +80,8 @@ export default function SubcontractorManagementPage() { const [subcontractorLoading, setSubcontractorLoading] = useState(false); const [subcontractorCount, setSubcontractorCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [selectedSubcontractorId, setSelectedSubcontractorId] = useState(null); // 우측: 품목 단가 @@ -158,6 +161,15 @@ export default function SubcontractorManagementPage() { load(); }, []); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("subcontractor-mng"); + if (saved) applyTableSettings(saved); + }, []); + // 외주업체 목록 조회 const fetchSubcontractors = useCallback(async () => { setSubcontractorLoading(true); @@ -728,8 +740,12 @@ export default function SubcontractorManagementPage() { filterId="subcontractor-mng" onFilterChange={setSearchFilters} dataCount={subcontractorCount} + externalFilterConfig={filterConfig} extraActions={
+
); diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index 0d8ee776..2d5dcd45 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -53,6 +53,7 @@ import { Maximize2, Minimize2, Merge, + Settings2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -80,6 +81,7 @@ import TimelineScheduler, { type StatusColor, } from "@/components/common/TimelineScheduler"; import { DynamicSearchFilter, type FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { exportToExcel } from "@/lib/utils/excelExport"; @@ -134,6 +136,8 @@ export default function ProductionPlanManagementPage() { // 검색 필터 (DynamicSearchFilter에서 사용) const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [searchItemCode, setSearchItemCode] = useState(""); const [searchStatus, setSearchStatus] = useState("all"); const [searchStartDate, setSearchStartDate] = useState(""); @@ -277,6 +281,15 @@ export default function ProductionPlanManagementPage() { [] ); + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("production-plan"); + if (saved) applyTableSettings(saved); + }, []); + // ========== 토글/선택 핸들러 ========== const toggleItemExpand = useCallback((itemCode: string) => { @@ -879,6 +892,7 @@ export default function ProductionPlanManagementPage() { filterId="production-plan" onFilterChange={handleSearchFilterChange} dataCount={finishedPlans.length + semiPlans.length} + externalFilterConfig={filterConfig} extraActions={ + fetchOrders()} /> + {/* 테이블 설정 모달 */} + + {/* 공통 확인 다이얼로그 */} {ConfirmDialogComponent} diff --git a/frontend/app/(main)/sales/sales-item/page.tsx b/frontend/app/(main)/sales/sales-item/page.tsx index 373bdf30..a5097b42 100644 --- a/frontend/app/(main)/sales/sales-item/page.tsx +++ b/frontend/app/(main)/sales/sales-item/page.tsx @@ -20,12 +20,13 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X } from "lucide-react"; +import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; @@ -71,6 +72,10 @@ export default function SalesItemPage() { const [searchFilters, setSearchFilters] = useState([]); const [selectedItemId, setSelectedItemId] = useState(null); + // 테이블 설정 + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); + // 우측: 거래처 const [customerItems, setCustomerItems] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); @@ -106,6 +111,17 @@ export default function SalesItemPage() { const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); const [editCustData, setEditCustData] = useState(null); + // 테이블 설정 적용 (필터) + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + // 마운트 시 저장된 설정 복원 + useEffect(() => { + const saved = loadTableSettings("sales-item"); + if (saved) applyTableSettings(saved); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -522,8 +538,12 @@ export default function SalesItemPage() { filterId="sales-item" onFilterChange={setSearchFilters} dataCount={itemCount} + externalFilterConfig={filterConfig} extraActions={
+ @@ -884,6 +904,14 @@ export default function SalesItemPage() { /> {ConfirmDialogComponent} + +
); } diff --git a/frontend/components/common/DynamicSearchFilter.tsx b/frontend/components/common/DynamicSearchFilter.tsx index ac44160d..e61c7c5e 100644 --- a/frontend/components/common/DynamicSearchFilter.tsx +++ b/frontend/components/common/DynamicSearchFilter.tsx @@ -23,7 +23,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; -import { Settings, ChevronsUpDown, RotateCcw } from "lucide-react"; +import { Settings, ChevronsUpDown, RotateCcw, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; @@ -50,6 +50,14 @@ export interface FilterValue { value: string; } +export interface ExternalFilterConfig { + columnName: string; + displayName: string; + enabled: boolean; + filterType: FilterType; + width: number; +} + export interface DynamicSearchFilterProps { /** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */ tableName: string; @@ -61,6 +69,8 @@ export interface DynamicSearchFilterProps { dataCount?: number; /** 추가 액션 버튼 영역 */ extraActions?: React.ReactNode; + /** TableSettingsModal에서 전달된 외부 필터 설정 (제공 시 자체 설정 모달 숨김) */ + externalFilterConfig?: ExternalFilterConfig[]; } const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [ @@ -86,12 +96,14 @@ export function DynamicSearchFilter({ onFilterChange, dataCount, extraActions, + externalFilterConfig, }: DynamicSearchFilterProps) { const [allColumns, setAllColumns] = useState([]); const [activeFilters, setActiveFilters] = useState([]); const [filterValues, setFilterValues] = useState>({}); const [selectOptions, setSelectOptions] = useState>({}); const [settingsOpen, setSettingsOpen] = useState(false); + const [selectSearchTerms, setSelectSearchTerms] = useState>({}); const [tempColumns, setTempColumns] = useState([]); const STORAGE_KEY_FILTERS = `dynamic_filter_config_${filterId}`; @@ -149,6 +161,22 @@ export function DynamicSearchFilter({ loadColumns(); }, [tableName, STORAGE_KEY_FILTERS, STORAGE_KEY_VALUES]); + // 외부 필터 설정 적용 (TableSettingsModal에서 전달) + useEffect(() => { + if (!externalFilterConfig) return; + const active: FilterColumn[] = externalFilterConfig + .filter((f) => f.enabled) + .map((f) => ({ + columnName: f.columnName, + columnLabel: f.displayName, + originalType: f.filterType, + filterType: f.filterType, + enabled: true, + width: f.width, + })); + setActiveFilters(active); + }, [externalFilterConfig]); + // select 타입 필터의 옵션 로드 useEffect(() => { const loadOptions = async () => { @@ -305,9 +333,22 @@ export function DynamicSearchFilter({ handleValueChange(filter.columnName, next.length > 0 ? next : ""); }; + const searchTerm = (selectSearchTerms[filter.columnName] || "").toLowerCase(); + const filteredOptions = searchTerm + ? options.filter((opt) => opt.label.toLowerCase().includes(searchTerm)) + : options; + return (
- + { + if (!open) { + setSelectSearchTerms((prev) => { + const next = { ...prev }; + delete next[filter.columnName]; + return next; + }); + } + }}> + {options.length > 5 && ( +
+
+ + + setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: e.target.value })) + } + placeholder="검색..." + className="h-7 pl-7 pr-7 text-xs" + /> + {selectSearchTerms[filter.columnName] && ( + + )} +
+
+ )}
{options.length === 0 ? (
옵션 없음
- ) : options.map((opt, i) => ( + ) : filteredOptions.length === 0 ? ( +
검색 결과 없음
+ ) : filteredOptions.map((opt, i) => (
toggleOption(opt.value, !selectedValues.includes(opt.value))}> @@ -376,9 +444,11 @@ export function DynamicSearchFilter({
)} {extraActions} - + {!externalFilterConfig && ( + + )}
{/* 필터 설정 모달 */} diff --git a/frontend/components/common/TableSettingsModal.tsx b/frontend/components/common/TableSettingsModal.tsx new file mode 100644 index 00000000..9a007b52 --- /dev/null +++ b/frontend/components/common/TableSettingsModal.tsx @@ -0,0 +1,569 @@ +"use client"; + +/** + * TableSettingsModal -- 하드코딩 페이지용 테이블 설정 모달 (3탭) + * + * 탭 1: 컬럼 설정 -- 컬럼 표시/숨김, 드래그 순서 변경, 너비(px) 설정, 틀고정 + * 탭 2: 필터 설정 -- 필터 활성/비활성, 필터 타입(텍스트/선택/날짜), 너비(%) 설정, 그룹별 합산 + * 탭 3: 그룹 설정 -- 그룹핑 컬럼 선택 + * + * 설정값은 localStorage에 저장되며, onSave 콜백으로 부모 컴포넌트에 전달 + * DynamicSearchFilter, DataGrid와 함께 사용 + */ + +import React, { useState, useEffect } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { GripVertical, Settings2, SlidersHorizontal, Layers, RotateCcw, Lock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { + DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, verticalListSortingStrategy, useSortable, arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// ===== 타입 ===== + +export interface ColumnSetting { + columnName: string; + displayName: string; + visible: boolean; + width: number; +} + +export interface FilterSetting { + columnName: string; + displayName: string; + enabled: boolean; + filterType: "text" | "select" | "date"; + width: number; +} + +export interface GroupSetting { + columnName: string; + displayName: string; + enabled: boolean; +} + +export interface TableSettings { + columns: ColumnSetting[]; + filters: FilterSetting[]; + groups: GroupSetting[]; + frozenCount: number; + groupSumEnabled: boolean; +} + +export interface TableSettingsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** 테이블명 (web-types API 호출용) */ + tableName: string; + /** localStorage 키 분리용 고유 ID */ + settingsId: string; + /** 저장 시 콜백 */ + onSave?: (settings: TableSettings) => void; + /** 초기 탭 */ + initialTab?: "columns" | "filters" | "groups"; +} + +// ===== 상수 ===== + +const FILTER_TYPE_OPTIONS = [ + { value: "text", label: "텍스트" }, + { value: "select", label: "선택" }, + { value: "date", label: "날짜" }, +]; + +const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"]; + +// ===== 유틸 ===== + +function getStorageKey(settingsId: string) { + return `table_settings_${settingsId}`; +} + +/** localStorage에서 저장된 설정 로드 (외부에서도 사용 가능) */ +export function loadTableSettings(settingsId: string): TableSettings | null { + try { + const raw = localStorage.getItem(getStorageKey(settingsId)); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */ +function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] { + const savedMap = new Map(saved.map((s) => [s.columnName, s])); + const ordered: ColumnSetting[] = []; + // 저장된 순서대로 + for (const s of saved) { + const f = fresh.find((c) => c.columnName === s.columnName); + if (f) ordered.push({ ...f, visible: s.visible, width: s.width }); + } + // 새로 추가된 컬럼은 맨 뒤에 + for (const f of fresh) { + if (!savedMap.has(f.columnName)) ordered.push(f); + } + return ordered; +} + +// ===== Sortable Column Row (탭 1) ===== + +function SortableColumnRow({ + col, + onToggleVisible, + onWidthChange, +}: { + col: ColumnSetting & { _idx: number }; + onToggleVisible: (idx: number) => void; + onWidthChange: (idx: number, width: number) => void; +}) { + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ id: col.columnName }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {/* 드래그 핸들 */} + + + {/* 표시 체크박스 */} + onToggleVisible(col._idx)} + /> + + {/* 표시 토글 (Switch) */} + onToggleVisible(col._idx)} + className="shrink-0" + /> + + {/* 컬럼명 + 기술명 */} +
+
{col.displayName}
+
{col.columnName}
+
+ + {/* 너비 입력 */} +
+ 너비: + onWidthChange(col._idx, Number(e.target.value) || 100)} + className="h-8 w-[70px] text-xs text-center" + min={50} + max={500} + /> +
+
+ ); +} + +// ===== TableSettingsModal ===== + +export function TableSettingsModal({ + open, + onOpenChange, + tableName, + settingsId, + onSave, + initialTab = "columns", +}: TableSettingsModalProps) { + const [activeTab, setActiveTab] = useState(initialTab); + const [loading, setLoading] = useState(false); + + // 임시 설정 (모달 내에서만 수정, 저장 시 반영) + const [tempColumns, setTempColumns] = useState([]); + const [tempFilters, setTempFilters] = useState([]); + const [tempGroups, setTempGroups] = useState([]); + const [tempFrozenCount, setTempFrozenCount] = useState(0); + const [tempGroupSum, setTempGroupSum] = useState(false); + + // 원본 컬럼 (초기화용) + const [defaultColumns, setDefaultColumns] = useState([]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + + // 모달 열릴 때 데이터 로드 + useEffect(() => { + if (!open) return; + setActiveTab(initialTab); + loadData(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + const loadData = async () => { + setLoading(true); + try { + const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`); + const types: any[] = res.data?.data || []; + + // 기본 컬럼 설정 생성 + const freshColumns: ColumnSetting[] = types + .filter((t) => !AUTO_COLS.includes(t.columnName)) + .map((t) => ({ + columnName: t.columnName, + displayName: t.displayName || t.columnLabel || t.columnName, + visible: true, + width: 120, + })); + + // 기본 필터 설정 생성 + const freshFilters: FilterSetting[] = freshColumns.map((c) => { + const wt = types.find((t) => t.columnName === c.columnName); + let filterType: "text" | "select" | "date" = "text"; + if (wt?.inputType === "category" || wt?.inputType === "select") filterType = "select"; + else if (wt?.inputType === "date" || wt?.inputType === "datetime") filterType = "date"; + return { + columnName: c.columnName, + displayName: c.displayName, + enabled: false, + filterType, + width: 25, + }; + }); + + // 기본 그룹 설정 생성 + const freshGroups: GroupSetting[] = freshColumns.map((c) => ({ + columnName: c.columnName, + displayName: c.displayName, + enabled: false, + })); + + setDefaultColumns(freshColumns); + + // localStorage에서 저장된 설정 복원 + const saved = loadTableSettings(settingsId); + if (saved) { + setTempColumns(mergeColumns(freshColumns, saved.columns)); + setTempFilters(freshFilters.map((f) => { + const s = saved.filters?.find((sf) => sf.columnName === f.columnName); + return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f; + })); + setTempGroups(freshGroups.map((g) => { + const s = saved.groups?.find((sg) => sg.columnName === g.columnName); + return s ? { ...g, enabled: s.enabled } : g; + })); + setTempFrozenCount(saved.frozenCount || 0); + setTempGroupSum(saved.groupSumEnabled || false); + } else { + setTempColumns(freshColumns); + setTempFilters(freshFilters); + setTempGroups(freshGroups); + setTempFrozenCount(0); + setTempGroupSum(false); + } + } catch (err) { + console.error("테이블 설정 로드 실패:", err); + } finally { + setLoading(false); + } + }; + + // 저장 + const handleSave = () => { + const settings: TableSettings = { + columns: tempColumns, + filters: tempFilters, + groups: tempGroups, + frozenCount: tempFrozenCount, + groupSumEnabled: tempGroupSum, + }; + localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings)); + onSave?.(settings); + onOpenChange(false); + }; + + // 컬럼 설정 초기화 + const handleResetColumns = () => { + setTempColumns(defaultColumns.map((c) => ({ ...c }))); + setTempFrozenCount(0); + }; + + // ===== 컬럼 설정 핸들러 ===== + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + setTempColumns((prev) => { + const oldIdx = prev.findIndex((c) => c.columnName === active.id); + const newIdx = prev.findIndex((c) => c.columnName === over.id); + return arrayMove(prev, oldIdx, newIdx); + }); + }; + + const toggleColumnVisible = (idx: number) => { + setTempColumns((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], visible: !next[idx].visible }; + return next; + }); + }; + + const changeColumnWidth = (idx: number, width: number) => { + setTempColumns((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], width }; + return next; + }); + }; + + // ===== 필터 설정 핸들러 ===== + + const allFiltersEnabled = tempFilters.length > 0 && tempFilters.every((f) => f.enabled); + + const toggleFilterAll = (checked: boolean) => { + setTempFilters((prev) => prev.map((f) => ({ ...f, enabled: checked }))); + }; + + const toggleFilter = (idx: number) => { + setTempFilters((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], enabled: !next[idx].enabled }; + return next; + }); + }; + + const changeFilterType = (idx: number, filterType: "text" | "select" | "date") => { + setTempFilters((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], filterType }; + return next; + }); + }; + + const changeFilterWidth = (idx: number, width: number) => { + setTempFilters((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], width }; + return next; + }); + }; + + // ===== 그룹 설정 핸들러 ===== + + const toggleGroup = (idx: number) => { + setTempGroups((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], enabled: !next[idx].enabled }; + return next; + }); + }; + + const visibleCount = tempColumns.filter((c) => c.visible).length; + + return ( + + + + 테이블 설정 + 테이블의 컬럼, 필터, 그룹화를 설정합니다 + + + {loading ? ( +
+ 로딩 중... +
+ ) : ( + setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0"> + + + 컬럼 설정 + + + 필터 설정 + + + 그룹 설정 + + + + {/* ===== 탭 1: 컬럼 설정 ===== */} + + {/* 헤더: 표시 수 / 틀고정 / 초기화 */} +
+
+ + {visibleCount}/{tempColumns.length}개 컬럼 표시 중 + +
+ + 틀고정: + + setTempFrozenCount( + Math.min(Math.max(0, Number(e.target.value) || 0), tempColumns.length) + ) + } + className="h-7 w-[50px] text-xs text-center" + min={0} + max={tempColumns.length} + /> + 개 컬럼 +
+
+ +
+ + {/* 컬럼 목록 (드래그 순서 변경 가능) */} + + c.columnName)} + strategy={verticalListSortingStrategy} + > +
+ {tempColumns.map((col, idx) => ( + + ))} +
+
+
+
+ + {/* ===== 탭 2: 필터 설정 ===== */} + + {/* 전체 선택 */} +
toggleFilterAll(!allFiltersEnabled)} + > + + 전체 선택 +
+ + {/* 필터 목록 */} +
+ {tempFilters.map((filter, idx) => ( +
+ toggleFilter(idx)} + /> +
{filter.displayName}
+ +
+ changeFilterWidth(idx, Number(e.target.value) || 25)} + className="h-8 w-[55px] text-xs text-center" + min={10} + max={100} + /> + % +
+
+ ))} +
+ + {/* 그룹별 합산 토글 */} +
+
+
그룹별 합산
+
같은 값끼리 그룹핑하여 합산
+
+ +
+
+ + {/* ===== 탭 3: 그룹 설정 ===== */} + +
+ 사용 가능한 컬럼 +
+ +
+ {tempGroups.map((group, idx) => ( +
toggleGroup(idx)} + > + +
+
{group.displayName}
+
{group.columnName}
+
+
+ ))} +
+
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index 325656d1..88b0d7d3 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -171,6 +171,80 @@ function SectionHeader({ ); } +// ─── 화면 선택 Combobox ─── +const ScreenSelector: React.FC<{ + value?: number; + onChange: (screenId?: number) => void; +}> = ({ value, onChange }) => { + const [open, setOpen] = useState(false); + const [screens, setScreens] = useState>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadScreens = async () => { + setLoading(true); + try { + const { screenApi } = await import("@/lib/api/screen"); + const response = await screenApi.getScreens({ page: 1, size: 1000 }); + setScreens( + response.data.map((s: any) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })), + ); + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setLoading(false); + } + }; + loadScreens(); + }, []); + + const selectedScreen = screens.find((s) => s.screenId === value); + + return ( + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + onChange(screen.screenId === value ? undefined : screen.screenId); + setOpen(false); + }} + className="text-xs" + > + +
+ {screen.screenName} + {screen.screenCode} +
+
+ ))} +
+
+
+
+
+ ); +}; + // ─── 수평 Switch Row (토스 패턴) ─── function SwitchRow({ label, @@ -2002,6 +2076,23 @@ export const V2SplitPanelLayoutConfigPanel: React.FC updateTab(tabIndex, { showAdd: checked })} /> + {tab.showAdd && ( +
+ 추가 시 열릴 화면 + { + updateTab(tabIndex, { + addButton: { + enabled: true, + mode: screenId ? "modal" : "auto", + modalScreenId: screenId, + }, + }); + }} + /> +
+ )} (panel: "left" | "right") => { console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex }); - // screenId 기반 모달 확인 + // 추가 탭의 addButton.modalScreenId 확인 + if (panel === "right" && activeTabIndex > 0) { + const tabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]; + if (tabConfig?.addButton?.mode === "modal" && tabConfig.addButton.modalScreenId) { + if (!selectedLeftItem) { + toast({ + title: "항목을 선택해주세요", + description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.", + variant: "destructive", + }); + return; + } + + const tableName = tabConfig.tableName || ""; + const urlParams: Record = { mode: "add", tableName }; + const parentData: Record = {}; + + if (selectedLeftItem) { + const relation = tabConfig.relation; + if (relation?.keys && Array.isArray(relation.keys)) { + for (const key of relation.keys) { + if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) { + parentData[key.rightColumn] = selectedLeftItem[key.leftColumn]; + } + } + } + } + + window.dispatchEvent( + new CustomEvent("openScreenModal", { + detail: { + screenId: tabConfig.addButton.modalScreenId, + urlParams, + splitPanelParentData: parentData, + }, + }), + ); + return; + } + } + + // screenId 기반 모달 확인 (기본 패널) const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel; const addModalConfig = panelConfig?.addModal; diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 51441e88..41716d83 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -675,8 +675,50 @@ const AdditionalTabConfigPanel: React.FC = ({ )}
- {/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */} + {/* ===== 7. 추가 버튼 설정 (showAdd일 때) ===== */} {tab.showAdd && ( +
+ +
+
+ + +
+ + {tab.addButton?.mode === "modal" && ( +
+ + { + updateTab({ + addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId }, + }); + }} + /> +
+ )} +
+
+ )} + + {/* ===== 7-1. 추가 모달 컬럼 설정 (showAdd && mode=auto일 때) ===== */} + {tab.showAdd && (!tab.addButton?.mode || tab.addButton?.mode === "auto") && (
diff --git a/frontend/lib/registry/components/split-panel-layout/types.ts b/frontend/lib/registry/components/split-panel-layout/types.ts index 123dc13a..b8add7dd 100644 --- a/frontend/lib/registry/components/split-panel-layout/types.ts +++ b/frontend/lib/registry/components/split-panel-layout/types.ts @@ -63,6 +63,12 @@ export interface AdditionalTabConfig { }>; }; + addButton?: { + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + }; + addConfig?: { targetTable?: string; autoFillColumns?: Record; -- 2.43.0