jskim-node #427
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<any>(
|
||||
`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<any>(
|
||||
`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<any>(
|
||||
`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<any>(
|
||||
`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[] = [];
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-25T01:37:37.051Z"
|
||||
"lastSentAt": "2026-03-25T05:06:13.529Z"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
|
||||
|
||||
// 우측 탭
|
||||
|
|
@ -119,6 +122,15 @@ export default function EquipmentInfoPage() {
|
|||
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(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 (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||
onClick={async () => {
|
||||
setExcelDetecting(true);
|
||||
|
|
@ -722,6 +738,14 @@ export default function EquipmentInfoPage() {
|
|||
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
|
||||
)}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={EQUIP_TABLE}
|
||||
settingsId="equipment-info"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<FilterValue[]>([]);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 우측: 사원
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
|
|
@ -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={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
|
|
@ -469,6 +485,14 @@ export default function DepartmentPage() {
|
|||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={DEPT_TABLE}
|
||||
settingsId="department"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(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={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
|
|
@ -504,6 +520,14 @@ export default function SubcontractorItemPage() {
|
|||
onSuccess={() => fetchItems()}
|
||||
/>
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
settingsId="subcontractor-item"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedSubcontractorId, setSelectedSubcontractorId] = useState<string | null>(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={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||
onClick={async () => {
|
||||
setExcelDetecting(true);
|
||||
|
|
@ -1136,6 +1152,14 @@ export default function SubcontractorManagementPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={SUBCONTRACTOR_TABLE}
|
||||
settingsId="subcontractor-mng"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
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={
|
||||
<Button size="sm" onClick={handleSearch}>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
|
|
@ -887,6 +901,9 @@ export default function ProductionPlanManagementPage() {
|
|||
}
|
||||
/>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-emerald-600 dark:text-emerald-400 border-emerald-500/30 hover:bg-emerald-500/10" onClick={() => setExcelUploadOpen(true)}>
|
||||
<Upload className="mr-1.5 h-4 w-4" />
|
||||
엑셀업로드
|
||||
|
|
@ -1731,6 +1748,14 @@ export default function ProductionPlanManagementPage() {
|
|||
|
||||
{/* ConfirmDialog 렌더 */}
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName="production_plan_mng"
|
||||
settingsId="production-plan"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { Label } from "@/components/ui/label";
|
|||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
Users, Package, MapPin, Search, X, Maximize2, Minimize2,
|
||||
Users, Package, MapPin, Search, X, Maximize2, Minimize2, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -40,6 +40,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||
import { validateField, validateForm, formatField } from "@/lib/utils/validation";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const CUSTOMER_TABLE = "customer_mng";
|
||||
const MAPPING_TABLE = "customer_item_mapping";
|
||||
|
|
@ -81,6 +82,8 @@ export default function CustomerManagementPage() {
|
|||
const [customerLoading, setCustomerLoading] = useState(false);
|
||||
const [customerCount, setCustomerCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 탭
|
||||
|
|
@ -169,6 +172,15 @@ export default function CustomerManagementPage() {
|
|||
load();
|
||||
}, []);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("customer-mng");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
|
||||
// 거래처 목록 조회
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
setCustomerLoading(true);
|
||||
|
|
@ -777,8 +789,12 @@ export default function CustomerManagementPage() {
|
|||
filterId="customer-mng"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={customerCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||
onClick={async () => {
|
||||
setExcelDetecting(true);
|
||||
|
|
@ -1271,6 +1287,14 @@ export default function CustomerManagementPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={CUSTOMER_TABLE}
|
||||
settingsId="customer-mng"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck,
|
||||
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -27,6 +27,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
|
|||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const DETAIL_TABLE = "sales_order_detail";
|
||||
|
||||
|
|
@ -98,6 +99,39 @@ export default function SalesOrderPage() {
|
|||
// 체크된 행 (다중선택)
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 테이블 설정
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 테이블 설정 적용 (컬럼 + 필터)
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
// 컬럼 표시/숨김/순서/너비
|
||||
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
|
||||
const applied: DataGridColumn[] = [];
|
||||
for (const cs of settings.columns) {
|
||||
if (!cs.visible) continue;
|
||||
const orig = colMap.get(cs.columnName);
|
||||
if (orig) {
|
||||
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
|
||||
}
|
||||
}
|
||||
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
|
||||
for (const col of GRID_COLUMNS) {
|
||||
if (!settingKeys.has(col.key)) applied.push(col);
|
||||
}
|
||||
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
|
||||
|
||||
// 필터 설정 → DynamicSearchFilter에 전달
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
// 마운트 시 저장된 설정 복원
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("sales-order");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
|
|
@ -537,6 +571,7 @@ export default function SalesOrderPage() {
|
|||
filterId="sales-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
/>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
|
|
@ -568,12 +603,15 @@ export default function SalesOrderPage() {
|
|||
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
|
||||
<Truck className="w-4 h-4 mr-1.5" /> 출하계획 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
gridId="sales-order"
|
||||
columns={GRID_COLUMNS}
|
||||
columns={gridColumns}
|
||||
data={orders}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
|
|
@ -893,6 +931,15 @@ export default function SalesOrderPage() {
|
|||
onSuccess={() => fetchOrders()}
|
||||
/>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={DETAIL_TABLE}
|
||||
settingsId="sales-order"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{/* 공통 확인 다이얼로그 */}
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<FilterValue[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
|
||||
// 테이블 설정
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 우측: 거래처
|
||||
const [customerItems, setCustomerItems] = useState<any[]>([]);
|
||||
const [customerLoading, setCustomerLoading] = useState(false);
|
||||
|
|
@ -106,6 +111,17 @@ export default function SalesItemPage() {
|
|||
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [editCustData, setEditCustData] = useState<any>(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={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
|
|
@ -884,6 +904,14 @@ export default function SalesItemPage() {
|
|||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
settingsId="sales-item"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<FilterColumn[]>([]);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterColumn[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
||||
const [selectOptions, setSelectOptions] = useState<Record<string, { label: string; value: string }[]>>({});
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [selectSearchTerms, setSelectSearchTerms] = useState<Record<string, string>>({});
|
||||
const [tempColumns, setTempColumns] = useState<FilterColumn[]>([]);
|
||||
|
||||
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 (
|
||||
<div style={widthStyle}>
|
||||
<Popover>
|
||||
<Popover onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectSearchTerms((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[filter.columnName];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox"
|
||||
className={cn("h-9 w-full justify-between text-sm font-normal", selectedValues.length === 0 && "text-muted-foreground")}>
|
||||
|
|
@ -316,10 +357,37 @@ export function DynamicSearchFilter({
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
{options.length > 5 && (
|
||||
<div className="border-b p-1">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
|
||||
<Input
|
||||
value={selectSearchTerms[filter.columnName] || ""}
|
||||
onChange={(e) =>
|
||||
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: e.target.value }))
|
||||
}
|
||||
placeholder="검색..."
|
||||
className="h-7 pl-7 pr-7 text-xs"
|
||||
/>
|
||||
{selectSearchTerms[filter.columnName] && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: "" }))
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="text-muted-foreground hover:text-foreground h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-60 overflow-auto p-1">
|
||||
{options.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
) : options.map((opt, i) => (
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">검색 결과 없음</div>
|
||||
) : filteredOptions.map((opt, i) => (
|
||||
<div key={`${opt.value}-${i}`}
|
||||
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"
|
||||
onClick={() => toggleOption(opt.value, !selectedValues.includes(opt.value))}>
|
||||
|
|
@ -376,9 +444,11 @@ export function DynamicSearchFilter({
|
|||
</div>
|
||||
)}
|
||||
{extraActions}
|
||||
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" /> 필터 설정
|
||||
</Button>
|
||||
{!externalFilterConfig && (
|
||||
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" /> 필터 설정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 모달 */}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2 px-2 rounded hover:bg-muted/50",
|
||||
isDragging && "bg-muted/50 shadow-md",
|
||||
)}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 표시 체크박스 */}
|
||||
<Checkbox
|
||||
checked={col.visible}
|
||||
onCheckedChange={() => onToggleVisible(col._idx)}
|
||||
/>
|
||||
|
||||
{/* 표시 토글 (Switch) */}
|
||||
<Switch
|
||||
checked={col.visible}
|
||||
onCheckedChange={() => onToggleVisible(col._idx)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
{/* 컬럼명 + 기술명 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{col.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{col.columnName}</div>
|
||||
</div>
|
||||
|
||||
{/* 너비 입력 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">너비:</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width}
|
||||
onChange={(e) => onWidthChange(col._idx, Number(e.target.value) || 100)}
|
||||
className="h-8 w-[70px] text-xs text-center"
|
||||
min={50}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 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<ColumnSetting[]>([]);
|
||||
const [tempFilters, setTempFilters] = useState<FilterSetting[]>([]);
|
||||
const [tempGroups, setTempGroups] = useState<GroupSetting[]>([]);
|
||||
const [tempFrozenCount, setTempFrozenCount] = useState(0);
|
||||
const [tempGroupSum, setTempGroupSum] = useState(false);
|
||||
|
||||
// 원본 컬럼 (초기화용)
|
||||
const [defaultColumns, setDefaultColumns] = useState<ColumnSetting[]>([]);
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>테이블 설정</DialogTitle>
|
||||
<DialogDescription>테이블의 컬럼, 필터, 그룹화를 설정합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="grid w-full grid-cols-3 shrink-0">
|
||||
<TabsTrigger value="columns" className="flex items-center gap-1.5">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 컬럼 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="filters" className="flex items-center gap-1.5">
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" /> 필터 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" /> 그룹 설정
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ===== 탭 1: 컬럼 설정 ===== */}
|
||||
<TabsContent value="columns" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
|
||||
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span>
|
||||
{visibleCount}/{tempColumns.length}개 컬럼 표시 중
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">틀고정:</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempFrozenCount}
|
||||
onChange={(e) =>
|
||||
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}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">개 컬럼</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetColumns} className="text-xs">
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={tempColumns.map((c) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{tempColumns.map((col, idx) => (
|
||||
<SortableColumnRow
|
||||
key={col.columnName}
|
||||
col={{ ...col, _idx: idx }}
|
||||
onToggleVisible={toggleColumnVisible}
|
||||
onWidthChange={changeColumnWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 2: 필터 설정 ===== */}
|
||||
<TabsContent value="filters" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
{/* 전체 선택 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 pb-3 border-b mb-2 cursor-pointer"
|
||||
onClick={() => toggleFilterAll(!allFiltersEnabled)}
|
||||
>
|
||||
<Checkbox checked={allFiltersEnabled} />
|
||||
<span className="text-sm">전체 선택</span>
|
||||
</div>
|
||||
|
||||
{/* 필터 목록 */}
|
||||
<div className="space-y-1">
|
||||
{tempFilters.map((filter, idx) => (
|
||||
<div
|
||||
key={filter.columnName}
|
||||
className="flex items-center gap-3 py-1.5 px-2 hover:bg-muted/50 rounded"
|
||||
>
|
||||
<Checkbox
|
||||
checked={filter.enabled}
|
||||
onCheckedChange={() => toggleFilter(idx)}
|
||||
/>
|
||||
<div className="flex-1 text-sm min-w-0 truncate">{filter.displayName}</div>
|
||||
<Select
|
||||
value={filter.filterType}
|
||||
onValueChange={(v) => changeFilterType(idx, v as any)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[90px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
value={filter.width}
|
||||
onChange={(e) => changeFilterWidth(idx, Number(e.target.value) || 25)}
|
||||
className="h-8 w-[55px] text-xs text-center"
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 그룹별 합산 토글 */}
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">그룹별 합산</div>
|
||||
<div className="text-xs text-muted-foreground">같은 값끼리 그룹핑하여 합산</div>
|
||||
</div>
|
||||
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 3: 그룹 설정 ===== */}
|
||||
<TabsContent value="groups" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
<div className="px-2 pb-3 border-b mb-2">
|
||||
<span className="text-sm font-medium">사용 가능한 컬럼</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{tempGroups.map((group, idx) => (
|
||||
<div
|
||||
key={group.columnName}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-3 rounded cursor-pointer hover:bg-muted/50",
|
||||
group.enabled && "bg-primary/5",
|
||||
)}
|
||||
onClick={() => toggleGroup(idx)}
|
||||
>
|
||||
<Checkbox checked={group.enabled} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{group.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{group.columnName}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
|
||||
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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-6 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
|
||||
onSelect={() => {
|
||||
onChange(screen.screenId === value ? undefined : screen.screenId);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 수평 Switch Row (토스 패턴) ───
|
||||
function SwitchRow({
|
||||
label,
|
||||
|
|
@ -2002,6 +2076,23 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<V2SplitPanelLayoutConfigPan
|
|||
checked={tab.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateTab(tabIndex, { showAdd: checked })}
|
||||
/>
|
||||
{tab.showAdd && (
|
||||
<div className="border-primary/20 ml-4 space-y-2 border-l-2 pl-3 pb-1">
|
||||
<span className="text-muted-foreground text-[11px]">추가 시 열릴 화면</span>
|
||||
<ScreenSelector
|
||||
value={tab.addButton?.modalScreenId}
|
||||
onChange={(screenId) => {
|
||||
updateTab(tabIndex, {
|
||||
addButton: {
|
||||
enabled: true,
|
||||
mode: screenId ? "modal" : "auto",
|
||||
modalScreenId: screenId,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SwitchRow
|
||||
label="삭제"
|
||||
checked={tab.showDelete ?? false}
|
||||
|
|
|
|||
|
|
@ -1745,7 +1745,48 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
(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<string, any> = { mode: "add", tableName };
|
||||
const parentData: Record<string, any> = {};
|
||||
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -675,8 +675,50 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
|
||||
{/* ===== 7. 추가 버튼 설정 (showAdd일 때) ===== */}
|
||||
{tab.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<Label className="text-xs font-semibold text-purple-700">추가 버튼 설정</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모드</Label>
|
||||
<Select
|
||||
value={tab.addButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") => {
|
||||
updateTab({
|
||||
addButton: { ...tab.addButton, enabled: true, mode: value },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{tab.addButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={tab.addButton?.modalScreenId}
|
||||
onChange={(screenId) => {
|
||||
updateTab({
|
||||
addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 7-1. 추가 모달 컬럼 설정 (showAdd && mode=auto일 때) ===== */}
|
||||
{tab.showAdd && (!tab.addButton?.mode || tab.addButton?.mode === "auto") && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-purple-700">추가 모달 컬럼 설정</Label>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export interface AdditionalTabConfig {
|
|||
}>;
|
||||
};
|
||||
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
};
|
||||
|
||||
addConfig?: {
|
||||
targetTable?: string;
|
||||
autoFillColumns?: Record<string, any>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue