From 20167ad35943b59dc028991e3264209b05730220 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Feb 2026 16:07:15 +0900 Subject: [PATCH] feat: Implement advanced filtering capabilities in entity search - Added a new helper function `applyFilters` to handle dynamic filter conditions for entity search queries. - Enhanced the `getDistinctColumnValues` and `getEntityOptions` endpoints to support JSON array filters, allowing for more flexible data retrieval based on specified conditions. - Updated the frontend components to integrate filter conditions, improving user interaction and data management in selection components. - Introduced new filter options in the V2Select component, enabling users to define and apply various filter criteria dynamically. --- .../src/controllers/entitySearchController.ts | 128 +++++++- frontend/components/v2/V2Select.tsx | 82 ++++- .../v2/config-panels/V2SelectConfigPanel.tsx | 293 +++++++++++++++++- .../modal-repeater-table/RepeaterTable.tsx | 77 ++++- .../v2-table-list/TableListComponent.tsx | 6 - .../v2-table-list/TableListConfigPanel.tsx | 33 +- frontend/next.config.mjs | 6 +- frontend/package-lock.json | 42 +-- frontend/types/v2-components.ts | 20 +- scripts/dev/start-npm.sh | 66 ++++ 10 files changed, 689 insertions(+), 64 deletions(-) create mode 100755 scripts/dev/start-npm.sh diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 3ece2ce7..62fc8bbe 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 필터 조건을 WHERE절에 적용하는 공통 헬퍼 + * filters JSON 배열: [{ column, operator, value }] + */ +function applyFilters( + filtersJson: string | undefined, + existingColumns: Set, + whereConditions: string[], + params: any[], + startParamIndex: number, + tableName: string, +): number { + let paramIndex = startParamIndex; + + if (!filtersJson) return paramIndex; + + let filters: Array<{ column: string; operator: string; value: unknown }>; + try { + filters = JSON.parse(filtersJson as string); + } catch { + logger.warn("filters JSON 파싱 실패", { tableName, filtersJson }); + return paramIndex; + } + + if (!Array.isArray(filters)) return paramIndex; + + for (const filter of filters) { + const { column, operator = "=", value } = filter; + if (!column || !existingColumns.has(column)) { + logger.warn("필터 컬럼 미존재 제외", { tableName, column }); + continue; + } + + switch (operator) { + case "=": + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${column}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + case "<": + case ">=": + case "<=": + whereConditions.push(`"${column}" ${operator} $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": { + const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inVals.length > 0) { + const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" IN (${ph})`); + params.push(...inVals); + paramIndex += inVals.length; + } + break; + } + case "notIn": { + const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInVals.length > 0) { + const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" NOT IN (${ph})`); + params.push(...notInVals); + paramIndex += notInVals.length; + } + break; + } + case "like": + whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + case "isNull": + whereConditions.push(`"${column}" IS NULL`); + break; + case "isNotNull": + whereConditions.push(`"${column}" IS NOT NULL`); + break; + default: + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + } + } + + return paramIndex; +} + /** * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) * GET /api/entity/:tableName/distinct/:columnName * * 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환 + * + * Query Params: + * - labelColumn: 별도의 라벨 컬럼 (선택) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { try { const { tableName, columnName } = req.params; - const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 + const { labelColumn, filters: filtersParam } = req.query; // 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re whereConditions.push(`"${columnName}" IS NOT NULL`); whereConditions.push(`"${columnName}" != ''`); + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re columnName, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, }); @@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re * Query Params: * - value: 값 컬럼 (기본: id) * - label: 표시 컬럼 (기본: name) + * - fields: 추가 반환 컬럼 (콤마 구분) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name", fields } = req.query; + const { value = "id", label = "name", fields, filters: filtersParam } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) paramIndex++; } + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) valueColumn, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, extraFields: extraColumns ? true : false, }); diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index fe21b790..f0021eeb 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { V2SelectProps, SelectOption } from "@/types/v2-components"; +import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components"; import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import V2FormContext from "./V2FormContext"; @@ -655,6 +655,7 @@ export const V2Select = forwardRef( const labelColumn = config.labelColumn; const apiEndpoint = config.apiEndpoint; const staticOptions = config.options; + const configFilters = config.filters; // 계층 코드 연쇄 선택 관련 const hierarchical = config.hierarchical; @@ -662,6 +663,54 @@ export const V2Select = forwardRef( // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) const formContext = useContext(V2FormContext); + + /** + * 필터 조건을 API 전달용 JSON으로 변환 + * field/user 타입은 런타임 값으로 치환 + */ + const resolvedFiltersJson = useMemo(() => { + if (!configFilters || configFilters.length === 0) return undefined; + + const resolved: Array<{ column: string; operator: string; value: unknown }> = []; + + for (const f of configFilters) { + const vt = f.valueType || "static"; + + // isNull/isNotNull은 값 불필요 + if (f.operator === "isNull" || f.operator === "isNotNull") { + resolved.push({ column: f.column, operator: f.operator, value: null }); + continue; + } + + let resolvedValue: unknown = f.value; + + if (vt === "field" && f.fieldRef) { + // 다른 폼 필드 참조 + if (formContext) { + resolvedValue = formContext.getValue(f.fieldRef); + } else { + const fd = (props as any).formData; + resolvedValue = fd?.[f.fieldRef]; + } + // 참조 필드 값이 비어있으면 이 필터 건너뜀 + if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue; + } else if (vt === "user" && f.userField) { + // 로그인 사용자 정보 참조 (props에서 가져옴) + const userMap: Record = { + companyCode: (props as any).companyCode, + userId: (props as any).userId, + deptCode: (props as any).deptCode, + userName: (props as any).userName, + }; + resolvedValue = userMap[f.userField]; + if (!resolvedValue) continue; + } + + resolved.push({ column: f.column, operator: f.operator, value: resolvedValue }); + } + + return resolved.length > 0 ? JSON.stringify(resolved) : undefined; + }, [configFilters, formContext, props]); // 부모 필드의 값 계산 const parentValue = useMemo(() => { @@ -684,6 +733,13 @@ export const V2Select = forwardRef( } }, [parentValue, hierarchical, source]); + // 필터 조건이 변경되면 옵션 다시 로드 + useEffect(() => { + if (resolvedFiltersJson !== undefined) { + setOptionsLoaded(false); + } + }, [resolvedFiltersJson]); + useEffect(() => { // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { @@ -731,11 +787,13 @@ export const V2Select = forwardRef( } } else if (source === "db" && table) { // DB 테이블에서 로드 + const dbParams: Record = { + value: valueColumn || "id", + label: labelColumn || "name", + }; + if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${table}/options`, { - params: { - value: valueColumn || "id", - label: labelColumn || "name", - }, + params: dbParams, }); const data = response.data; if (data.success && data.data) { @@ -745,8 +803,10 @@ export const V2Select = forwardRef( // 엔티티(참조 테이블)에서 로드 const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; + const entityParams: Record = { value: valueCol, label: labelCol }; + if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { value: valueCol, label: labelCol }, + params: entityParams, }); const data = response.data; if (data.success && data.data) { @@ -790,11 +850,13 @@ export const V2Select = forwardRef( } } else if (source === "select" || source === "distinct") { // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 - // tableName, columnName은 props에서 가져옴 - // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀 const isValidColumnName = columnName && !columnName.startsWith("comp_"); if (tableName && isValidColumnName) { - const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const distinctParams: Record = {}; + if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson; + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, { + params: distinctParams, + }); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ @@ -818,7 +880,7 @@ export const V2Select = forwardRef( }; loadOptions(); - }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]); // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 const autoFillTargets = useMemo(() => { diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index d631f454..66ebb369 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -5,15 +5,16 @@ * 통합 선택 컴포넌트의 세부 설정을 관리합니다. */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } 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 { Separator } from "@/components/ui/separator"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@/components/ui/button"; -import { Plus, Trash2, Loader2 } from "lucide-react"; +import { Plus, Trash2, Loader2, Filter } from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import type { V2SelectFilter } from "@/types/v2-components"; interface ColumnOption { columnName: string; @@ -25,6 +26,238 @@ interface CategoryValueOption { valueLabel: string; } +const OPERATOR_OPTIONS = [ + { value: "=", label: "같음 (=)" }, + { value: "!=", label: "다름 (!=)" }, + { value: ">", label: "초과 (>)" }, + { value: "<", label: "미만 (<)" }, + { value: ">=", label: "이상 (>=)" }, + { value: "<=", label: "이하 (<=)" }, + { value: "in", label: "포함 (IN)" }, + { value: "notIn", label: "미포함 (NOT IN)" }, + { value: "like", label: "유사 (LIKE)" }, + { value: "isNull", label: "NULL" }, + { value: "isNotNull", label: "NOT NULL" }, +] as const; + +const VALUE_TYPE_OPTIONS = [ + { value: "static", label: "고정값" }, + { value: "field", label: "폼 필드 참조" }, + { value: "user", label: "로그인 사용자" }, +] as const; + +const USER_FIELD_OPTIONS = [ + { value: "companyCode", label: "회사코드" }, + { value: "userId", label: "사용자ID" }, + { value: "deptCode", label: "부서코드" }, + { value: "userName", label: "사용자명" }, +] as const; + +/** + * 필터 조건 설정 서브 컴포넌트 + */ +const FilterConditionsSection: React.FC<{ + filters: V2SelectFilter[]; + columns: ColumnOption[]; + loadingColumns: boolean; + targetTable: string; + onFiltersChange: (filters: V2SelectFilter[]) => void; +}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => { + + const addFilter = () => { + onFiltersChange([ + ...filters, + { column: "", operator: "=", valueType: "static", value: "" }, + ]); + }; + + const updateFilter = (index: number, patch: Partial) => { + const updated = [...filters]; + updated[index] = { ...updated[index], ...patch }; + + // valueType 변경 시 관련 필드 초기화 + if (patch.valueType) { + if (patch.valueType === "static") { + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "field") { + updated[index].value = undefined; + updated[index].userField = undefined; + } else if (patch.valueType === "user") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + } + } + + // isNull/isNotNull 연산자는 값 불필요 + if (patch.operator === "isNull" || patch.operator === "isNotNull") { + updated[index].value = undefined; + updated[index].fieldRef = undefined; + updated[index].userField = undefined; + updated[index].valueType = "static"; + } + + onFiltersChange(updated); + }; + + const removeFilter = (index: number) => { + onFiltersChange(filters.filter((_, i) => i !== index)); + }; + + const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; + + return ( +
+
+
+ + +
+ +
+ +

+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +

+ + {loadingColumns && ( +
+ + 컬럼 목록 로딩 중... +
+ )} + + {filters.length === 0 && ( +

+ 필터 조건이 없습니다 +

+ )} + +
+ {filters.map((filter, index) => ( +
+ {/* 행 1: 컬럼 + 연산자 + 삭제 */} +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */} + {needsValue(filter.operator) && ( +
+ {/* 값 유형 */} + + + {/* 값 입력 영역 */} + {(filter.valueType || "static") === "static" && ( + updateFilter(index, { value: e.target.value })} + placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "field" && ( + updateFilter(index, { fieldRef: e.target.value })} + placeholder="참조할 필드명 (columnName)" + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; @@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC = ({ const [categoryValues, setCategoryValues] = useState([]); const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼) + const [filterColumns, setFilterColumns] = useState([]); + const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); + const updateConfig = (field: string, value: any) => { onChange({ ...config, [field]: value }); }; + // 필터 대상 테이블 결정 + const filterTargetTable = useMemo(() => { + const src = config.source || "static"; + if (src === "entity") return config.entityTable; + if (src === "db") return config.table; + if (src === "distinct" || src === "select") return tableName; + return null; + }, [config.source, config.entityTable, config.table, tableName]); + + // 필터 대상 테이블의 컬럼 로드 + useEffect(() => { + if (!filterTargetTable) { + setFilterColumns([]); + return; + } + + const loadFilterColumns = async () => { + setLoadingFilterColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + setFilterColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + })) + ); + } catch { + setFilterColumns([]); + } finally { + setLoadingFilterColumns(false); + } + }; + + loadFilterColumns(); + }, [filterTargetTable]); + // 카테고리 타입이면 source를 자동으로 category로 설정 useEffect(() => { if (isCategoryType && config.source !== "category") { @@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC = ({ /> )} + + {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */} + {effectiveSource !== "static" && filterTargetTable && ( + <> + + updateConfig("filters", filters)} + /> + + )} ); }; diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 5ad6d0eb..d57ae60b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -162,6 +162,79 @@ export function RepeaterTable({ // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); + // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용) + const editableColIndices = useMemo( + () => visibleColumns.reduce((acc, col, idx) => { + if (col.editable && !col.calculated) acc.push(idx); + return acc; + }, []), + [visibleColumns], + ); + + // 방향키로 리피터 셀 간 이동 + const handleArrowNavigation = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return; + + const target = e.target as HTMLElement; + const cell = target.closest("[data-repeater-row]") as HTMLElement | null; + if (!cell) return; + + const row = Number(cell.dataset.repeaterRow); + const col = Number(cell.dataset.repeaterCol); + if (isNaN(row) || isNaN(col)) return; + + // 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시 + if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") { + const input = target as HTMLInputElement; + const len = input.value?.length ?? 0; + const pos = input.selectionStart ?? 0; + // 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동 + if (key === "ArrowRight" && pos < len) return; + if (key === "ArrowLeft" && pos > 0) return; + } + + let nextRow = row; + let nextColPos = editableColIndices.indexOf(col); + + switch (key) { + case "ArrowUp": + nextRow = Math.max(0, row - 1); + break; + case "ArrowDown": + nextRow = Math.min(data.length - 1, row + 1); + break; + case "ArrowLeft": + nextColPos = Math.max(0, nextColPos - 1); + break; + case "ArrowRight": + nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1); + break; + } + + const nextCol = editableColIndices[nextColPos]; + if (nextRow === row && nextCol === col) return; + + e.preventDefault(); + + const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`; + const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null; + if (!nextCell) return; + + const focusable = nextCell.querySelector( + 'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])', + ); + if (focusable) { + focusable.focus(); + if (focusable.tagName === "INPUT") { + (focusable as HTMLInputElement).select(); + } + } + }, + [editableColIndices, data.length], + ); + // DnD 센서 설정 const sensors = useSensors( useSensor(PointerSensor, { @@ -648,7 +721,7 @@ export function RepeaterTable({ return ( -
+
{renderCell(row, col, rowIndex)} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 1eaef469..30584fc4 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -5777,12 +5777,6 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
- {/* 🆕 편집 불가 컬럼 표시 */} - {column.editable === false && ( - - - - )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( {sortDirection === "asc" ? "↑" : "↓"} diff --git a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx index becd3c34..35f15596 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx @@ -1333,7 +1333,38 @@ export const TableListConfigPanel: React.FC = ({ /> {column.label || column.columnName} - + {isAdded && ( + + )} + {column.input_type || column.dataType}
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f1907e..b7b134ec 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -23,8 +23,7 @@ const nextConfig = { // Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용 // 로컬 개발: http://127.0.0.1:8080 사용 async rewrites() { - // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080"; + const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080"; return [ { source: "/api/:path*", @@ -49,8 +48,7 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { - // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080/api", }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8b262a1..01edd32d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -262,7 +262,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -304,7 +303,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -338,7 +336,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2669,7 +2666,6 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3323,7 +3319,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3391,7 +3386,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3705,7 +3699,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6206,7 +6199,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6217,7 +6209,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6260,7 +6251,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6343,7 +6333,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6976,7 +6965,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8127,8 +8115,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -8450,7 +8437,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9210,7 +9196,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9299,7 +9284,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9401,7 +9385,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10573,7 +10556,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11354,8 +11336,7 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/levn": { "version": "0.4.1", @@ -12684,7 +12665,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12978,7 +12958,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13008,7 +12987,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -13057,7 +13035,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13184,7 +13161,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13254,7 +13230,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13305,7 +13280,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13338,8 +13312,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -13647,7 +13620,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -13670,8 +13642,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -14701,8 +14672,7 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -14790,7 +14760,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15139,7 +15108,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/types/v2-components.ts b/frontend/types/v2-components.ts index 88ac1691..6ce5a974 100644 --- a/frontend/types/v2-components.ts +++ b/frontend/types/v2-components.ts @@ -139,6 +139,23 @@ export interface SelectOption { label: string; } +/** + * V2Select 필터 조건 + * 옵션 데이터를 조회할 때 적용할 WHERE 조건 + */ +export interface V2SelectFilter { + column: string; + operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like" | "isNull" | "isNotNull"; + /** 값 유형: static=고정값, field=다른 폼 필드 참조, user=로그인 사용자 정보 */ + valueType?: "static" | "field" | "user"; + /** static일 때 고정값 */ + value?: unknown; + /** field일 때 참조할 폼 필드명 (columnName) */ + fieldRef?: string; + /** user일 때 참조할 사용자 필드 */ + userField?: "companyCode" | "userId" | "deptCode" | "userName"; +} + export interface V2SelectConfig { mode: V2SelectMode; source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드) @@ -151,7 +168,8 @@ export interface V2SelectConfig { table?: string; valueColumn?: string; labelColumn?: string; - filters?: Array<{ column: string; operator: string; value: unknown }>; + // 옵션 필터 조건 (모든 source에서 사용 가능) + filters?: V2SelectFilter[]; // 엔티티 연결 (source: entity) entityTable?: string; entityValueField?: string; diff --git a/scripts/dev/start-npm.sh b/scripts/dev/start-npm.sh new file mode 100755 index 00000000..7b7fc54a --- /dev/null +++ b/scripts/dev/start-npm.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +echo "============================================" +echo "WACE 솔루션 - npm 직접 실행 (Docker 없이)" +echo "============================================" +echo "" + +PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +LOG_DIR="$PROJECT_ROOT/scripts/dev/logs" +mkdir -p "$LOG_DIR" + +BACKEND_LOG="$LOG_DIR/backend.log" +FRONTEND_LOG="$LOG_DIR/frontend.log" + +# 기존 프로세스 정리 +echo "[1/4] 기존 프로세스 정리 중..." +lsof -ti:8080 | xargs kill -9 2>/dev/null +lsof -ti:9771 | xargs kill -9 2>/dev/null +echo " 완료" +echo "" + +# 백엔드 npm install + 실행 +echo "[2/4] 백엔드 의존성 설치 중..." +cd "$PROJECT_ROOT/backend-node" +npm install --silent +echo " 완료" +echo "" + +echo "[3/4] 백엔드 서버 시작 중 (포트 8080)..." +npm run dev > "$BACKEND_LOG" 2>&1 & +BACKEND_PID=$! +echo " PID: $BACKEND_PID" +echo "" + +# 프론트엔드 npm install + 실행 +echo "[4/4] 프론트엔드 의존성 설치 + 서버 시작 중 (포트 9771)..." +cd "$PROJECT_ROOT/frontend" +npm install --silent +npm run dev > "$FRONTEND_LOG" 2>&1 & +FRONTEND_PID=$! +echo " PID: $FRONTEND_PID" +echo "" + +sleep 3 + +echo "============================================" +echo "모든 서비스가 시작되었습니다!" +echo "============================================" +echo "" +echo " [BACKEND] http://localhost:8080/api" +echo " [FRONTEND] http://localhost:9771" +echo "" +echo " 백엔드 PID: $BACKEND_PID" +echo " 프론트엔드 PID: $FRONTEND_PID" +echo "" +echo " 프론트엔드 로그: tail -f $FRONTEND_LOG" +echo "" +echo "Ctrl+C 로 종료하면 백엔드/프론트엔드 모두 종료됩니다." +echo "============================================" +echo "" +echo "--- 백엔드 로그 출력 시작 ---" +echo "" + +trap "echo ''; echo '서비스를 종료합니다...'; kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" SIGINT SIGTERM + +tail -f "$BACKEND_LOG"