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.
This commit is contained in:
kjs 2026-03-25 15:18:38 +09:00
parent 69c5a78753
commit df6c479589
22 changed files with 1401 additions and 34 deletions

View File

@ -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": []

View File

@ -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
);

View File

@ -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[] = [];

View File

@ -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}

View File

@ -1,3 +1,3 @@
{
"lastSentAt": "2026-03-25T01:37:37.051Z"
"lastSentAt": "2026-03-25T05:06:13.529Z"
}

View File

@ -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
}

View File

@ -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"
}
]
}
]
}

View File

@ -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"
}

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -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>
);

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
{/* 필터 설정 모달 */}

View File

@ -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>
);
}

View File

@ -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}

View File

@ -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;

View File

@ -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>

View File

@ -63,6 +63,12 @@ export interface AdditionalTabConfig {
}>;
};
addButton?: {
enabled: boolean;
mode: "auto" | "modal";
modalScreenId?: number;
};
addConfig?: {
targetTable?: string;
autoFillColumns?: Record<string, any>;