jskim-node #396
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue