jskim-node #396

Merged
kjs merged 44 commits from jskim-node into main 2026-02-28 14:37:11 +09:00
10 changed files with 689 additions and 64 deletions
Showing only changes of commit 20167ad359 - Show all commits

View File

@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/**
* WHERE절에
* filters JSON : [{ column, operator, value }]
*/
function applyFilters(
filtersJson: string | undefined,
existingColumns: Set<string>,
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 ) * DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName * GET /api/entity/:tableName/distinct/:columnName
* *
* DISTINCT * DISTINCT
*
* Query Params:
* - labelColumn: 별도의 ()
* - filters: JSON ()
* : [{"column":"status","operator":"=","value":"active"}]
*/ */
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
try { try {
const { tableName, columnName } = req.params; const { tableName, columnName } = req.params;
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 const { labelColumn, filters: filtersParam } = req.query;
// 유효성 검증 // 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") { 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}" IS NOT NULL`);
whereConditions.push(`"${columnName}" != ''`); whereConditions.push(`"${columnName}" != ''`);
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0 const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
columnName, columnName,
labelColumn: effectiveLabelColumn, labelColumn: effectiveLabelColumn,
companyCode, companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount, rowCount: result.rowCount,
}); });
@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
* Query Params: * Query Params:
* - value: (기본: id) * - value: (기본: id)
* - label: 표시 (기본: name) * - label: 표시 (기본: name)
* - fields: 추가 ( )
* - filters: JSON ()
* : [{"column":"status","operator":"=","value":"active"}]
*/ */
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try { try {
const { tableName } = req.params; const { tableName } = req.params;
const { value = "id", label = "name", fields } = req.query; const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
// tableName 유효성 검증 // tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") { if (!tableName || tableName === "undefined" || tableName === "null") {
@ -163,6 +276,16 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
paramIndex++; paramIndex++;
} }
// 필터 조건 적용
paramIndex = applyFilters(
filtersParam as string | undefined,
existingColumns,
whereConditions,
params,
paramIndex,
tableName,
);
const whereClause = whereConditions.length > 0 const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
@ -195,6 +318,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
valueColumn, valueColumn,
labelColumn: effectiveLabelColumn, labelColumn: effectiveLabelColumn,
companyCode, companyCode,
hasFilters: !!filtersParam,
rowCount: result.rowCount, rowCount: result.rowCount,
extraFields: extraColumns ? true : false, extraFields: extraColumns ? true : false,
}); });

View File

@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils"; 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 { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import V2FormContext from "./V2FormContext"; import V2FormContext from "./V2FormContext";
@ -655,6 +655,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const labelColumn = config.labelColumn; const labelColumn = config.labelColumn;
const apiEndpoint = config.apiEndpoint; const apiEndpoint = config.apiEndpoint;
const staticOptions = config.options; const staticOptions = config.options;
const configFilters = config.filters;
// 계층 코드 연쇄 선택 관련 // 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical; const hierarchical = config.hierarchical;
@ -663,6 +664,54 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(V2FormContext); 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<string, string | undefined> = {
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(() => { const parentValue = useMemo(() => {
if (!hierarchical || !parentField) return null; if (!hierarchical || !parentField) return null;
@ -684,6 +733,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
} }
}, [parentValue, hierarchical, source]); }, [parentValue, hierarchical, source]);
// 필터 조건이 변경되면 옵션 다시 로드
useEffect(() => {
if (resolvedFiltersJson !== undefined) {
setOptionsLoaded(false);
}
}, [resolvedFiltersJson]);
useEffect(() => { useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
if (optionsLoaded && source !== "static") { if (optionsLoaded && source !== "static") {
@ -731,11 +787,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
} }
} else if (source === "db" && table) { } else if (source === "db" && table) {
// DB 테이블에서 로드 // DB 테이블에서 로드
const dbParams: Record<string, any> = {
value: valueColumn || "id",
label: labelColumn || "name",
};
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${table}/options`, { const response = await apiClient.get(`/entity/${table}/options`, {
params: { params: dbParams,
value: valueColumn || "id",
label: labelColumn || "name",
},
}); });
const data = response.data; const data = response.data;
if (data.success && data.data) { if (data.success && data.data) {
@ -745,8 +803,10 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// 엔티티(참조 테이블)에서 로드 // 엔티티(참조 테이블)에서 로드
const valueCol = entityValueColumn || "id"; const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name"; const labelCol = entityLabelColumn || "name";
const entityParams: Record<string, any> = { value: valueCol, label: labelCol };
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${entityTable}/options`, { const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: { value: valueCol, label: labelCol }, params: entityParams,
}); });
const data = response.data; const data = response.data;
if (data.success && data.data) { if (data.success && data.data) {
@ -790,11 +850,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
} }
} else if (source === "select" || source === "distinct") { } else if (source === "select" || source === "distinct") {
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
// tableName, columnName은 props에서 가져옴
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
const isValidColumnName = columnName && !columnName.startsWith("comp_"); const isValidColumnName = columnName && !columnName.startsWith("comp_");
if (tableName && isValidColumnName) { if (tableName && isValidColumnName) {
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); const distinctParams: Record<string, any> = {};
if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
params: distinctParams,
});
const data = response.data; const data = response.data;
if (data.success && data.data) { if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
@ -818,7 +880,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
}; };
loadOptions(); 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) 컬럼을 사용하는 다른 컴포넌트 자동 감지 // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => { const autoFillTargets = useMemo(() => {

View File

@ -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 { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; 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 { apiClient } from "@/lib/api/client";
import type { V2SelectFilter } from "@/types/v2-components";
interface ColumnOption { interface ColumnOption {
columnName: string; columnName: string;
@ -25,6 +26,238 @@ interface CategoryValueOption {
valueLabel: string; 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<V2SelectFilter>) => {
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 (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Filter className="h-3.5 w-3.5 text-muted-foreground" />
<Label className="text-xs font-medium"> </Label>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addFilter}
className="h-6 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-muted-foreground text-[10px]">
{targetTable}
</p>
{loadingColumns && (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{filters.length === 0 && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
<div className="space-y-3">
{filters.map((filter, index) => (
<div key={index} className="space-y-1.5 rounded-md border p-2">
{/* 행 1: 컬럼 + 연산자 + 삭제 */}
<div className="flex items-center gap-1.5">
{/* 컬럼 선택 */}
<Select
value={filter.column || ""}
onValueChange={(v) => updateFilter(index, { column: v })}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 연산자 선택 */}
<Select
value={filter.operator || "="}
onValueChange={(v) => updateFilter(index, { operator: v as V2SelectFilter["operator"] })}
>
<SelectTrigger className="h-7 w-[90px] shrink-0 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATOR_OPTIONS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFilter(index)}
className="text-destructive h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */}
{needsValue(filter.operator) && (
<div className="flex items-center gap-1.5">
{/* 값 유형 */}
<Select
value={filter.valueType || "static"}
onValueChange={(v) => updateFilter(index, { valueType: v as V2SelectFilter["valueType"] })}
>
<SelectTrigger className="h-7 w-[100px] shrink-0 text-[11px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_TYPE_OPTIONS.map((vt) => (
<SelectItem key={vt.value} value={vt.value}>
{vt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 값 입력 영역 */}
{(filter.valueType || "static") === "static" && (
<Input
value={String(filter.value ?? "")}
onChange={(e) => 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" && (
<Input
value={filter.fieldRef || ""}
onChange={(e) => updateFilter(index, { fieldRef: e.target.value })}
placeholder="참조할 필드명 (columnName)"
className="h-7 flex-1 text-[11px]"
/>
)}
{filter.valueType === "user" && (
<Select
value={filter.userField || ""}
onValueChange={(v) => updateFilter(index, { userField: v as V2SelectFilter["userField"] })}
>
<SelectTrigger className="h-7 flex-1 text-[11px]">
<SelectValue placeholder="사용자 필드" />
</SelectTrigger>
<SelectContent>
{USER_FIELD_OPTIONS.map((uf) => (
<SelectItem key={uf.value} value={uf.value}>
{uf.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
))}
</div>
</div>
);
};
interface V2SelectConfigPanelProps { interface V2SelectConfigPanelProps {
config: Record<string, any>; config: Record<string, any>;
onChange: (config: Record<string, any>) => void; onChange: (config: Record<string, any>) => void;
@ -53,10 +286,52 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]); const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
// 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼)
const [filterColumns, setFilterColumns] = useState<ColumnOption[]>([]);
const [loadingFilterColumns, setLoadingFilterColumns] = useState(false);
const updateConfig = (field: string, value: any) => { const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value }); 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로 설정 // 카테고리 타입이면 source를 자동으로 category로 설정
useEffect(() => { useEffect(() => {
if (isCategoryType && config.source !== "category") { if (isCategoryType && config.source !== "category") {
@ -518,6 +793,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
/> />
</div> </div>
)} )}
{/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */}
{effectiveSource !== "static" && filterTargetTable && (
<>
<Separator />
<FilterConditionsSection
filters={(config.filters as V2SelectFilter[]) || []}
columns={filterColumns}
loadingColumns={loadingFilterColumns}
targetTable={filterTargetTable}
onFiltersChange={(filters) => updateConfig("filters", filters)}
/>
</>
)}
</div> </div>
); );
}; };

View File

@ -162,6 +162,79 @@ export function RepeaterTable({
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
const initializedRef = useRef(false); const initializedRef = useRef(false);
// 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용)
const editableColIndices = useMemo(
() => visibleColumns.reduce<number[]>((acc, col, idx) => {
if (col.editable && !col.calculated) acc.push(idx);
return acc;
}, []),
[visibleColumns],
);
// 방향키로 리피터 셀 간 이동
const handleArrowNavigation = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
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<HTMLElement>(
'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 센서 설정 // DnD 센서 설정
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@ -648,7 +721,7 @@ export function RepeaterTable({
return ( return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white"> <div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white" onKeyDown={handleArrowNavigation}>
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto"> <div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<table <table
className="border-collapse text-xs" className="border-collapse text-xs"
@ -840,6 +913,8 @@ export function RepeaterTable({
width: `${columnWidths[col.field]}px`, width: `${columnWidths[col.field]}px`,
maxWidth: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px`,
}} }}
data-repeater-row={rowIndex}
data-repeater-col={colIndex}
> >
{renderCell(row, col, rowIndex)} {renderCell(row, col, rowIndex)}
</td> </td>

View File

@ -5777,12 +5777,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
renderCheckboxHeader() renderCheckboxHeader()
) : ( ) : (
<div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}> <div style={{ display: "flex", alignItems: "center", gap: "4px", justifyContent: "center" }}>
{/* 🆕 편집 불가 컬럼 표시 */}
{column.editable === false && (
<span title="편집 불가">
<Lock className="text-muted-foreground h-3 w-3" />
</span>
)}
<span>{columnLabels[column.columnName] || column.displayName}</span> <span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && ( {column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span> <span>{sortDirection === "asc" ? "↑" : "↓"}</span>

View File

@ -1333,7 +1333,38 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
/> />
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" /> <Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.label || column.columnName}</span> <span className="truncate text-xs">{column.label || column.columnName}</span>
<span className="ml-auto text-[10px] text-gray-400"> {isAdded && (
<button
type="button"
title={
config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
? "편집 잠금 (클릭하여 해제)"
: "편집 가능 (클릭하여 잠금)"
}
className={cn(
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
config.columns?.find((c) => c.columnName === column.columnName)?.editable === false
? "text-destructive hover:bg-destructive/10"
: "text-muted-foreground hover:bg-muted",
)}
onClick={(e) => {
e.stopPropagation();
const currentCol = config.columns?.find((c) => c.columnName === column.columnName);
if (currentCol) {
updateColumn(column.columnName, {
editable: currentCol.editable === false ? undefined : false,
});
}
}}
>
{config.columns?.find((c) => c.columnName === column.columnName)?.editable === false ? (
<Lock className="h-3 w-3" />
) : (
<Unlock className="h-3 w-3" />
)}
</button>
)}
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
{column.input_type || column.dataType} {column.input_type || column.dataType}
</span> </span>
</div> </div>

View File

@ -23,8 +23,7 @@ const nextConfig = {
// Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용 // Docker 환경: 컨테이너 이름(pms-backend-mac) 또는 SERVER_API_URL 사용
// 로컬 개발: http://127.0.0.1:8080 사용 // 로컬 개발: http://127.0.0.1:8080 사용
async rewrites() { async rewrites() {
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 const backendUrl = process.env.SERVER_API_URL || "http://127.0.0.1:8080";
const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080";
return [ return [
{ {
source: "/api/:path*", source: "/api/:path*",
@ -49,8 +48,7 @@ const nextConfig = {
// 환경 변수 (런타임에 읽기) // 환경 변수 (런타임에 읽기)
env: { env: {
// Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8080/api",
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api",
}, },
}; };

View File

@ -262,7 +262,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -304,7 +303,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -338,7 +336,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -2669,7 +2666,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.17.8", "@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0", "@types/react-reconciler": "^0.32.0",
@ -3323,7 +3319,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.6" "@tanstack/query-core": "5.90.6"
}, },
@ -3391,7 +3386,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/ueberdosis" "url": "https://github.com/sponsors/ueberdosis"
@ -3705,7 +3699,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-changeset": "^2.3.0", "prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1", "prosemirror-collab": "^1.3.1",
@ -6206,7 +6199,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@ -6217,7 +6209,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -6260,7 +6251,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0", "@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3", "@tweenjs/tween.js": "~23.1.3",
@ -6343,7 +6333,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2", "@typescript-eslint/types": "8.46.2",
@ -6976,7 +6965,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -8127,8 +8115,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/d3": { "node_modules/d3": {
"version": "7.9.0", "version": "7.9.0",
@ -8450,7 +8437,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -9210,7 +9196,6 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -9299,7 +9284,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@ -9401,7 +9385,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@ -10573,7 +10556,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/immer" "url": "https://opencollective.com/immer"
@ -11354,8 +11336,7 @@
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause"
"peer": true
}, },
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
@ -12684,7 +12665,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@ -12978,7 +12958,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"orderedmap": "^2.0.0" "orderedmap": "^2.0.0"
} }
@ -13008,7 +12987,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.0.0", "prosemirror-model": "^1.0.0",
"prosemirror-transform": "^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", "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"prosemirror-model": "^1.20.0", "prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0", "prosemirror-state": "^1.0.0",
@ -13184,7 +13161,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -13254,7 +13230,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@ -13305,7 +13280,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@ -13338,8 +13312,7 @@
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-leaflet": { "node_modules/react-leaflet": {
"version": "5.0.0", "version": "5.0.0",
@ -13647,7 +13620,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@ -13670,8 +13642,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/recharts/node_modules/redux-thunk": { "node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@ -14701,8 +14672,7 @@
"version": "0.180.0", "version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/three-mesh-bvh": { "node_modules/three-mesh-bvh": {
"version": "0.8.3", "version": "0.8.3",
@ -14790,7 +14760,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -15139,7 +15108,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -139,6 +139,23 @@ export interface SelectOption {
label: string; 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 { export interface V2SelectConfig {
mode: V2SelectMode; mode: V2SelectMode;
source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드) source: V2SelectSource | "distinct" | "select"; // distinct/select 추가 (테이블 컬럼에서 자동 로드)
@ -151,7 +168,8 @@ export interface V2SelectConfig {
table?: string; table?: string;
valueColumn?: string; valueColumn?: string;
labelColumn?: string; labelColumn?: string;
filters?: Array<{ column: string; operator: string; value: unknown }>; // 옵션 필터 조건 (모든 source에서 사용 가능)
filters?: V2SelectFilter[];
// 엔티티 연결 (source: entity) // 엔티티 연결 (source: entity)
entityTable?: string; entityTable?: string;
entityValueField?: string; entityValueField?: string;

66
scripts/dev/start-npm.sh Executable file
View File

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