From e1508e9087dfc9d24ab7eb493b7fe312b21b33be Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 01:06:01 +0900 Subject: [PATCH] [agent-pipeline] pipe-20260311155325-udmh round-2 --- .../V2LocationSwapSelectorConfigPanel.tsx | 564 +++++++++++++++++ .../V2TimelineSchedulerConfigPanel.tsx | 572 ++++++++++++++++++ .../v2-location-swap-selector/index.ts | 2 +- .../components/v2-timeline-scheduler/index.ts | 2 +- 4 files changed, 1138 insertions(+), 2 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2LocationSwapSelectorConfigPanel.tsx create mode 100644 frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx diff --git a/frontend/components/v2/config-panels/V2LocationSwapSelectorConfigPanel.tsx b/frontend/components/v2/config-panels/V2LocationSwapSelectorConfigPanel.tsx new file mode 100644 index 00000000..42f9fe70 --- /dev/null +++ b/frontend/components/v2/config-panels/V2LocationSwapSelectorConfigPanel.tsx @@ -0,0 +1,564 @@ +"use client"; + +/** + * V2 출발지/도착지 선택 설정 패널 + * 토스식 단계별 UX: 데이터 소스 -> 필드 매핑 -> UI 설정 -> DB 초기값(접힘) + */ + +import React, { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Settings, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +interface V2LocationSwapSelectorConfigPanelProps { + config: any; + onChange: (config: any) => void; + tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; + screenTableName?: string; +} + +export const V2LocationSwapSelectorConfigPanel: React.FC = ({ + config, + onChange, + tableColumns = [], + screenTableName, +}) => { + const [tables, setTables] = useState>([]); + const [columns, setColumns] = useState>([]); + const [codeCategories, setCodeCategories] = useState>([]); + const [dbSettingsOpen, setDbSettingsOpen] = useState(false); + + useEffect(() => { + const loadTables = async () => { + try { + const response = await apiClient.get("/table-management/tables"); + if (response.data.success && response.data.data) { + setTables( + response.data.data.map((t: any) => ({ + name: t.tableName || t.table_name, + label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } + }; + loadTables(); + }, []); + + useEffect(() => { + const loadColumns = async () => { + const tableName = config?.dataSource?.tableName; + if (!tableName) { + setColumns([]); + return; + } + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) { + columnData = columnData.columns; + } + if (Array.isArray(columnData)) { + setColumns( + columnData.map((c: any) => ({ + name: c.columnName || c.column_name || c.name, + label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name, + })) + ); + } + } + } catch (error) { + console.error("컬럼 목록 로드 실패:", error); + } + }; + if (config?.dataSource?.type === "table") { + loadColumns(); + } + }, [config?.dataSource?.tableName, config?.dataSource?.type]); + + useEffect(() => { + const loadCodeCategories = async () => { + try { + const response = await apiClient.get("/code-management/categories"); + if (response.data.success && response.data.data) { + setCodeCategories( + response.data.data.map((c: any) => ({ + value: c.category_code || c.categoryCode || c.code, + label: c.category_name || c.categoryName || c.name, + })) + ); + } + } catch (error: any) { + if (error?.response?.status !== 404) { + console.error("코드 카테고리 로드 실패:", error); + } + } + }; + loadCodeCategories(); + }, []); + + const handleChange = (path: string, value: any) => { + const keys = path.split("."); + const newConfig = { ...config }; + let current: any = newConfig; + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } + current[keys[keys.length - 1]] = value; + onChange(newConfig); + }; + + const dataSourceType = config?.dataSource?.type || "static"; + + return ( +
+ {/* ─── 1단계: 데이터 소스 ─── */} +
+

데이터 소스

+

장소 목록을 어디서 가져올지 선택해요

+
+ +
+ {/* 소스 타입 카드 선택 */} +
+ {[ + { value: "static", label: "고정 옵션" }, + { value: "table", label: "테이블" }, + { value: "code", label: "코드 관리" }, + ].map(({ value, label }) => ( + + ))} +
+ + {/* 고정 옵션 설정 */} + {dataSourceType === "static" && ( +
+
+

고정된 2개 장소만 사용할 때 설정해요 (예: 포항 / 광양)

+
+
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[0] = { ...newOptions[0], value: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="포항" + className="h-7 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[0] = { ...newOptions[0], label: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="포항" + className="h-7 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[1] = { ...newOptions[1], value: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="광양" + className="h-7 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[1] = { ...newOptions[1], label: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="광양" + className="h-7 text-xs" + /> +
+
+
+ )} + + {/* 테이블 설정 */} + {dataSourceType === "table" && ( +
+
+ 테이블 + +
+
+ 값 필드 + +
+
+ 표시 필드 + +
+
+ )} + + {/* 코드 카테고리 설정 */} + {dataSourceType === "code" && ( +
+
+ 코드 카테고리 + +
+
+ )} +
+ + {/* ─── 2단계: 필드 매핑 ─── */} +
+

필드 매핑

+

+ 출발지/도착지 값이 저장될 컬럼을 지정해요 + {screenTableName && ( + (화면 테이블: {screenTableName}) + )} +

+
+ +
+
+ 출발지 저장 컬럼 + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("departureField", e.target.value)} + className="h-7 w-[140px] text-xs" + /> + )} +
+ +
+ 도착지 저장 컬럼 + {tableColumns.length > 0 ? ( + + ) : ( + handleChange("destinationField", e.target.value)} + className="h-7 w-[140px] text-xs" + /> + )} +
+ +
+
+ 출발지명 컬럼 +

라벨 저장용 (선택)

+
+ {tableColumns.length > 0 ? ( + + ) : ( + handleChange("departureLabelField", e.target.value)} + placeholder="departure_name" + className="h-7 w-[140px] text-xs" + /> + )} +
+ +
+
+ 도착지명 컬럼 +

라벨 저장용 (선택)

+
+ {tableColumns.length > 0 ? ( + + ) : ( + handleChange("destinationLabelField", e.target.value)} + placeholder="destination_name" + className="h-7 w-[140px] text-xs" + /> + )} +
+
+ + {/* ─── 3단계: UI 설정 ─── */} +
+

UI 설정

+

라벨과 스타일을 설정해요

+
+ +
+
+ 출발지 라벨 + handleChange("departureLabel", e.target.value)} + className="h-7 w-[120px] text-xs" + /> +
+ +
+ 도착지 라벨 + handleChange("destinationLabel", e.target.value)} + className="h-7 w-[120px] text-xs" + /> +
+ +
+ 스타일 + +
+ +
+
+

교환 버튼

+

출발지와 도착지를 바꿀 수 있어요

+
+ handleChange("showSwapButton", checked)} + /> +
+
+ + {/* ─── 4단계: DB 초기값 로드 (Collapsible) ─── */} + + + + + +
+
+
+

DB에서 초기값 로드

+

새로고침 후에도 DB에 저장된 값을 자동으로 불러와요

+
+ handleChange("loadFromDb", checked)} + /> +
+ + {config?.loadFromDb !== false && ( +
+
+ 조회 테이블 + +
+ +
+
+ 키 필드 +

현재 사용자 ID로 조회할 필드

+
+ handleChange("dbKeyField", e.target.value)} + placeholder="user_id" + className="h-7 w-[120px] text-xs" + /> +
+
+ )} +
+
+
+
+ ); +}; + +V2LocationSwapSelectorConfigPanel.displayName = "V2LocationSwapSelectorConfigPanel"; + +export default V2LocationSwapSelectorConfigPanel; diff --git a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx new file mode 100644 index 00000000..b88677e2 --- /dev/null +++ b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx @@ -0,0 +1,572 @@ +"use client"; + +/** + * V2 타임라인 스케줄러 설정 패널 + * 토스식 단계별 UX: 스케줄 생성 설정 -> 리소스 설정 -> 표시 설정(접힘) + */ + +import React, { useState, useEffect } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { tableTypeApi } from "@/lib/api/screen"; +import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping } from "@/lib/registry/components/v2-timeline-scheduler/types"; +import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; + +interface V2TimelineSchedulerConfigPanelProps { + config: TimelineSchedulerConfig; + onChange: (config: Partial) => void; +} + +interface TableInfo { + tableName: string; + displayName: string; +} + +interface ColumnInfo { + columnName: string; + displayName: string; +} + +export const V2TimelineSchedulerConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const [tables, setTables] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); + const [resourceColumns, setResourceColumns] = useState([]); + const [loading, setLoading] = useState(false); + const [sourceTableOpen, setSourceTableOpen] = useState(false); + const [resourceTableOpen, setResourceTableOpen] = useState(false); + const [displayOpen, setDisplayOpen] = useState(false); + + useEffect(() => { + const loadTables = async () => { + setLoading(true); + try { + const tableList = await tableTypeApi.getTables(); + if (Array.isArray(tableList)) { + setTables( + tableList.map((t: any) => ({ + tableName: t.table_name || t.tableName, + displayName: t.display_name || t.displayName || t.table_name || t.tableName, + })) + ); + } + } catch (err) { + console.error("테이블 목록 로드 오류:", err); + } finally { + setLoading(false); + } + }; + loadTables(); + }, []); + + useEffect(() => { + const loadSourceColumns = async () => { + if (!config.sourceConfig?.tableName) { + setSourceColumns([]); + return; + } + try { + const columns = await tableTypeApi.getColumns(config.sourceConfig.tableName); + if (Array.isArray(columns)) { + setSourceColumns( + columns.map((col: any) => ({ + columnName: col.column_name || col.columnName, + displayName: col.display_name || col.displayName || col.column_name || col.columnName, + })) + ); + } + } catch (err) { + console.error("소스 컬럼 로드 오류:", err); + setSourceColumns([]); + } + }; + loadSourceColumns(); + }, [config.sourceConfig?.tableName]); + + useEffect(() => { + const loadResourceColumns = async () => { + if (!config.resourceTable) { + setResourceColumns([]); + return; + } + try { + const columns = await tableTypeApi.getColumns(config.resourceTable); + if (Array.isArray(columns)) { + setResourceColumns( + columns.map((col: any) => ({ + columnName: col.column_name || col.columnName, + displayName: col.display_name || col.displayName || col.column_name || col.columnName, + })) + ); + } + } catch (err) { + console.error("리소스 컬럼 로드 오류:", err); + setResourceColumns([]); + } + }; + loadResourceColumns(); + }, [config.resourceTable]); + + const updateConfig = (updates: Partial) => { + onChange({ ...config, ...updates }); + }; + + const updateSourceConfig = (updates: Partial) => { + updateConfig({ + sourceConfig: { + ...config.sourceConfig, + ...updates, + }, + }); + }; + + const updateResourceFieldMapping = (field: string, value: string) => { + updateConfig({ + resourceFieldMapping: { + ...config.resourceFieldMapping, + id: config.resourceFieldMapping?.id || "id", + name: config.resourceFieldMapping?.name || "name", + [field]: value, + }, + }); + }; + + return ( +
+ {/* ─── 1단계: 스케줄 생성 설정 ─── */} +
+
+ +

스케줄 생성 설정

+
+

스케줄 자동 생성 시 참조할 원본 데이터를 설정해요 (저장: schedule_mng)

+
+ +
+ {/* 스케줄 타입 */} +
+ 스케줄 타입 + +
+ + {/* 소스 테이블 Combobox */} +
+ 소스 테이블 (수주/작업요청 등) + + + + + + { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }} + > + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + updateSourceConfig({ tableName: table.tableName }); + setSourceTableOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + {table.tableName} +
+
+ ))} +
+
+
+
+
+
+ + {/* 소스 필드 매핑 (테이블 선택 시) */} + {config.sourceConfig?.tableName && ( +
+

필드 매핑

+ +
+
+ 기준일 (마감일/납기일) * +

스케줄 종료일로 사용돼요

+
+ +
+ +
+ 수량 필드 + +
+ +
+ 그룹 필드 (품목코드) + +
+ +
+ 그룹명 필드 (품목명) + +
+
+ )} +
+ + {/* ─── 2단계: 리소스 설정 ─── */} +
+
+ +

리소스 설정 (설비/작업자)

+
+

타임라인 Y축에 표시할 리소스를 설정해요

+
+ +
+ {/* 리소스 테이블 Combobox */} +
+ 리소스 테이블 + + + + + + { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }} + > + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + updateConfig({ resourceTable: table.tableName }); + setResourceTableOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + {table.tableName} +
+
+ ))} +
+
+
+
+
+
+ + {/* 리소스 필드 매핑 */} + {config.resourceTable && ( +
+

리소스 필드

+ +
+ ID 필드 + +
+ +
+ 이름 필드 + +
+
+ )} +
+ + {/* ─── 3단계: 표시 설정 (Collapsible) ─── */} + + + + + +
+ {/* 줌 레벨 */} +
+ 기본 줌 레벨 + +
+ + {/* 높이 */} +
+ 높이 (px) + updateConfig({ height: parseInt(e.target.value) || 500 })} + className="h-7 w-[100px] text-xs" + /> +
+ + {/* 행 높이 */} +
+ 행 높이 (px) + updateConfig({ rowHeight: parseInt(e.target.value) || 50 })} + className="h-7 w-[100px] text-xs" + /> +
+ + {/* Switch 토글들 */} +
+
+

편집 가능

+

스케줄을 직접 수정할 수 있어요

+
+ updateConfig({ editable: v })} + /> +
+ +
+
+

드래그 이동

+

스케줄을 드래그해서 날짜를 변경해요

+
+ updateConfig({ draggable: v })} + /> +
+ +
+
+

리사이즈

+

스케줄의 기간을 늘이거나 줄여요

+
+ updateConfig({ resizable: v })} + /> +
+ +
+
+

오늘 표시선

+

오늘 날짜에 빨간 세로선을 표시해요

+
+ updateConfig({ showTodayLine: v })} + /> +
+ +
+
+

진행률 표시

+

각 스케줄의 진행률 바를 보여줘요

+
+ updateConfig({ showProgress: v })} + /> +
+ +
+
+

툴바 표시

+

상단의 네비게이션/줌 컨트롤을 보여줘요

+
+ updateConfig({ showToolbar: v })} + /> +
+
+
+
+
+ ); +}; + +V2TimelineSchedulerConfigPanel.displayName = "V2TimelineSchedulerConfigPanel"; + +export default V2TimelineSchedulerConfigPanel; diff --git a/frontend/lib/registry/components/v2-location-swap-selector/index.ts b/frontend/lib/registry/components/v2-location-swap-selector/index.ts index 33455afb..acb566c9 100644 --- a/frontend/lib/registry/components/v2-location-swap-selector/index.ts +++ b/frontend/lib/registry/components/v2-location-swap-selector/index.ts @@ -3,7 +3,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent"; -import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel"; +import { V2LocationSwapSelectorConfigPanel as LocationSwapSelectorConfigPanel } from "@/components/v2/config-panels/V2LocationSwapSelectorConfigPanel"; /** * LocationSwapSelector 컴포넌트 정의 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/index.ts index 33c483a0..4c50b00a 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/index.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/index.ts @@ -3,7 +3,7 @@ import { ComponentCategory } from "@/types/component"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent"; -import { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel"; +import { V2TimelineSchedulerConfigPanel as TimelineSchedulerConfigPanel } from "@/components/v2/config-panels/V2TimelineSchedulerConfigPanel"; import { defaultTimelineSchedulerConfig } from "./config"; import { TimelineSchedulerConfig } from "./types";