Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
011f0556d2
|
|
@ -2141,3 +2141,4 @@ export async function multiTableSave(
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1646,7 +1646,18 @@ export class NodeFlowExecutionService {
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
const whereClauses: string[] = [];
|
const whereClauses: string[] = [];
|
||||||
whereConditions?.forEach((condition: any) => {
|
whereConditions?.forEach((condition: any) => {
|
||||||
const condValue = data[condition.field];
|
// 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져옴
|
||||||
|
let condValue: any;
|
||||||
|
if (condition.sourceField) {
|
||||||
|
condValue = data[condition.sourceField];
|
||||||
|
} else if (
|
||||||
|
condition.staticValue !== undefined &&
|
||||||
|
condition.staticValue !== ""
|
||||||
|
) {
|
||||||
|
condValue = condition.staticValue;
|
||||||
|
} else {
|
||||||
|
condValue = data[condition.field];
|
||||||
|
}
|
||||||
|
|
||||||
if (condition.operator === "IS NULL") {
|
if (condition.operator === "IS NULL") {
|
||||||
whereClauses.push(`${condition.field} IS NULL`);
|
whereClauses.push(`${condition.field} IS NULL`);
|
||||||
|
|
@ -1987,7 +1998,18 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
// WHERE 조건 생성
|
// WHERE 조건 생성
|
||||||
whereConditions?.forEach((condition: any) => {
|
whereConditions?.forEach((condition: any) => {
|
||||||
const condValue = data[condition.field];
|
// 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져옴
|
||||||
|
let condValue: any;
|
||||||
|
if (condition.sourceField) {
|
||||||
|
condValue = data[condition.sourceField];
|
||||||
|
} else if (
|
||||||
|
condition.staticValue !== undefined &&
|
||||||
|
condition.staticValue !== ""
|
||||||
|
) {
|
||||||
|
condValue = condition.staticValue;
|
||||||
|
} else {
|
||||||
|
condValue = data[condition.field];
|
||||||
|
}
|
||||||
|
|
||||||
if (condition.operator === "IS NULL") {
|
if (condition.operator === "IS NULL") {
|
||||||
whereClauses.push(`${condition.field} IS NULL`);
|
whereClauses.push(`${condition.field} IS NULL`);
|
||||||
|
|
@ -2889,7 +2911,26 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
const clauses = conditions.map((condition, index) => {
|
const clauses = conditions.map((condition, index) => {
|
||||||
const value = data ? data[condition.field] : condition.value;
|
// 🔥 수정: sourceField가 있으면 소스 데이터에서 값을 가져오고,
|
||||||
|
// 없으면 staticValue 또는 기존 field 사용
|
||||||
|
let value: any;
|
||||||
|
if (data) {
|
||||||
|
if (condition.sourceField) {
|
||||||
|
// sourceField가 있으면 소스 데이터에서 해당 필드의 값을 가져옴
|
||||||
|
value = data[condition.sourceField];
|
||||||
|
} else if (
|
||||||
|
condition.staticValue !== undefined &&
|
||||||
|
condition.staticValue !== ""
|
||||||
|
) {
|
||||||
|
// staticValue가 있으면 사용
|
||||||
|
value = condition.staticValue;
|
||||||
|
} else {
|
||||||
|
// 둘 다 없으면 기존 방식 (field로 값 조회)
|
||||||
|
value = data[condition.field];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
value = condition.value;
|
||||||
|
}
|
||||||
values.push(value);
|
values.push(value);
|
||||||
|
|
||||||
// 연산자를 SQL 문법으로 변환
|
// 연산자를 SQL 문법으로 변환
|
||||||
|
|
|
||||||
|
|
@ -607,7 +607,9 @@ class NumberingRuleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
if (result.rowCount === 0) return null;
|
if (result.rowCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const rule = result.rows[0];
|
const rule = result.rows[0];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2360,29 +2360,32 @@ export class ScreenManagementService {
|
||||||
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
|
||||||
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
|
||||||
|
|
||||||
// 현재 최대 번호 조회
|
// 현재 최대 번호 조회 (숫자 추출 후 정렬)
|
||||||
const existingScreens = await client.query<{ screen_code: string }>(
|
// 패턴: COMPANY_CODE_XXX 또는 COMPANY_CODEXXX
|
||||||
`SELECT screen_code FROM screen_definitions
|
const existingScreens = await client.query<{ screen_code: string; num: number }>(
|
||||||
WHERE company_code = $1 AND screen_code LIKE $2
|
`SELECT screen_code,
|
||||||
ORDER BY screen_code DESC
|
COALESCE(
|
||||||
LIMIT 10`,
|
NULLIF(
|
||||||
[companyCode, `${companyCode}%`]
|
regexp_replace(screen_code, $2, '\\1'),
|
||||||
|
screen_code
|
||||||
|
)::integer,
|
||||||
|
0
|
||||||
|
) as num
|
||||||
|
FROM screen_definitions
|
||||||
|
WHERE company_code = $1
|
||||||
|
AND screen_code ~ $2
|
||||||
|
AND deleted_date IS NULL
|
||||||
|
ORDER BY num DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[companyCode, `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[_]?(\\d+)$`]
|
||||||
);
|
);
|
||||||
|
|
||||||
let maxNumber = 0;
|
let maxNumber = 0;
|
||||||
const pattern = new RegExp(
|
if (existingScreens.rows.length > 0 && existingScreens.rows[0].num) {
|
||||||
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
|
maxNumber = existingScreens.rows[0].num;
|
||||||
);
|
}
|
||||||
|
|
||||||
for (const screen of existingScreens.rows) {
|
console.log(`🔢 현재 최대 화면 코드 번호: ${companyCode} → ${maxNumber}`);
|
||||||
const match = screen.screen_code.match(pattern);
|
|
||||||
if (match) {
|
|
||||||
const number = parseInt(match[1], 10);
|
|
||||||
if (number > maxNumber) {
|
|
||||||
maxNumber = number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// count개의 코드를 순차적으로 생성
|
// count개의 코드를 순차적으로 생성
|
||||||
const codes: string[] = [];
|
const codes: string[] = [];
|
||||||
|
|
|
||||||
|
|
@ -166,18 +166,28 @@ export default function CopyScreenModal({
|
||||||
|
|
||||||
// linkedScreens 로딩이 완료되면 화면 코드 생성
|
// linkedScreens 로딩이 완료되면 화면 코드 생성
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 모달 화면들의 코드가 모두 설정되었는지 확인
|
||||||
|
const allModalCodesSet = linkedScreens.length === 0 ||
|
||||||
|
linkedScreens.every(screen => screen.newScreenCode);
|
||||||
|
|
||||||
console.log("🔍 코드 생성 조건 체크:", {
|
console.log("🔍 코드 생성 조건 체크:", {
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
loadingLinkedScreens,
|
loadingLinkedScreens,
|
||||||
screenCode,
|
screenCode,
|
||||||
linkedScreensCount: linkedScreens.length,
|
linkedScreensCount: linkedScreens.length,
|
||||||
|
allModalCodesSet,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (targetCompanyCode && !loadingLinkedScreens && !screenCode) {
|
// 조건: 회사 코드가 있고, 로딩이 완료되고, (메인 코드가 없거나 모달 코드가 없을 때)
|
||||||
|
const needsCodeGeneration = targetCompanyCode &&
|
||||||
|
!loadingLinkedScreens &&
|
||||||
|
(!screenCode || (linkedScreens.length > 0 && !allModalCodesSet));
|
||||||
|
|
||||||
|
if (needsCodeGeneration) {
|
||||||
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
|
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
|
||||||
generateScreenCodes();
|
generateScreenCodes();
|
||||||
}
|
}
|
||||||
}, [targetCompanyCode, loadingLinkedScreens, screenCode]);
|
}, [targetCompanyCode, loadingLinkedScreens, screenCode, linkedScreens]);
|
||||||
|
|
||||||
// 회사 목록 조회
|
// 회사 목록 조회
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
|
|
|
||||||
|
|
@ -317,6 +317,11 @@ apiClient.interceptors.response.use(
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 채번 규칙 미리보기 API 실패는 조용하게 처리 (화면 로드 시 자주 발생)
|
||||||
|
if (url?.includes("/numbering-rules/") && url?.includes("/preview")) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||||
console.error("API 응답 오류:", {
|
console.error("API 응답 오류:", {
|
||||||
status: status,
|
status: status,
|
||||||
|
|
@ -324,7 +329,6 @@ apiClient.interceptors.response.use(
|
||||||
url: url,
|
url: url,
|
||||||
data: error.response?.data,
|
data: error.response?.data,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
headers: error.config?.headers,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 401 에러 처리
|
// 401 에러 처리
|
||||||
|
|
|
||||||
|
|
@ -109,11 +109,24 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
|
||||||
export async function previewNumberingCode(
|
export async function previewNumberingCode(
|
||||||
ruleId: string
|
ruleId: string
|
||||||
): Promise<ApiResponse<{ generatedCode: string }>> {
|
): Promise<ApiResponse<{ generatedCode: string }>> {
|
||||||
|
// ruleId 유효성 검사
|
||||||
|
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
|
||||||
|
return { success: false, error: "채번 규칙 ID가 설정되지 않았습니다" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
|
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
|
||||||
|
if (!response.data) {
|
||||||
|
return { success: false, error: "서버 응답이 비어있습니다" };
|
||||||
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return { success: false, error: error.message || "코드 미리보기 실패" };
|
const errorMessage =
|
||||||
|
error.response?.data?.error ||
|
||||||
|
error.response?.data?.message ||
|
||||||
|
error.message ||
|
||||||
|
"코드 미리보기 실패";
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { ItemSelectionModal } from "./ItemSelectionModal";
|
import { ItemSelectionModal } from "./ItemSelectionModal";
|
||||||
import { RepeaterTable } from "./RepeaterTable";
|
import { RepeaterTable } from "./RepeaterTable";
|
||||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types";
|
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
|
||||||
import { useCalculation } from "./useCalculation";
|
import { useCalculation } from "./useCalculation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -294,6 +294,9 @@ export function ModalRepeaterTableComponent({
|
||||||
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
|
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||||
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
|
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID)
|
||||||
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||||
const configuredColumns = componentConfig?.columns || propColumns || [];
|
const configuredColumns = componentConfig?.columns || propColumns || [];
|
||||||
|
|
@ -410,6 +413,193 @@ export function ModalRepeaterTableComponent({
|
||||||
|
|
||||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 데이터 소스 변경 시 호출
|
||||||
|
* 해당 컬럼의 모든 행 데이터를 새로운 소스에서 다시 조회
|
||||||
|
*/
|
||||||
|
const handleDataSourceChange = async (columnField: string, optionId: string) => {
|
||||||
|
console.log(`🔄 데이터 소스 변경: ${columnField} → ${optionId}`);
|
||||||
|
|
||||||
|
// 활성화 상태 업데이트
|
||||||
|
setActiveDataSources((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[columnField]: optionId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 컬럼 찾기
|
||||||
|
const column = columns.find((col) => col.field === columnField);
|
||||||
|
if (!column?.dynamicDataSource?.enabled) {
|
||||||
|
console.warn(`⚠️ 컬럼 "${columnField}"에 동적 데이터 소스가 설정되지 않음`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 옵션 찾기
|
||||||
|
const option = column.dynamicDataSource.options.find((opt) => opt.id === optionId);
|
||||||
|
if (!option) {
|
||||||
|
console.warn(`⚠️ 옵션 "${optionId}"을 찾을 수 없음`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 행에 대해 새 값 조회
|
||||||
|
const updatedData = await Promise.all(
|
||||||
|
localValue.map(async (row, index) => {
|
||||||
|
try {
|
||||||
|
const newValue = await fetchDynamicValue(option, row);
|
||||||
|
console.log(` ✅ 행 ${index}: ${columnField} = ${newValue}`);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
[columnField]: newValue,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ 행 ${index} 조회 실패:`, error);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 계산 필드 업데이트 후 데이터 반영
|
||||||
|
const calculatedData = calculateAll(updatedData);
|
||||||
|
handleChange(calculatedData);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 데이터 소스 옵션에 따라 값 조회
|
||||||
|
*/
|
||||||
|
async function fetchDynamicValue(
|
||||||
|
option: DynamicDataSourceOption,
|
||||||
|
rowData: any
|
||||||
|
): Promise<any> {
|
||||||
|
if (option.sourceType === "table" && option.tableConfig) {
|
||||||
|
// 테이블 직접 조회 (단순 조인)
|
||||||
|
const { tableName, valueColumn, joinConditions } = option.tableConfig;
|
||||||
|
|
||||||
|
const whereConditions: Record<string, any> = {};
|
||||||
|
for (const cond of joinConditions) {
|
||||||
|
const value = rowData[cond.sourceField];
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
whereConditions[cond.targetField] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 테이블 조회: ${tableName}`, whereConditions);
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
{ search: whereConditions, size: 1, page: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
||||||
|
return response.data.data.data[0][valueColumn];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
} else if (option.sourceType === "multiTable" && option.multiTableConfig) {
|
||||||
|
// 테이블 복합 조인 (2개 이상 테이블 순차 조인)
|
||||||
|
const { joinChain, valueColumn } = option.multiTableConfig;
|
||||||
|
|
||||||
|
if (!joinChain || joinChain.length === 0) {
|
||||||
|
console.warn("⚠️ 조인 체인이 비어있습니다.");
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 복합 조인 시작: ${joinChain.length}단계`);
|
||||||
|
|
||||||
|
// 현재 값을 추적 (첫 단계는 현재 행에서 시작)
|
||||||
|
let currentValue: any = null;
|
||||||
|
let currentRow: any = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < joinChain.length; i++) {
|
||||||
|
const step = joinChain[i];
|
||||||
|
const { tableName, joinCondition, outputField } = step;
|
||||||
|
|
||||||
|
// 조인 조건 값 가져오기
|
||||||
|
let fromValue: any;
|
||||||
|
if (i === 0) {
|
||||||
|
// 첫 번째 단계: 현재 행에서 값 가져오기
|
||||||
|
fromValue = rowData[joinCondition.fromField];
|
||||||
|
console.log(` 📍 단계 ${i + 1}: 현재행.${joinCondition.fromField} = ${fromValue}`);
|
||||||
|
} else {
|
||||||
|
// 이후 단계: 이전 조회 결과에서 값 가져오기
|
||||||
|
fromValue = currentRow?.[joinCondition.fromField] || currentValue;
|
||||||
|
console.log(` 📍 단계 ${i + 1}: 이전결과.${joinCondition.fromField} = ${fromValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromValue === undefined || fromValue === null) {
|
||||||
|
console.warn(`⚠️ 단계 ${i + 1}: 조인 조건 값이 없습니다. (${joinCondition.fromField})`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 조회
|
||||||
|
const whereConditions: Record<string, any> = {
|
||||||
|
[joinCondition.toField]: fromValue
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
{ search: whereConditions, size: 1, page: 1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
||||||
|
currentRow = response.data.data.data[0];
|
||||||
|
currentValue = outputField ? currentRow[outputField] : currentRow;
|
||||||
|
console.log(` ✅ 단계 ${i + 1} 성공:`, { outputField, value: currentValue });
|
||||||
|
} else {
|
||||||
|
console.warn(` ⚠️ 단계 ${i + 1}: 조회 결과 없음`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ 단계 ${i + 1} 조회 실패:`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종 값 반환 (마지막 테이블에서 valueColumn 가져오기)
|
||||||
|
const finalValue = currentRow?.[valueColumn];
|
||||||
|
console.log(`🎯 복합 조인 완료: ${valueColumn} = ${finalValue}`);
|
||||||
|
return finalValue;
|
||||||
|
|
||||||
|
} else if (option.sourceType === "api" && option.apiConfig) {
|
||||||
|
// 전용 API 호출 (복잡한 다중 조인)
|
||||||
|
const { endpoint, method = "GET", parameterMappings, responseValueField } = option.apiConfig;
|
||||||
|
|
||||||
|
// 파라미터 빌드
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
for (const mapping of parameterMappings) {
|
||||||
|
const value = rowData[mapping.sourceField];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
params[mapping.paramName] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 API 호출: ${method} ${endpoint}`, params);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (method === "POST") {
|
||||||
|
response = await apiClient.post(endpoint, params);
|
||||||
|
} else {
|
||||||
|
response = await apiClient.get(endpoint, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
// responseValueField로 값 추출 (중첩 경로 지원: "data.price")
|
||||||
|
const keys = responseValueField.split(".");
|
||||||
|
let value = response.data.data;
|
||||||
|
for (const key of keys) {
|
||||||
|
value = value?.[key];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// 초기 데이터에 계산 필드 적용
|
// 초기 데이터에 계산 필드 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localValue.length > 0 && calculationRules.length > 0) {
|
if (localValue.length > 0 && calculationRules.length > 0) {
|
||||||
|
|
@ -579,6 +769,8 @@ export function ModalRepeaterTableComponent({
|
||||||
onDataChange={handleChange}
|
onDataChange={handleChange}
|
||||||
onRowChange={handleRowChange}
|
onRowChange={handleRowChange}
|
||||||
onRowDelete={handleRowDelete}
|
onRowDelete={handleRowDelete}
|
||||||
|
activeDataSources={activeDataSources}
|
||||||
|
onDataSourceChange={handleDataSourceChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 항목 선택 모달 */}
|
{/* 항목 선택 모달 */}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule } from "./types";
|
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -170,6 +171,10 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
||||||
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
|
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
|
||||||
|
|
||||||
|
// 동적 데이터 소스 설정 모달
|
||||||
|
const [dynamicSourceModalOpen, setDynamicSourceModalOpen] = useState(false);
|
||||||
|
const [editingDynamicSourceColumnIndex, setEditingDynamicSourceColumnIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용)
|
// config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanedConfig = cleanupInitialConfig(config);
|
const cleanedConfig = cleanupInitialConfig(config);
|
||||||
|
|
@ -397,6 +402,101 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
updateConfig({ calculationRules: rules });
|
updateConfig({ calculationRules: rules });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 동적 데이터 소스 설정 함수들
|
||||||
|
const openDynamicSourceModal = (columnIndex: number) => {
|
||||||
|
setEditingDynamicSourceColumnIndex(columnIndex);
|
||||||
|
setDynamicSourceModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDynamicDataSource = (columnIndex: number, enabled: boolean) => {
|
||||||
|
const columns = [...(localConfig.columns || [])];
|
||||||
|
if (enabled) {
|
||||||
|
columns[columnIndex] = {
|
||||||
|
...columns[columnIndex],
|
||||||
|
dynamicDataSource: {
|
||||||
|
enabled: true,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { dynamicDataSource, ...rest } = columns[columnIndex];
|
||||||
|
columns[columnIndex] = rest;
|
||||||
|
}
|
||||||
|
updateConfig({ columns });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDynamicSourceOption = (columnIndex: number) => {
|
||||||
|
const columns = [...(localConfig.columns || [])];
|
||||||
|
const col = columns[columnIndex];
|
||||||
|
const newOption: DynamicDataSourceOption = {
|
||||||
|
id: `option_${Date.now()}`,
|
||||||
|
label: "새 옵션",
|
||||||
|
sourceType: "table",
|
||||||
|
tableConfig: {
|
||||||
|
tableName: "",
|
||||||
|
valueColumn: "",
|
||||||
|
joinConditions: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
columns[columnIndex] = {
|
||||||
|
...col,
|
||||||
|
dynamicDataSource: {
|
||||||
|
...col.dynamicDataSource!,
|
||||||
|
enabled: true,
|
||||||
|
options: [...(col.dynamicDataSource?.options || []), newOption],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateConfig({ columns });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDynamicSourceOption = (columnIndex: number, optionIndex: number, updates: Partial<DynamicDataSourceOption>) => {
|
||||||
|
const columns = [...(localConfig.columns || [])];
|
||||||
|
const col = columns[columnIndex];
|
||||||
|
const options = [...(col.dynamicDataSource?.options || [])];
|
||||||
|
options[optionIndex] = { ...options[optionIndex], ...updates };
|
||||||
|
|
||||||
|
columns[columnIndex] = {
|
||||||
|
...col,
|
||||||
|
dynamicDataSource: {
|
||||||
|
...col.dynamicDataSource!,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateConfig({ columns });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDynamicSourceOption = (columnIndex: number, optionIndex: number) => {
|
||||||
|
const columns = [...(localConfig.columns || [])];
|
||||||
|
const col = columns[columnIndex];
|
||||||
|
const options = [...(col.dynamicDataSource?.options || [])];
|
||||||
|
options.splice(optionIndex, 1);
|
||||||
|
|
||||||
|
columns[columnIndex] = {
|
||||||
|
...col,
|
||||||
|
dynamicDataSource: {
|
||||||
|
...col.dynamicDataSource!,
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateConfig({ columns });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDefaultDynamicSourceOption = (columnIndex: number, optionId: string) => {
|
||||||
|
const columns = [...(localConfig.columns || [])];
|
||||||
|
const col = columns[columnIndex];
|
||||||
|
|
||||||
|
columns[columnIndex] = {
|
||||||
|
...col,
|
||||||
|
dynamicDataSource: {
|
||||||
|
...col.dynamicDataSource!,
|
||||||
|
defaultOptionId: optionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateConfig({ columns });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4">
|
<div className="space-y-6 p-4">
|
||||||
{/* 소스/저장 테이블 설정 */}
|
{/* 소스/저장 테이블 설정 */}
|
||||||
|
|
@ -1327,6 +1427,60 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 6. 동적 데이터 소스 설정 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
동적 데이터 소스
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
checked={col.dynamicDataSource?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => toggleDynamicDataSource(index, checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
컬럼 헤더 클릭으로 데이터 소스 전환 (예: 거래처별 단가, 품목별 단가)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{col.dynamicDataSource?.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{col.dynamicDataSource.options.length}개 옵션 설정됨
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openDynamicSourceModal(index)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
옵션 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 미리보기 */}
|
||||||
|
{col.dynamicDataSource.options.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{col.dynamicDataSource.options.map((opt) => (
|
||||||
|
<span
|
||||||
|
key={opt.id}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-2 py-0.5 rounded-full",
|
||||||
|
col.dynamicDataSource?.defaultOptionId === opt.id
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{col.dynamicDataSource?.defaultOptionId === opt.id && " (기본)"}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1493,6 +1647,650 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 동적 데이터 소스 설정 모달 */}
|
||||||
|
<Dialog open={dynamicSourceModalOpen} onOpenChange={setDynamicSourceModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
동적 데이터 소스 설정
|
||||||
|
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
|
||||||
|
<span className="text-primary ml-2">
|
||||||
|
({localConfig.columns[editingDynamicSourceColumnIndex].label})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
컬럼 헤더 클릭 시 선택할 수 있는 데이터 소스 옵션을 설정합니다
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 옵션 목록 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(localConfig.columns[editingDynamicSourceColumnIndex].dynamicDataSource?.options || []).map((option, optIndex) => (
|
||||||
|
<div key={option.id} className="border rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium">옵션 {optIndex + 1}</span>
|
||||||
|
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId === option.id && (
|
||||||
|
<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded">기본</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId !== option.id && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setDefaultDynamicSourceOption(editingDynamicSourceColumnIndex, option.id)}
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
기본으로 설정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => removeDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex)}
|
||||||
|
className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 라벨 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">표시 라벨 *</Label>
|
||||||
|
<Input
|
||||||
|
value={option.label}
|
||||||
|
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { label: e.target.value })}
|
||||||
|
placeholder="예: 거래처별 단가"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 소스 타입 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">조회 방식 *</Label>
|
||||||
|
<Select
|
||||||
|
value={option.sourceType}
|
||||||
|
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
sourceType: value as "table" | "multiTable" | "api",
|
||||||
|
tableConfig: value === "table" ? { tableName: "", valueColumn: "", joinConditions: [] } : undefined,
|
||||||
|
multiTableConfig: value === "multiTable" ? { joinChain: [], valueColumn: "" } : undefined,
|
||||||
|
apiConfig: value === "api" ? { endpoint: "", parameterMappings: [], responseValueField: "" } : undefined,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="table">테이블 직접 조회 (단순 조인)</SelectItem>
|
||||||
|
<SelectItem value="multiTable">테이블 복합 조인 (2개 이상)</SelectItem>
|
||||||
|
<SelectItem value="api">전용 API 호출</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 직접 조회 설정 */}
|
||||||
|
{option.sourceType === "table" && (
|
||||||
|
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
|
||||||
|
<p className="text-xs font-medium">테이블 조회 설정</p>
|
||||||
|
|
||||||
|
{/* 참조 테이블 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">참조 테이블 *</Label>
|
||||||
|
<Select
|
||||||
|
value={option.tableConfig?.tableName || ""}
|
||||||
|
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
tableConfig: { ...option.tableConfig!, tableName: value },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 값 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">값 컬럼 (가져올 컬럼) *</Label>
|
||||||
|
<ReferenceColumnSelector
|
||||||
|
referenceTable={option.tableConfig?.tableName || ""}
|
||||||
|
value={option.tableConfig?.valueColumn || ""}
|
||||||
|
onChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
tableConfig: { ...option.tableConfig!, valueColumn: value },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 조건 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">조인 조건 *</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const newConditions = [...(option.tableConfig?.joinConditions || []), { sourceField: "", targetField: "" }];
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(option.tableConfig?.joinConditions || []).map((cond, condIndex) => (
|
||||||
|
<div key={condIndex} className="flex items-center gap-2 p-2 bg-background rounded">
|
||||||
|
<Select
|
||||||
|
value={cond.sourceField}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newConditions = [...(option.tableConfig?.joinConditions || [])];
|
||||||
|
newConditions[condIndex] = { ...newConditions[condIndex], sourceField: value };
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px] flex-1">
|
||||||
|
<SelectValue placeholder="현재 행 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(localConfig.columns || []).map((col) => (
|
||||||
|
<SelectItem key={col.field} value={col.field}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px] text-muted-foreground">=</span>
|
||||||
|
<ReferenceColumnSelector
|
||||||
|
referenceTable={option.tableConfig?.tableName || ""}
|
||||||
|
value={cond.targetField}
|
||||||
|
onChange={(value) => {
|
||||||
|
const newConditions = [...(option.tableConfig?.joinConditions || [])];
|
||||||
|
newConditions[condIndex] = { ...newConditions[condIndex], targetField: value };
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newConditions = [...(option.tableConfig?.joinConditions || [])];
|
||||||
|
newConditions.splice(condIndex, 1);
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 복합 조인 설정 (2개 이상 테이블) */}
|
||||||
|
{option.sourceType === "multiTable" && (
|
||||||
|
<div className="space-y-3 p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium">복합 조인 설정</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
여러 테이블을 순차적으로 조인합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 체인 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">조인 체인 *</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const newChain: MultiTableJoinStep[] = [
|
||||||
|
...(option.multiTableConfig?.joinChain || []),
|
||||||
|
{ tableName: "", joinCondition: { fromField: "", toField: "" }, outputField: "" }
|
||||||
|
];
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
조인 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시작점 안내 */}
|
||||||
|
<div className="p-2 bg-background rounded border-l-2 border-primary">
|
||||||
|
<p className="text-[10px] font-medium text-primary">시작: 현재 행 데이터</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
첫 번째 조인은 현재 행의 필드에서 시작합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 단계들 */}
|
||||||
|
{(option.multiTableConfig?.joinChain || []).map((step, stepIndex) => (
|
||||||
|
<div key={stepIndex} className="p-3 border rounded-md bg-background space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center text-[10px] font-bold">
|
||||||
|
{stepIndex + 1}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium">조인 단계 {stepIndex + 1}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||||
|
newChain.splice(stepIndex, 1);
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인할 테이블 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">조인할 테이블 *</Label>
|
||||||
|
<Select
|
||||||
|
value={step.tableName}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||||
|
newChain[stepIndex] = { ...newChain[stepIndex], tableName: value };
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 조건 */}
|
||||||
|
<div className="grid grid-cols-[1fr,auto,1fr] gap-2 items-end">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">
|
||||||
|
{stepIndex === 0 ? "현재 행 필드" : "이전 단계 출력 필드"}
|
||||||
|
</Label>
|
||||||
|
{stepIndex === 0 ? (
|
||||||
|
<Select
|
||||||
|
value={step.joinCondition.fromField}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||||
|
newChain[stepIndex] = {
|
||||||
|
...newChain[stepIndex],
|
||||||
|
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: value }
|
||||||
|
};
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(localConfig.columns || []).map((col) => (
|
||||||
|
<SelectItem key={col.field} value={col.field}>
|
||||||
|
{col.label} ({col.field})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={step.joinCondition.fromField}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||||
|
newChain[stepIndex] = {
|
||||||
|
...newChain[stepIndex],
|
||||||
|
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: e.target.value }
|
||||||
|
};
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder={option.multiTableConfig?.joinChain[stepIndex - 1]?.outputField || "이전 출력 필드"}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center pb-1">
|
||||||
|
<span className="text-xs text-muted-foreground">=</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">대상 테이블 필드</Label>
|
||||||
|
<ReferenceColumnSelector
|
||||||
|
referenceTable={step.tableName}
|
||||||
|
value={step.joinCondition.toField}
|
||||||
|
onChange={(value) => {
|
||||||
|
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||||
|
newChain[stepIndex] = {
|
||||||
|
...newChain[stepIndex],
|
||||||
|
joinCondition: { ...newChain[stepIndex].joinCondition, toField: value }
|
||||||
|
};
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 단계로 전달할 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">다음 단계로 전달할 필드 (출력)</Label>
|
||||||
|
<ReferenceColumnSelector
|
||||||
|
referenceTable={step.tableName}
|
||||||
|
value={step.outputField || ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||||
|
newChain[stepIndex] = { ...newChain[stepIndex], outputField: value };
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{stepIndex < (option.multiTableConfig?.joinChain.length || 0) - 1
|
||||||
|
? "다음 조인 단계에서 사용할 필드"
|
||||||
|
: "마지막 단계면 비워두세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 조인 미리보기 */}
|
||||||
|
{step.tableName && step.joinCondition.fromField && step.joinCondition.toField && (
|
||||||
|
<div className="p-2 bg-muted/50 rounded text-[10px] font-mono">
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">
|
||||||
|
{stepIndex === 0 ? "현재행" : option.multiTableConfig?.joinChain[stepIndex - 1]?.tableName}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">.{step.joinCondition.fromField}</span>
|
||||||
|
<span className="mx-2 text-green-600 dark:text-green-400">=</span>
|
||||||
|
<span className="text-green-600 dark:text-green-400">{step.tableName}</span>
|
||||||
|
<span className="text-muted-foreground">.{step.joinCondition.toField}</span>
|
||||||
|
{step.outputField && (
|
||||||
|
<span className="ml-2 text-purple-600 dark:text-purple-400">
|
||||||
|
→ {step.outputField}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 조인 체인이 없을 때 안내 */}
|
||||||
|
{(!option.multiTableConfig?.joinChain || option.multiTableConfig.joinChain.length === 0) && (
|
||||||
|
<div className="p-4 border-2 border-dashed rounded-lg text-center">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
조인 체인이 없습니다
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
"조인 추가" 버튼을 클릭하여 테이블 조인을 설정하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최종 값 컬럼 */}
|
||||||
|
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
|
||||||
|
<div className="space-y-1 pt-2 border-t">
|
||||||
|
<Label className="text-[10px]">최종 값 컬럼 (가져올 값) *</Label>
|
||||||
|
<ReferenceColumnSelector
|
||||||
|
referenceTable={option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName || ""}
|
||||||
|
value={option.multiTableConfig.valueColumn || ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
multiTableConfig: { ...option.multiTableConfig!, valueColumn: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
마지막 테이블에서 가져올 값
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 전체 조인 경로 미리보기 */}
|
||||||
|
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
|
||||||
|
<div className="p-3 bg-muted rounded-md">
|
||||||
|
<p className="text-[10px] font-medium mb-2">조인 경로 미리보기</p>
|
||||||
|
<div className="text-[10px] font-mono space-y-1">
|
||||||
|
{option.multiTableConfig.joinChain.map((step, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1">
|
||||||
|
{idx === 0 && (
|
||||||
|
<>
|
||||||
|
<span className="text-blue-600">현재행</span>
|
||||||
|
<span>.{step.joinCondition.fromField}</span>
|
||||||
|
<span className="text-muted-foreground mx-1">→</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-green-600">{step.tableName}</span>
|
||||||
|
<span>.{step.joinCondition.toField}</span>
|
||||||
|
{step.outputField && idx < option.multiTableConfig!.joinChain.length - 1 && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground mx-1">→</span>
|
||||||
|
<span className="text-purple-600">{step.outputField}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{option.multiTableConfig.valueColumn && (
|
||||||
|
<div className="pt-1 border-t mt-1">
|
||||||
|
<span className="text-orange-600">최종 값: </span>
|
||||||
|
<span>{option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName}.{option.multiTableConfig.valueColumn}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* API 호출 설정 */}
|
||||||
|
{option.sourceType === "api" && (
|
||||||
|
<div className="space-y-3 p-3 bg-purple-50 dark:bg-purple-950 rounded-md border border-purple-200 dark:border-purple-800">
|
||||||
|
<p className="text-xs font-medium">API 호출 설정</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
복잡한 다중 조인은 백엔드 API로 처리합니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* API 엔드포인트 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">API 엔드포인트 *</Label>
|
||||||
|
<Input
|
||||||
|
value={option.apiConfig?.endpoint || ""}
|
||||||
|
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, endpoint: e.target.value },
|
||||||
|
})}
|
||||||
|
placeholder="/api/price/customer"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HTTP 메서드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">HTTP 메서드</Label>
|
||||||
|
<Select
|
||||||
|
value={option.apiConfig?.method || "GET"}
|
||||||
|
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, method: value as "GET" | "POST" },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="GET">GET</SelectItem>
|
||||||
|
<SelectItem value="POST">POST</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 파라미터 매핑 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-[10px]">파라미터 매핑 *</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const newMappings = [...(option.apiConfig?.parameterMappings || []), { paramName: "", sourceField: "" }];
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 text-[10px] px-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(option.apiConfig?.parameterMappings || []).map((mapping, mapIndex) => (
|
||||||
|
<div key={mapIndex} className="flex items-center gap-2 p-2 bg-background rounded">
|
||||||
|
<Input
|
||||||
|
value={mapping.paramName}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
|
||||||
|
newMappings[mapIndex] = { ...newMappings[mapIndex], paramName: e.target.value };
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="파라미터명"
|
||||||
|
className="h-7 text-[10px] flex-1"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground">=</span>
|
||||||
|
<Select
|
||||||
|
value={mapping.sourceField}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
|
||||||
|
newMappings[mapIndex] = { ...newMappings[mapIndex], sourceField: value };
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-[10px] flex-1">
|
||||||
|
<SelectValue placeholder="현재 행 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(localConfig.columns || []).map((col) => (
|
||||||
|
<SelectItem key={col.field} value={col.field}>
|
||||||
|
{col.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
|
||||||
|
newMappings.splice(mapIndex, 1);
|
||||||
|
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 응답 값 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">응답 값 필드 *</Label>
|
||||||
|
<Input
|
||||||
|
value={option.apiConfig?.responseValueField || ""}
|
||||||
|
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||||
|
apiConfig: { ...option.apiConfig!, responseValueField: e.target.value },
|
||||||
|
})}
|
||||||
|
placeholder="price (또는 data.price)"
|
||||||
|
className="h-8 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
API 응답에서 값을 가져올 필드 (중첩 경로 지원: data.price)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 옵션 추가 버튼 */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => addDynamicSourceOption(editingDynamicSourceColumnIndex)}
|
||||||
|
className="w-full h-10"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
데이터 소스 옵션 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 안내 */}
|
||||||
|
<div className="p-3 bg-muted rounded-md text-xs text-muted-foreground">
|
||||||
|
<p className="font-medium mb-1">사용 예시</p>
|
||||||
|
<ul className="space-y-1 text-[10px]">
|
||||||
|
<li>- <strong>거래처별 단가</strong>: customer_item_price 테이블에서 조회</li>
|
||||||
|
<li>- <strong>품목별 단가</strong>: item_info 테이블에서 기준 단가 조회</li>
|
||||||
|
<li>- <strong>계약 단가</strong>: 전용 API로 복잡한 조인 처리</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDynamicSourceModalOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import React, { useState, useEffect } from "react";
|
||||||
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 { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Trash2, ChevronDown, Check } from "lucide-react";
|
||||||
import { RepeaterColumnConfig } from "./types";
|
import { RepeaterColumnConfig } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -14,6 +15,9 @@ interface RepeaterTableProps {
|
||||||
onDataChange: (newData: any[]) => void;
|
onDataChange: (newData: any[]) => void;
|
||||||
onRowChange: (index: number, newRow: any) => void;
|
onRowChange: (index: number, newRow: any) => void;
|
||||||
onRowDelete: (index: number) => void;
|
onRowDelete: (index: number) => void;
|
||||||
|
// 동적 데이터 소스 관련
|
||||||
|
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
|
||||||
|
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RepeaterTable({
|
export function RepeaterTable({
|
||||||
|
|
@ -22,12 +26,17 @@ export function RepeaterTable({
|
||||||
onDataChange,
|
onDataChange,
|
||||||
onRowChange,
|
onRowChange,
|
||||||
onRowDelete,
|
onRowDelete,
|
||||||
|
activeDataSources = {},
|
||||||
|
onDataSourceChange,
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
const [editingCell, setEditingCell] = useState<{
|
const [editingCell, setEditingCell] = useState<{
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
field: string;
|
field: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 동적 데이터 소스 Popover 열림 상태
|
||||||
|
const [openPopover, setOpenPopover] = useState<string | null>(null);
|
||||||
|
|
||||||
// 데이터 변경 감지 (필요시 활성화)
|
// 데이터 변경 감지 (필요시 활성화)
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행");
|
// console.log("📊 RepeaterTable 데이터 업데이트:", data.length, "개 행");
|
||||||
|
|
@ -144,16 +153,79 @@ export function RepeaterTable({
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||||
#
|
#
|
||||||
</th>
|
</th>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => {
|
||||||
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||||
|
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||||
|
const activeOption = hasDynamicSource
|
||||||
|
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.field}
|
key={col.field}
|
||||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
>
|
>
|
||||||
|
{hasDynamicSource ? (
|
||||||
|
<Popover
|
||||||
|
open={openPopover === col.field}
|
||||||
|
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 hover:text-primary transition-colors",
|
||||||
|
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{col.label}</span>
|
||||||
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto min-w-[160px] p-1"
|
||||||
|
align="start"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
|
||||||
|
데이터 소스 선택
|
||||||
|
</div>
|
||||||
|
{col.dynamicDataSource!.options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDataSourceChange?.(col.field, option.id);
|
||||||
|
setOpenPopover(null);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||||
|
"focus:outline-none focus-visible:bg-accent",
|
||||||
|
activeOption?.id === option.id && "bg-accent/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3",
|
||||||
|
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{col.label}
|
{col.label}
|
||||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||||
삭제
|
삭제
|
||||||
</th>
|
</th>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export interface ModalRepeaterTableProps {
|
||||||
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||||
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
||||||
|
|
||||||
// 🆕 저장 대상 테이블 설정
|
// 저장 대상 테이블 설정
|
||||||
targetTable?: string; // 저장할 테이블 (예: "sales_order_mng")
|
targetTable?: string; // 저장할 테이블 (예: "sales_order_mng")
|
||||||
|
|
||||||
// 모달 설정
|
// 모달 설정
|
||||||
|
|
@ -25,14 +25,14 @@ export interface ModalRepeaterTableProps {
|
||||||
calculationRules?: CalculationRule[]; // 자동 계산 규칙
|
calculationRules?: CalculationRule[]; // 자동 계산 규칙
|
||||||
|
|
||||||
// 데이터
|
// 데이터
|
||||||
value: any[]; // 현재 추가된 항목들
|
value: Record<string, unknown>[]; // 현재 추가된 항목들
|
||||||
onChange: (newData: any[]) => void; // 데이터 변경 콜백
|
onChange: (newData: Record<string, unknown>[]) => void; // 데이터 변경 콜백
|
||||||
|
|
||||||
// 중복 체크
|
// 중복 체크
|
||||||
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
|
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
|
||||||
|
|
||||||
// 필터링
|
// 필터링
|
||||||
filterCondition?: Record<string, any>;
|
filterCondition?: Record<string, unknown>;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
|
|
||||||
// 스타일
|
// 스타일
|
||||||
|
|
@ -47,11 +47,92 @@ export interface RepeaterColumnConfig {
|
||||||
calculated?: boolean; // 계산 필드 여부
|
calculated?: boolean; // 계산 필드 여부
|
||||||
width?: string; // 컬럼 너비
|
width?: string; // 컬럼 너비
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
defaultValue?: any; // 기본값
|
defaultValue?: string | number | boolean; // 기본값
|
||||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||||
|
|
||||||
// 🆕 컬럼 매핑 설정
|
// 컬럼 매핑 설정
|
||||||
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
|
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
|
||||||
|
|
||||||
|
// 동적 데이터 소스 (컬럼 헤더 클릭으로 데이터 소스 전환)
|
||||||
|
dynamicDataSource?: DynamicDataSourceConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 데이터 소스 설정
|
||||||
|
* 컬럼 헤더를 클릭하여 데이터 소스를 전환할 수 있는 기능
|
||||||
|
* 예: 거래처별 단가, 품목별 단가, 기준 단가 등을 선택
|
||||||
|
*/
|
||||||
|
export interface DynamicDataSourceConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
options: DynamicDataSourceOption[];
|
||||||
|
defaultOptionId?: string; // 기본 선택 옵션 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동적 데이터 소스 옵션
|
||||||
|
* 각 옵션은 다른 테이블/API에서 데이터를 가져오는 방법을 정의
|
||||||
|
*/
|
||||||
|
export interface DynamicDataSourceOption {
|
||||||
|
id: string;
|
||||||
|
label: string; // 표시 라벨 (예: "거래처별 단가")
|
||||||
|
|
||||||
|
// 조회 방식
|
||||||
|
sourceType: "table" | "multiTable" | "api";
|
||||||
|
|
||||||
|
// 테이블 직접 조회 (단순 조인 - 1개 테이블)
|
||||||
|
tableConfig?: {
|
||||||
|
tableName: string; // 참조 테이블명
|
||||||
|
valueColumn: string; // 가져올 값 컬럼
|
||||||
|
joinConditions: {
|
||||||
|
sourceField: string; // 현재 행의 필드
|
||||||
|
targetField: string; // 참조 테이블의 필드
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 복합 조인 (2개 이상 테이블 조인)
|
||||||
|
multiTableConfig?: {
|
||||||
|
// 조인 체인 정의 (순서대로 조인)
|
||||||
|
joinChain: MultiTableJoinStep[];
|
||||||
|
// 최종적으로 가져올 값 컬럼 (마지막 테이블에서)
|
||||||
|
valueColumn: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전용 API 호출 (복잡한 다중 조인)
|
||||||
|
apiConfig?: {
|
||||||
|
endpoint: string; // API 엔드포인트 (예: "/api/price/customer")
|
||||||
|
method?: "GET" | "POST"; // HTTP 메서드 (기본: GET)
|
||||||
|
parameterMappings: {
|
||||||
|
paramName: string; // API 파라미터명
|
||||||
|
sourceField: string; // 현재 행의 필드
|
||||||
|
}[];
|
||||||
|
responseValueField: string; // 응답에서 값을 가져올 필드
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 복합 조인 단계 정의
|
||||||
|
* 예: item_info.item_number → customer_item.item_code → customer_item.id → customer_item_price.customer_item_id
|
||||||
|
*/
|
||||||
|
export interface MultiTableJoinStep {
|
||||||
|
// 조인할 테이블
|
||||||
|
tableName: string;
|
||||||
|
// 조인 조건
|
||||||
|
joinCondition: {
|
||||||
|
// 이전 단계의 필드 (첫 번째 단계는 현재 행의 필드)
|
||||||
|
fromField: string;
|
||||||
|
// 이 테이블의 필드
|
||||||
|
toField: string;
|
||||||
|
};
|
||||||
|
// 다음 단계로 전달할 필드 (다음 조인에 사용)
|
||||||
|
outputField?: string;
|
||||||
|
// 추가 필터 조건 (선택사항)
|
||||||
|
additionalFilters?: {
|
||||||
|
field: string;
|
||||||
|
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||||
|
value: string | number | boolean;
|
||||||
|
// 값이 현재 행에서 오는 경우
|
||||||
|
valueFromField?: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -101,11 +182,10 @@ export interface ItemSelectionModalProps {
|
||||||
sourceColumns: string[];
|
sourceColumns: string[];
|
||||||
sourceSearchFields?: string[];
|
sourceSearchFields?: string[];
|
||||||
multiSelect?: boolean;
|
multiSelect?: boolean;
|
||||||
filterCondition?: Record<string, any>;
|
filterCondition?: Record<string, unknown>;
|
||||||
modalTitle: string;
|
modalTitle: string;
|
||||||
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
|
alreadySelected: Record<string, unknown>[]; // 이미 선택된 항목들 (중복 방지용)
|
||||||
uniqueField?: string;
|
uniqueField?: string;
|
||||||
onSelect: (items: any[]) => void;
|
onSelect: (items: Record<string, unknown>[]) => void;
|
||||||
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
|
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,8 @@ export function RepeatScreenModalComponent({
|
||||||
...props
|
...props
|
||||||
}: RepeatScreenModalComponentProps) {
|
}: RepeatScreenModalComponentProps) {
|
||||||
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
|
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
|
||||||
const groupedData = propsGroupedData || (props as any).groupedData;
|
// DynamicComponentRenderer에서는 _groupedData로 전달됨
|
||||||
|
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
...component?.config,
|
...component?.config,
|
||||||
|
|
@ -99,25 +100,99 @@ export function RepeatScreenModalComponent({
|
||||||
contentRowId: string;
|
contentRowId: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가
|
||||||
|
useEffect(() => {
|
||||||
|
const handleTriggerSave = async (event: Event) => {
|
||||||
|
if (!(event instanceof CustomEvent)) return;
|
||||||
|
|
||||||
|
console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신");
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
// 기존 데이터 저장
|
||||||
|
if (cardMode === "withTable") {
|
||||||
|
await saveGroupedData();
|
||||||
|
} else {
|
||||||
|
await saveSimpleData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부 테이블 데이터 저장
|
||||||
|
await saveExternalTableData();
|
||||||
|
|
||||||
|
// 연동 저장 처리 (syncSaves)
|
||||||
|
await processSyncSaves();
|
||||||
|
|
||||||
|
console.log("[RepeatScreenModal] 외부 트리거 저장 완료");
|
||||||
|
|
||||||
|
// 저장 완료 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
|
||||||
|
detail: { success: true }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 성공 콜백 실행
|
||||||
|
if (event.detail?.onSuccess) {
|
||||||
|
event.detail.onSuccess();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error);
|
||||||
|
|
||||||
|
// 저장 실패 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
|
||||||
|
detail: { success: false, error: error.message }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 실패 콜백 실행
|
||||||
|
if (event.detail?.onError) {
|
||||||
|
event.detail.onError(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
|
||||||
|
};
|
||||||
|
}, [cardMode, groupedCardsData, externalTableData, contentRows]);
|
||||||
|
|
||||||
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
|
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeFormSave = (event: Event) => {
|
const handleBeforeFormSave = (event: Event) => {
|
||||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||||
|
|
||||||
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
||||||
|
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
|
||||||
|
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
|
||||||
|
|
||||||
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
||||||
const saveDataByTable: Record<string, any[]> = {};
|
const saveDataByTable: Record<string, any[]> = {};
|
||||||
|
|
||||||
for (const [key, rows] of Object.entries(externalTableData)) {
|
for (const [key, rows] of Object.entries(externalTableData)) {
|
||||||
|
// key 형식: cardId-contentRowId
|
||||||
|
const keyParts = key.split("-");
|
||||||
|
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
|
||||||
|
|
||||||
// contentRow 찾기
|
// contentRow 찾기
|
||||||
const contentRow = contentRows.find((r) => key.includes(r.id));
|
const contentRow = contentRows.find((r) => key.includes(r.id));
|
||||||
if (!contentRow?.tableDataSource?.enabled) continue;
|
if (!contentRow?.tableDataSource?.enabled) continue;
|
||||||
|
|
||||||
|
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
|
||||||
|
const card = groupedCardsData.find((c) => c._cardId === cardId);
|
||||||
|
const representativeData = card?._representativeData || {};
|
||||||
|
|
||||||
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
|
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
|
||||||
|
|
||||||
// dirty 행만 필터링 (삭제된 행 제외)
|
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
|
||||||
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted);
|
// 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음)
|
||||||
|
const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted);
|
||||||
|
|
||||||
|
console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, {
|
||||||
|
totalRows: rows.length,
|
||||||
|
dirtyRows: dirtyRows.length,
|
||||||
|
rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted }))
|
||||||
|
});
|
||||||
|
|
||||||
if (dirtyRows.length === 0) continue;
|
if (dirtyRows.length === 0) continue;
|
||||||
|
|
||||||
|
|
@ -126,8 +201,9 @@ export function RepeatScreenModalComponent({
|
||||||
.filter((col) => col.editable)
|
.filter((col) => col.editable)
|
||||||
.map((col) => col.field);
|
.map((col) => col.field);
|
||||||
|
|
||||||
const joinKeys = (contentRow.tableDataSource.joinConditions || [])
|
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
|
||||||
.map((cond) => cond.sourceKey);
|
const joinConditions = contentRow.tableDataSource.joinConditions || [];
|
||||||
|
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
|
||||||
|
|
||||||
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
|
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
|
||||||
|
|
||||||
|
|
@ -145,6 +221,17 @@ export function RepeatScreenModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기
|
||||||
|
// 예: sales_order_id (sourceKey) = card의 id (targetKey)
|
||||||
|
for (const joinCond of joinConditions) {
|
||||||
|
const { sourceKey, targetKey } = joinCond;
|
||||||
|
// sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴
|
||||||
|
if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) {
|
||||||
|
saveData[sourceKey] = representativeData[targetKey];
|
||||||
|
console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// _isNew 플래그 유지
|
// _isNew 플래그 유지
|
||||||
saveData._isNew = row._isNew;
|
saveData._isNew = row._isNew;
|
||||||
saveData._targetTable = targetTable;
|
saveData._targetTable = targetTable;
|
||||||
|
|
@ -590,18 +677,26 @@ export function RepeatScreenModalComponent({
|
||||||
|
|
||||||
if (!hasExternalAggregation) return;
|
if (!hasExternalAggregation) return;
|
||||||
|
|
||||||
// contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기
|
// contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기
|
||||||
const tableRowWithExternalSource = contentRows.find(
|
const tableRowsWithExternalSource = contentRows.filter(
|
||||||
(row) => row.type === "table" && row.tableDataSource?.enabled
|
(row) => row.type === "table" && row.tableDataSource?.enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!tableRowWithExternalSource) return;
|
if (tableRowsWithExternalSource.length === 0) return;
|
||||||
|
|
||||||
// 각 카드의 집계 재계산
|
// 각 카드의 집계 재계산
|
||||||
const updatedCards = groupedCardsData.map((card) => {
|
const updatedCards = groupedCardsData.map((card) => {
|
||||||
const key = `${card._cardId}-${tableRowWithExternalSource.id}`;
|
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
|
||||||
|
const externalRowsByTableId: Record<string, any[]> = {};
|
||||||
|
const allExternalRows: any[] = [];
|
||||||
|
|
||||||
|
for (const tableRow of tableRowsWithExternalSource) {
|
||||||
|
const key = `${card._cardId}-${tableRow.id}`;
|
||||||
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
||||||
const externalRows = (extData[key] || []).filter((row) => !row._isDeleted);
|
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
|
||||||
|
externalRowsByTableId[tableRow.id] = rows;
|
||||||
|
allExternalRows.push(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
// 집계 재계산
|
// 집계 재계산
|
||||||
const newAggregations: Record<string, number> = {};
|
const newAggregations: Record<string, number> = {};
|
||||||
|
|
@ -616,7 +711,7 @@ export function RepeatScreenModalComponent({
|
||||||
if (isExternalTable) {
|
if (isExternalTable) {
|
||||||
// 외부 테이블 집계
|
// 외부 테이블 집계
|
||||||
newAggregations[agg.resultField] = calculateColumnAggregation(
|
newAggregations[agg.resultField] = calculateColumnAggregation(
|
||||||
externalRows,
|
allExternalRows,
|
||||||
agg.sourceField || "",
|
agg.sourceField || "",
|
||||||
agg.type || "sum"
|
agg.type || "sum"
|
||||||
);
|
);
|
||||||
|
|
@ -626,12 +721,28 @@ export function RepeatScreenModalComponent({
|
||||||
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
|
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
|
||||||
}
|
}
|
||||||
} else if (sourceType === "formula" && agg.formula) {
|
} else if (sourceType === "formula" && agg.formula) {
|
||||||
|
// 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용
|
||||||
|
let filteredExternalRows: any[];
|
||||||
|
|
||||||
|
if (agg.externalTableRefs && agg.externalTableRefs.length > 0) {
|
||||||
|
// 특정 테이블만 참조
|
||||||
|
filteredExternalRows = [];
|
||||||
|
for (const tableId of agg.externalTableRefs) {
|
||||||
|
if (externalRowsByTableId[tableId]) {
|
||||||
|
filteredExternalRows.push(...externalRowsByTableId[tableId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 모든 외부 테이블 데이터 사용 (기존 동작)
|
||||||
|
filteredExternalRows = allExternalRows;
|
||||||
|
}
|
||||||
|
|
||||||
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
|
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
|
||||||
newAggregations[agg.resultField] = evaluateFormulaWithContext(
|
newAggregations[agg.resultField] = evaluateFormulaWithContext(
|
||||||
agg.formula,
|
agg.formula,
|
||||||
card._representativeData,
|
card._representativeData,
|
||||||
card._rows,
|
card._rows,
|
||||||
externalRows,
|
filteredExternalRows,
|
||||||
newAggregations // 이전 집계 결과 참조
|
newAggregations // 이전 집계 결과 참조
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -654,8 +765,8 @@ export function RepeatScreenModalComponent({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 v3.1: 외부 테이블 행 추가
|
// 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
|
||||||
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
||||||
const key = `${cardId}-${contentRowId}`;
|
const key = `${cardId}-${contentRowId}`;
|
||||||
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
|
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
|
||||||
const representativeData = (card as GroupedCardData)?._representativeData || card || {};
|
const representativeData = (card as GroupedCardData)?._representativeData || card || {};
|
||||||
|
|
@ -707,6 +818,41 @@ export function RepeatScreenModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v3.13: 자동 채번 처리
|
||||||
|
const rowNumbering = contentRow.tableCrud?.rowNumbering;
|
||||||
|
console.log("[RepeatScreenModal] 채번 설정 확인:", {
|
||||||
|
tableCrud: contentRow.tableCrud,
|
||||||
|
rowNumbering,
|
||||||
|
enabled: rowNumbering?.enabled,
|
||||||
|
targetColumn: rowNumbering?.targetColumn,
|
||||||
|
numberingRuleId: rowNumbering?.numberingRuleId,
|
||||||
|
});
|
||||||
|
if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) {
|
||||||
|
try {
|
||||||
|
console.log("[RepeatScreenModal] 자동 채번 시작:", {
|
||||||
|
targetColumn: rowNumbering.targetColumn,
|
||||||
|
numberingRuleId: rowNumbering.numberingRuleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
|
||||||
|
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||||
|
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
|
||||||
|
|
||||||
|
console.log("[RepeatScreenModal] 자동 채번 완료:", {
|
||||||
|
column: rowNumbering.targetColumn,
|
||||||
|
generatedCode: response.data.generatedCode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("[RepeatScreenModal] 채번 실패:", response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[RepeatScreenModal] 채번 API 호출 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[RepeatScreenModal] 새 행 추가:", {
|
console.log("[RepeatScreenModal] 새 행 추가:", {
|
||||||
cardId,
|
cardId,
|
||||||
contentRowId,
|
contentRowId,
|
||||||
|
|
@ -1009,26 +1155,70 @@ export function RepeatScreenModalComponent({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 v3.1: 외부 테이블 행 삭제 실행 (소프트 삭제 - _isDeleted 플래그 설정)
|
// 🆕 v3.14: 외부 테이블 행 삭제 실행 (즉시 DELETE API 호출)
|
||||||
const handleDeleteExternalRow = (cardId: string, rowId: string, contentRowId: string) => {
|
const handleDeleteExternalRow = async (cardId: string, rowId: string, contentRowId: string) => {
|
||||||
const key = `${cardId}-${contentRowId}`;
|
const key = `${cardId}-${contentRowId}`;
|
||||||
|
const rows = externalTableData[key] || [];
|
||||||
|
const targetRow = rows.find((row) => row._rowId === rowId);
|
||||||
|
|
||||||
|
// 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제
|
||||||
|
if (targetRow?._originalData?.id) {
|
||||||
|
try {
|
||||||
|
const contentRow = contentRows.find((r) => r.id === contentRowId);
|
||||||
|
const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable;
|
||||||
|
|
||||||
|
if (!targetTable) {
|
||||||
|
console.error("[RepeatScreenModal] 삭제 대상 테이블을 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[RepeatScreenModal] DELETE API 호출: ${targetTable}, id=${targetRow._originalData.id}`);
|
||||||
|
|
||||||
|
// 백엔드는 배열 형태의 데이터를 기대함
|
||||||
|
await apiClient.request({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/table-management/tables/${targetTable}/delete`,
|
||||||
|
data: [{ id: targetRow._originalData.id }],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[RepeatScreenModal] DELETE 성공: ${targetTable}, id=${targetRow._originalData.id}`);
|
||||||
|
|
||||||
|
// 성공 시 UI에서 완전히 제거
|
||||||
setExternalTableData((prev) => {
|
setExternalTableData((prev) => {
|
||||||
const newData = {
|
const newData = {
|
||||||
...prev,
|
...prev,
|
||||||
[key]: (prev[key] || []).map((row) =>
|
[key]: prev[key].filter((row) => row._rowId !== rowId),
|
||||||
row._rowId === rowId
|
|
||||||
? { ...row, _isDeleted: true, _isDirty: true }
|
|
||||||
: row
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 🆕 v3.5: 행 삭제 시 집계 재계산 (삭제된 행 제외)
|
// 행 삭제 시 집계 재계산
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
recalculateAggregationsWithExternalData(newData);
|
recalculateAggregationsWithExternalData(newData);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[RepeatScreenModal] DELETE 실패:`, error.response?.data || error.message);
|
||||||
|
// 에러 시에도 다이얼로그 닫기
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 새로 추가된 행 (아직 DB에 없음) - UI에서만 제거
|
||||||
|
console.log(`[RepeatScreenModal] 새 행 삭제 (DB 없음): rowId=${rowId}`);
|
||||||
|
setExternalTableData((prev) => {
|
||||||
|
const newData = {
|
||||||
|
...prev,
|
||||||
|
[key]: prev[key].filter((row) => row._rowId !== rowId),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 행 삭제 시 집계 재계산
|
||||||
|
setTimeout(() => {
|
||||||
|
recalculateAggregationsWithExternalData(newData);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setDeleteConfirmOpen(false);
|
setDeleteConfirmOpen(false);
|
||||||
setPendingDeleteInfo(null);
|
setPendingDeleteInfo(null);
|
||||||
};
|
};
|
||||||
|
|
@ -1323,8 +1513,13 @@ export function RepeatScreenModalComponent({
|
||||||
for (const fn of extAggFunctions) {
|
for (const fn of extAggFunctions) {
|
||||||
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
|
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
|
||||||
expression = expression.replace(regex, (match, fieldName) => {
|
expression = expression.replace(regex, (match, fieldName) => {
|
||||||
if (!externalRows || externalRows.length === 0) return "0";
|
if (!externalRows || externalRows.length === 0) {
|
||||||
|
console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`);
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
const values = externalRows.map((row) => Number(row[fieldName]) || 0);
|
const values = externalRows.map((row) => Number(row[fieldName]) || 0);
|
||||||
|
const sum = values.reduce((a, b) => a + b, 0);
|
||||||
|
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
|
||||||
const baseFn = fn.replace("_EXT", "");
|
const baseFn = fn.replace("_EXT", "");
|
||||||
switch (baseFn) {
|
switch (baseFn) {
|
||||||
case "SUM":
|
case "SUM":
|
||||||
|
|
@ -1525,6 +1720,9 @@ export function RepeatScreenModalComponent({
|
||||||
// 🆕 v3.1: 외부 테이블 데이터 저장
|
// 🆕 v3.1: 외부 테이블 데이터 저장
|
||||||
await saveExternalTableData();
|
await saveExternalTableData();
|
||||||
|
|
||||||
|
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
||||||
|
await processSyncSaves();
|
||||||
|
|
||||||
alert("저장되었습니다.");
|
alert("저장되었습니다.");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("저장 실패:", error);
|
console.error("저장 실패:", error);
|
||||||
|
|
@ -1582,6 +1780,102 @@ export function RepeatScreenModalComponent({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
||||||
|
const processSyncSaves = async () => {
|
||||||
|
const syncPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// contentRows에서 syncSaves가 설정된 테이블 행 찾기
|
||||||
|
for (const contentRow of contentRows) {
|
||||||
|
if (contentRow.type !== "table") continue;
|
||||||
|
if (!contentRow.tableCrud?.syncSaves?.length) continue;
|
||||||
|
|
||||||
|
const sourceTable = contentRow.tableDataSource?.sourceTable;
|
||||||
|
if (!sourceTable) continue;
|
||||||
|
|
||||||
|
// 이 테이블 행의 모든 카드 데이터 수집
|
||||||
|
for (const card of groupedCardsData) {
|
||||||
|
const key = `${card._cardId}-${contentRow.id}`;
|
||||||
|
const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted);
|
||||||
|
|
||||||
|
// 각 syncSave 설정 처리
|
||||||
|
for (const syncSave of contentRow.tableCrud.syncSaves) {
|
||||||
|
if (!syncSave.enabled) continue;
|
||||||
|
if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue;
|
||||||
|
|
||||||
|
// 조인 키 값 수집 (중복 제거)
|
||||||
|
const joinKeyValues = new Set<string | number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const keyValue = row[syncSave.joinKey.sourceField];
|
||||||
|
if (keyValue !== undefined && keyValue !== null) {
|
||||||
|
joinKeyValues.add(keyValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 조인 키별로 집계 계산 및 업데이트
|
||||||
|
for (const keyValue of joinKeyValues) {
|
||||||
|
// 해당 조인 키에 해당하는 행들만 필터링
|
||||||
|
const filteredRows = rows.filter(
|
||||||
|
(row) => row[syncSave.joinKey.sourceField] === keyValue
|
||||||
|
);
|
||||||
|
|
||||||
|
// 집계 계산
|
||||||
|
let aggregatedValue: number = 0;
|
||||||
|
const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0);
|
||||||
|
|
||||||
|
switch (syncSave.aggregationType) {
|
||||||
|
case "sum":
|
||||||
|
aggregatedValue = values.reduce((a, b) => a + b, 0);
|
||||||
|
break;
|
||||||
|
case "count":
|
||||||
|
aggregatedValue = values.length;
|
||||||
|
break;
|
||||||
|
case "avg":
|
||||||
|
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
|
||||||
|
break;
|
||||||
|
case "min":
|
||||||
|
aggregatedValue = values.length > 0 ? Math.min(...values) : 0;
|
||||||
|
break;
|
||||||
|
case "max":
|
||||||
|
aggregatedValue = values.length > 0 ? Math.max(...values) : 0;
|
||||||
|
break;
|
||||||
|
case "latest":
|
||||||
|
aggregatedValue = values.length > 0 ? values[values.length - 1] : 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn} → ${syncSave.targetTable}.${syncSave.targetColumn}`, {
|
||||||
|
joinKey: keyValue,
|
||||||
|
aggregationType: syncSave.aggregationType,
|
||||||
|
values,
|
||||||
|
aggregatedValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 대상 테이블 업데이트
|
||||||
|
syncPromises.push(
|
||||||
|
apiClient
|
||||||
|
.put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, {
|
||||||
|
[syncSave.targetColumn]: aggregatedValue,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(`[SyncSave] 업데이트 실패:`, err);
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncPromises.length > 0) {
|
||||||
|
console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`);
|
||||||
|
await Promise.all(syncPromises);
|
||||||
|
console.log(`[SyncSave] 연동 저장 완료`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 🆕 v3.1: Footer 버튼 클릭 핸들러
|
// 🆕 v3.1: Footer 버튼 클릭 핸들러
|
||||||
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
|
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
|
||||||
switch (btn.action) {
|
switch (btn.action) {
|
||||||
|
|
@ -1928,27 +2222,10 @@ export function RepeatScreenModalComponent({
|
||||||
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
|
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
|
{/* 테이블 헤더 영역: 제목 + 버튼들 */}
|
||||||
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && (
|
{(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
|
||||||
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
|
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
|
||||||
<span>{contentRow.tableTitle || ""}</span>
|
<span>{contentRow.tableTitle || ""}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
|
|
||||||
{contentRow.tableCrud?.allowSave && (
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
|
|
||||||
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
|
|
||||||
className="h-7 text-xs gap-1"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
{contentRow.tableCrud?.saveButtonLabel || "저장"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{/* 추가 버튼 */}
|
{/* 추가 버튼 */}
|
||||||
{contentRow.tableCrud?.allowCreate && (
|
{contentRow.tableCrud?.allowCreate && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1968,7 +2245,8 @@ export function RepeatScreenModalComponent({
|
||||||
{contentRow.showTableHeader !== false && (
|
{contentRow.showTableHeader !== false && (
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
{(contentRow.tableColumns || []).map((col) => (
|
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||||
|
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={col.id}
|
key={col.id}
|
||||||
style={{ width: col.width }}
|
style={{ width: col.width }}
|
||||||
|
|
@ -1987,7 +2265,7 @@ export function RepeatScreenModalComponent({
|
||||||
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
|
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={(contentRow.tableColumns?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
|
colSpan={(contentRow.tableColumns?.filter(col => !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
|
|
@ -2003,7 +2281,8 @@ export function RepeatScreenModalComponent({
|
||||||
row._isDeleted && "bg-destructive/10 opacity-60"
|
row._isDeleted && "bg-destructive/10 opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{(contentRow.tableColumns || []).map((col) => (
|
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||||
|
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={`${row._rowId}-${col.id}`}
|
key={`${row._rowId}-${col.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -188,10 +188,6 @@ export interface TableCrudConfig {
|
||||||
allowUpdate: boolean; // 행 수정 허용
|
allowUpdate: boolean; // 행 수정 허용
|
||||||
allowDelete: boolean; // 행 삭제 허용
|
allowDelete: boolean; // 행 삭제 허용
|
||||||
|
|
||||||
// 🆕 v3.5: 테이블 영역 저장 버튼
|
|
||||||
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
|
|
||||||
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
|
|
||||||
|
|
||||||
// 신규 행 기본값
|
// 신규 행 기본값
|
||||||
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
|
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
|
||||||
|
|
||||||
|
|
@ -203,6 +199,54 @@ export interface TableCrudConfig {
|
||||||
|
|
||||||
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
|
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
|
||||||
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
|
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
|
||||||
|
|
||||||
|
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
|
||||||
|
syncSaves?: SyncSaveConfig[];
|
||||||
|
|
||||||
|
// 🆕 v3.13: 행 추가 시 자동 채번 설정
|
||||||
|
rowNumbering?: RowNumberingConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.13: 테이블 행 채번 설정
|
||||||
|
* "추가" 버튼 클릭 시 특정 컬럼에 자동으로 번호를 생성
|
||||||
|
*
|
||||||
|
* 사용 예시:
|
||||||
|
* - 출하계획번호(shipment_plan_no) 자동 생성
|
||||||
|
* - 송장번호(invoice_no) 자동 생성
|
||||||
|
* - 작업지시번호(work_order_no) 자동 생성
|
||||||
|
*
|
||||||
|
* 참고: 채번 후 읽기 전용 여부는 테이블 컬럼의 "수정 가능" 설정으로 제어
|
||||||
|
*/
|
||||||
|
export interface RowNumberingConfig {
|
||||||
|
enabled: boolean; // 채번 사용 여부
|
||||||
|
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
|
||||||
|
|
||||||
|
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
|
||||||
|
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3.12: 연동 저장 설정
|
||||||
|
* 테이블 데이터 저장 시 다른 테이블의 특정 컬럼에 집계 값을 동기화
|
||||||
|
*/
|
||||||
|
export interface SyncSaveConfig {
|
||||||
|
id: string; // 고유 ID
|
||||||
|
enabled: boolean; // 활성화 여부
|
||||||
|
|
||||||
|
// 소스 설정 (이 테이블에서)
|
||||||
|
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
|
||||||
|
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
|
||||||
|
|
||||||
|
// 대상 설정 (저장할 테이블)
|
||||||
|
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
|
||||||
|
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
|
||||||
|
|
||||||
|
// 조인 키 (어떤 레코드를 업데이트할지)
|
||||||
|
joinKey: {
|
||||||
|
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
|
||||||
|
targetField: string; // 대상 테이블의 키 (예: "id")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -285,10 +329,19 @@ export interface AggregationConfig {
|
||||||
// - 산술 연산: +, -, *, /, ()
|
// - 산술 연산: +, -, *, /, ()
|
||||||
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
|
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
|
||||||
|
|
||||||
|
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
|
||||||
|
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
|
||||||
|
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
|
||||||
|
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
|
||||||
|
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
|
||||||
|
|
||||||
// === 공통 ===
|
// === 공통 ===
|
||||||
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||||
label: string; // 표시 라벨 (예: "총수주잔량")
|
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||||
|
|
||||||
|
// === 🆕 v3.10: 숨김 설정 ===
|
||||||
|
hidden?: boolean; // 레이아웃에서 숨김 (연산에만 사용, 기본: false)
|
||||||
|
|
||||||
// === 🆕 v3.9: 저장 설정 ===
|
// === 🆕 v3.9: 저장 설정 ===
|
||||||
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
|
saveConfig?: AggregationSaveConfig; // 연관 테이블 저장 설정
|
||||||
}
|
}
|
||||||
|
|
@ -337,6 +390,9 @@ export interface TableColumnConfig {
|
||||||
editable: boolean; // 편집 가능 여부
|
editable: boolean; // 편집 가능 여부
|
||||||
required?: boolean; // 필수 입력 여부
|
required?: boolean; // 필수 입력 여부
|
||||||
|
|
||||||
|
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
|
||||||
|
hidden?: boolean; // 숨김 여부
|
||||||
|
|
||||||
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
||||||
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
||||||
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
||||||
|
|
|
||||||
|
|
@ -284,15 +284,91 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}));
|
}));
|
||||||
}, [leftData, leftGrouping]);
|
}, [leftData, leftGrouping]);
|
||||||
|
|
||||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리)
|
// 날짜 포맷팅 헬퍼 함수
|
||||||
|
const formatDateValue = useCallback((value: any, dateFormat: string): string => {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (isNaN(date.getTime())) return String(value);
|
||||||
|
|
||||||
|
if (dateFormat === "relative") {
|
||||||
|
// 상대 시간 (예: 3일 전, 2시간 전)
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
const diffMonth = Math.floor(diffDay / 30);
|
||||||
|
const diffYear = Math.floor(diffMonth / 12);
|
||||||
|
|
||||||
|
if (diffYear > 0) return `${diffYear}년 전`;
|
||||||
|
if (diffMonth > 0) return `${diffMonth}개월 전`;
|
||||||
|
if (diffDay > 0) return `${diffDay}일 전`;
|
||||||
|
if (diffHour > 0) return `${diffHour}시간 전`;
|
||||||
|
if (diffMin > 0) return `${diffMin}분 전`;
|
||||||
|
return "방금 전";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 포맷 문자열 치환
|
||||||
|
return dateFormat
|
||||||
|
.replace("YYYY", String(date.getFullYear()))
|
||||||
|
.replace("MM", String(date.getMonth() + 1).padStart(2, "0"))
|
||||||
|
.replace("DD", String(date.getDate()).padStart(2, "0"))
|
||||||
|
.replace("HH", String(date.getHours()).padStart(2, "0"))
|
||||||
|
.replace("mm", String(date.getMinutes()).padStart(2, "0"))
|
||||||
|
.replace("ss", String(date.getSeconds()).padStart(2, "0"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 숫자 포맷팅 헬퍼 함수
|
||||||
|
const formatNumberValue = useCallback((value: any, format: any): string => {
|
||||||
|
if (value === null || value === undefined || value === "") return "-";
|
||||||
|
const num = typeof value === "number" ? value : parseFloat(String(value));
|
||||||
|
if (isNaN(num)) return String(value);
|
||||||
|
|
||||||
|
const options: Intl.NumberFormatOptions = {
|
||||||
|
minimumFractionDigits: format?.decimalPlaces ?? 0,
|
||||||
|
maximumFractionDigits: format?.decimalPlaces ?? 10,
|
||||||
|
useGrouping: format?.thousandSeparator ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = num.toLocaleString("ko-KR", options);
|
||||||
|
if (format?.prefix) result = format.prefix + result;
|
||||||
|
if (format?.suffix) result = result + format.suffix;
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(
|
(
|
||||||
columnName: string,
|
columnName: string,
|
||||||
value: any,
|
value: any,
|
||||||
categoryMappings: Record<string, Record<string, { label: string; color?: string }>>,
|
categoryMappings: Record<string, Record<string, { label: string; color?: string }>>,
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text";
|
||||||
|
thousandSeparator?: boolean;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
dateFormat?: string;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 🆕 날짜 포맷 적용
|
||||||
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 숫자 포맷 적용
|
||||||
|
if (
|
||||||
|
format?.type === "number" ||
|
||||||
|
format?.type === "currency" ||
|
||||||
|
format?.thousandSeparator ||
|
||||||
|
format?.decimalPlaces !== undefined
|
||||||
|
) {
|
||||||
|
return formatNumberValue(value, format);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
|
// 🆕 카테고리 매핑 찾기 (여러 키 형태 시도)
|
||||||
// 1. 전체 컬럼명 (예: "item_info.material")
|
// 1. 전체 컬럼명 (예: "item_info.material")
|
||||||
// 2. 컬럼명만 (예: "material")
|
// 2. 컬럼명만 (예: "material")
|
||||||
|
|
@ -323,10 +399,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
|
||||||
|
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
|
||||||
|
return formatDateValue(value, "YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 자동 숫자 감지 (숫자 또는 숫자 문자열) - 소수점 있으면 정수로 변환
|
||||||
|
if (typeof value === "number") {
|
||||||
|
// 숫자인 경우 정수로 표시 (소수점 제거)
|
||||||
|
return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && /^-?\d+\.?\d*$/.test(value.trim())) {
|
||||||
|
// 숫자 문자열인 경우 (예: "5.00" → "5")
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
return Number.isInteger(num) ? String(num) : String(Math.round(num * 100) / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 일반 값
|
// 일반 값
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
[],
|
[formatDateValue, formatNumberValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
|
|
@ -405,9 +499,60 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setRightData(detail);
|
setRightData(detail);
|
||||||
} else if (relationshipType === "join") {
|
} else if (relationshipType === "join") {
|
||||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||||
|
const keys = componentConfig.rightPanel?.relation?.keys;
|
||||||
|
const leftTable = componentConfig.leftPanel?.tableName;
|
||||||
|
|
||||||
|
// 🆕 복합키 지원
|
||||||
|
if (keys && keys.length > 0 && leftTable) {
|
||||||
|
// 복합키: 여러 조건으로 필터링
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
|
// 복합키 조건 생성
|
||||||
|
const searchConditions: Record<string, any> = {};
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
|
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
||||||
|
|
||||||
|
// 엔티티 조인 API로 데이터 조회
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
search: searchConditions,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||||
|
|
||||||
|
// 추가 dataFilter 적용
|
||||||
|
let filteredData = result.data || [];
|
||||||
|
const dataFilter = componentConfig.rightPanel?.dataFilter;
|
||||||
|
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||||
|
filteredData = filteredData.filter((item: any) => {
|
||||||
|
return dataFilter.conditions.every((cond: any) => {
|
||||||
|
const value = item[cond.column];
|
||||||
|
const condValue = cond.value;
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals":
|
||||||
|
return value === condValue;
|
||||||
|
case "notEquals":
|
||||||
|
return value !== condValue;
|
||||||
|
case "contains":
|
||||||
|
return String(value).includes(String(condValue));
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRightData(filteredData);
|
||||||
|
} else {
|
||||||
|
// 단일키 (하위 호환성)
|
||||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||||
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
||||||
const leftTable = componentConfig.leftPanel?.tableName;
|
|
||||||
|
|
||||||
if (leftColumn && rightColumn && leftTable) {
|
if (leftColumn && rightColumn && leftTable) {
|
||||||
const leftValue = leftItem[leftColumn];
|
const leftValue = leftItem[leftColumn];
|
||||||
|
|
@ -425,6 +570,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("우측 데이터 로드 실패:", error);
|
console.error("우측 데이터 로드 실패:", error);
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -840,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
primaryKeyValue = item[firstKey];
|
primaryKeyValue = item[firstKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ 수정 모달 열기:`, {
|
console.log("✅ 수정 모달 열기:", {
|
||||||
tableName: rightTableName,
|
tableName: rightTableName,
|
||||||
primaryKeyName,
|
primaryKeyName,
|
||||||
primaryKeyValue,
|
primaryKeyValue,
|
||||||
|
|
@ -1527,6 +1673,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName,
|
leftColumnLabels[colName] || (typeof col === "object" ? col.label : null) || colName,
|
||||||
width: typeof col === "object" ? col.width : 150,
|
width: typeof col === "object" ? col.width : 150,
|
||||||
align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right",
|
align: (typeof col === "object" ? col.align : "left") as "left" | "center" | "right",
|
||||||
|
format: typeof col === "object" ? col.format : undefined, // 🆕 포맷 설정 포함
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: Object.keys(filteredData[0] || {})
|
: Object.keys(filteredData[0] || {})
|
||||||
|
|
@ -1537,6 +1684,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
label: leftColumnLabels[key] || key,
|
label: leftColumnLabels[key] || key,
|
||||||
width: 150,
|
width: 150,
|
||||||
align: "left" as const,
|
align: "left" as const,
|
||||||
|
format: undefined, // 🆕 기본값
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 🔧 그룹화된 데이터 렌더링
|
// 🔧 그룹화된 데이터 렌더링
|
||||||
|
|
@ -1587,7 +1735,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(col.name, item[col.name], leftCategoryMappings)}
|
{formatCellValue(
|
||||||
|
col.name,
|
||||||
|
item[col.name],
|
||||||
|
leftCategoryMappings,
|
||||||
|
col.format,
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1643,7 +1796,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(col.name, item[col.name], leftCategoryMappings)}
|
{formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1747,7 +1900,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
} else {
|
} else {
|
||||||
// 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시
|
// 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시
|
||||||
const keys = Object.keys(item).filter(
|
const keys = Object.keys(item).filter(
|
||||||
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k)
|
(k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k),
|
||||||
);
|
);
|
||||||
displayFields = keys.slice(0, 2).map((key) => ({
|
displayFields = keys.slice(0, 2).map((key) => ({
|
||||||
label: leftColumnLabels[key] || key,
|
label: leftColumnLabels[key] || key,
|
||||||
|
|
@ -1960,6 +2113,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
? displayColumns.map((col) => ({
|
? displayColumns.map((col) => ({
|
||||||
...col,
|
...col,
|
||||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||||
|
format: col.format, // 🆕 포맷 설정 유지
|
||||||
}))
|
}))
|
||||||
: Object.keys(filteredData[0] || {})
|
: Object.keys(filteredData[0] || {})
|
||||||
.filter((key) => shouldShowField(key))
|
.filter((key) => shouldShowField(key))
|
||||||
|
|
@ -1969,6 +2123,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
label: rightColumnLabels[key] || key,
|
label: rightColumnLabels[key] || key,
|
||||||
width: 150,
|
width: 150,
|
||||||
align: "left" as const,
|
align: "left" as const,
|
||||||
|
format: undefined, // 🆕 기본값
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -2014,7 +2169,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(col.name, item[col.name], rightCategoryMappings)}
|
{formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
|
|
@ -2022,7 +2177,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||||
<Button
|
<Button
|
||||||
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
variant={
|
||||||
|
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -2030,20 +2187,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}}
|
}}
|
||||||
className="h-7"
|
className="h-7"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3 mr-1" />
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteClick("right", item);
|
handleDeleteClick("right", item);
|
||||||
}}
|
}}
|
||||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||||
title="삭제"
|
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-600" />
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2083,26 +2242,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
|
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
|
||||||
let value = item[col.name];
|
let value = item[col.name];
|
||||||
if (value === undefined && col.name.includes('.')) {
|
if (value === undefined && col.name.includes(".")) {
|
||||||
const columnName = col.name.split('.').pop();
|
const columnName = col.name.split(".").pop();
|
||||||
// 1차: 컬럼명 그대로 (예: item_number)
|
// 1차: 컬럼명 그대로 (예: item_number)
|
||||||
value = item[columnName || ''];
|
value = item[columnName || ""];
|
||||||
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
|
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
const parts = col.name.split('.');
|
const parts = col.name.split(".");
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
const refTable = parts[0]; // item_info
|
const refTable = parts[0]; // item_info
|
||||||
const refColumn = parts[1]; // item_number 또는 item_name
|
const refColumn = parts[1]; // item_number 또는 item_name
|
||||||
// FK 컬럼명 추론: item_info → item_id
|
// FK 컬럼명 추론: item_info → item_id
|
||||||
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
|
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
|
||||||
|
|
||||||
// 백엔드에서 반환하는 별칭 패턴:
|
// 백엔드에서 반환하는 별칭 패턴:
|
||||||
// 1) item_id_name (기본 referenceColumn)
|
// 1) item_id_name (기본 referenceColumn)
|
||||||
// 2) item_id_item_name (추가 컬럼)
|
// 2) item_id_item_name (추가 컬럼)
|
||||||
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
|
if (
|
||||||
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
|
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
|
||||||
|
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
|
||||||
|
) {
|
||||||
// 기본 참조 컬럼 (item_number, customer_code 등)
|
// 기본 참조 컬럼 (item_number, customer_code 등)
|
||||||
const aliasKey = fkColumn + '_name';
|
const aliasKey = fkColumn + "_name";
|
||||||
value = item[aliasKey];
|
value = item[aliasKey];
|
||||||
} else {
|
} else {
|
||||||
// 추가 컬럼 (item_name, customer_name 등)
|
// 추가 컬럼 (item_name, customer_name 등)
|
||||||
|
|
@ -2120,26 +2281,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
// 🆕 엔티티 조인 컬럼 처리
|
// 🆕 엔티티 조인 컬럼 처리
|
||||||
let value = item[col.name];
|
let value = item[col.name];
|
||||||
if (value === undefined && col.name.includes('.')) {
|
if (value === undefined && col.name.includes(".")) {
|
||||||
const columnName = col.name.split('.').pop();
|
const columnName = col.name.split(".").pop();
|
||||||
// 1차: 컬럼명 그대로
|
// 1차: 컬럼명 그대로
|
||||||
value = item[columnName || ''];
|
value = item[columnName || ""];
|
||||||
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
|
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
const parts = col.name.split('.');
|
const parts = col.name.split(".");
|
||||||
if (parts.length === 2) {
|
if (parts.length === 2) {
|
||||||
const refTable = parts[0]; // item_info
|
const refTable = parts[0]; // item_info
|
||||||
const refColumn = parts[1]; // item_number 또는 item_name
|
const refColumn = parts[1]; // item_number 또는 item_name
|
||||||
// FK 컬럼명 추론: item_info → item_id
|
// FK 컬럼명 추론: item_info → item_id
|
||||||
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
|
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
|
||||||
|
|
||||||
// 백엔드에서 반환하는 별칭 패턴:
|
// 백엔드에서 반환하는 별칭 패턴:
|
||||||
// 1) item_id_name (기본 referenceColumn)
|
// 1) item_id_name (기본 referenceColumn)
|
||||||
// 2) item_id_item_name (추가 컬럼)
|
// 2) item_id_item_name (추가 컬럼)
|
||||||
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
|
if (
|
||||||
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
|
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
|
||||||
|
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
|
||||||
|
) {
|
||||||
// 기본 참조 컬럼
|
// 기본 참조 컬럼
|
||||||
const aliasKey = fkColumn + '_name';
|
const aliasKey = fkColumn + "_name";
|
||||||
value = item[aliasKey];
|
value = item[aliasKey];
|
||||||
} else {
|
} else {
|
||||||
// 추가 컬럼
|
// 추가 컬럼
|
||||||
|
|
@ -2158,11 +2321,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
firstValues = Object.entries(item)
|
firstValues = Object.entries(item)
|
||||||
.filter(([key]) => !key.toLowerCase().includes("id"))
|
.filter(([key]) => !key.toLowerCase().includes("id"))
|
||||||
.slice(0, summaryCount)
|
.slice(0, summaryCount)
|
||||||
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||||
|
|
||||||
allValues = Object.entries(item)
|
allValues = Object.entries(item)
|
||||||
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
||||||
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -2180,27 +2343,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
{firstValues.map(([key, value, label], idx) => {
|
{firstValues.map(([key, value, label], idx) => {
|
||||||
// 포맷 설정 및 볼드 설정 찾기
|
// 포맷 설정 및 볼드 설정 찾기
|
||||||
const colConfig = rightColumns?.find(c => c.name === key);
|
const colConfig = rightColumns?.find((c) => c.name === key);
|
||||||
const format = colConfig?.format;
|
const format = colConfig?.format;
|
||||||
const boldValue = colConfig?.bold ?? false;
|
const boldValue = colConfig?.bold ?? false;
|
||||||
|
|
||||||
// 🆕 카테고리 매핑 적용
|
// 🆕 포맷 적용 (날짜/숫자/카테고리)
|
||||||
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
|
const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
|
||||||
|
|
||||||
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
|
|
||||||
let displayValue: React.ReactNode = formattedValue;
|
|
||||||
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
|
|
||||||
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
displayValue = numValue.toLocaleString('ko-KR', {
|
|
||||||
minimumFractionDigits: format.decimalPlaces ?? 0,
|
|
||||||
maximumFractionDigits: format.decimalPlaces ?? 10,
|
|
||||||
useGrouping: format.thousandSeparator ?? false,
|
|
||||||
});
|
|
||||||
if (format.prefix) displayValue = format.prefix + displayValue;
|
|
||||||
if (format.suffix) displayValue = displayValue + format.suffix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
|
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
|
||||||
|
|
||||||
|
|
@ -2212,7 +2360,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
|
className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2233,19 +2381,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}}
|
}}
|
||||||
className="h-7"
|
className="h-7"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3 w-3 mr-1" />
|
<Pencil className="mr-1 h-3 w-3" />
|
||||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDeleteClick("right", item);
|
handleDeleteClick("right", item);
|
||||||
}}
|
}}
|
||||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||||
title="삭제"
|
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-red-600" />
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2274,26 +2422,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<tbody className="divide-border divide-y">
|
<tbody className="divide-border divide-y">
|
||||||
{allValues.map(([key, value, label]) => {
|
{allValues.map(([key, value, label]) => {
|
||||||
// 포맷 설정 찾기
|
// 포맷 설정 찾기
|
||||||
const colConfig = rightColumns?.find(c => c.name === key);
|
const colConfig = rightColumns?.find((c) => c.name === key);
|
||||||
const format = colConfig?.format;
|
const format = colConfig?.format;
|
||||||
|
|
||||||
// 🆕 카테고리 매핑 적용
|
// 🆕 포맷 적용 (날짜/숫자/카테고리)
|
||||||
const formattedValue = formatCellValue(key, value, rightCategoryMappings);
|
const displayValue = formatCellValue(key, value, rightCategoryMappings, format);
|
||||||
|
|
||||||
// 숫자 포맷 적용 (카테고리가 아닌 경우만)
|
|
||||||
let displayValue: React.ReactNode = formattedValue;
|
|
||||||
if (typeof formattedValue === 'string' && value !== null && value !== undefined && value !== "" && format) {
|
|
||||||
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
displayValue = numValue.toLocaleString('ko-KR', {
|
|
||||||
minimumFractionDigits: format.decimalPlaces ?? 0,
|
|
||||||
maximumFractionDigits: format.decimalPlaces ?? 10,
|
|
||||||
useGrouping: format.thousandSeparator ?? false,
|
|
||||||
});
|
|
||||||
if (format.prefix) displayValue = format.prefix + displayValue;
|
|
||||||
if (format.suffix) displayValue = displayValue + format.suffix;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={key} className="hover:bg-muted">
|
<tr key={key} className="hover:bg-muted">
|
||||||
|
|
@ -2336,7 +2469,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
console.log("🔍 [디버깅] 상세 모드 표시 로직:");
|
console.log("🔍 [디버깅] 상세 모드 표시 로직:");
|
||||||
console.log(" 📋 rightData 전체:", rightData);
|
console.log(" 📋 rightData 전체:", rightData);
|
||||||
console.log(" 📋 rightData keys:", Object.keys(rightData));
|
console.log(" 📋 rightData keys:", Object.keys(rightData));
|
||||||
console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`));
|
console.log(
|
||||||
|
" ⚙️ 설정된 컬럼:",
|
||||||
|
rightColumns.map((c) => `${c.name} (${c.label})`),
|
||||||
|
);
|
||||||
|
|
||||||
// 설정된 컬럼만 표시
|
// 설정된 컬럼만 표시
|
||||||
displayEntries = rightColumns
|
displayEntries = rightColumns
|
||||||
|
|
@ -2345,9 +2481,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
let value = rightData[col.name];
|
let value = rightData[col.name];
|
||||||
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
|
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
|
||||||
|
|
||||||
if (value === undefined && col.name.includes('.')) {
|
if (value === undefined && col.name.includes(".")) {
|
||||||
const columnName = col.name.split('.').pop();
|
const columnName = col.name.split(".").pop();
|
||||||
value = rightData[columnName || ''];
|
value = rightData[columnName || ""];
|
||||||
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
|
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -104,10 +104,15 @@ export interface SplitPanelLayoutConfig {
|
||||||
|
|
||||||
// 좌측 선택 항목과의 관계 설정
|
// 좌측 선택 항목과의 관계 설정
|
||||||
relation?: {
|
relation?: {
|
||||||
type: "join" | "detail"; // 관계 타입
|
type?: "join" | "detail"; // 관계 타입 (optional - 하위 호환성)
|
||||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼
|
leftColumn?: string; // 좌측 테이블의 연결 컬럼 (단일키 - 하위 호환성)
|
||||||
rightColumn?: string; // 우측 테이블의 연결 컬럼 (join용)
|
rightColumn?: string; // 우측 테이블의 연결 컬럼 (단일키 - 하위 호환성)
|
||||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||||
|
// 🆕 복합키 지원 (여러 컬럼으로 조인)
|
||||||
|
keys?: Array<{
|
||||||
|
leftColumn: string; // 좌측 테이블의 조인 컬럼
|
||||||
|
rightColumn: string; // 우측 테이블의 조인 컬럼
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
|
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
|
||||||
|
|
@ -150,6 +155,14 @@ export interface SplitPanelLayoutConfig {
|
||||||
buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline")
|
buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline")
|
||||||
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
|
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 삭제 버튼 설정
|
||||||
|
deleteButton?: {
|
||||||
|
enabled: boolean; // 삭제 버튼 표시 여부 (기본: true)
|
||||||
|
buttonLabel?: string; // 버튼 라벨 (기본: "삭제")
|
||||||
|
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
|
||||||
|
confirmMessage?: string; // 삭제 확인 메시지
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen";
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
@ -113,22 +113,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
// 채번 규칙은 비동기로 처리
|
// 채번 규칙은 비동기로 처리
|
||||||
if (testAutoGeneration.type === "numbering_rule") {
|
if (testAutoGeneration.type === "numbering_rule") {
|
||||||
const ruleId = testAutoGeneration.options?.numberingRuleId;
|
const ruleId = testAutoGeneration.options?.numberingRuleId;
|
||||||
if (ruleId) {
|
if (ruleId && ruleId !== "undefined" && ruleId !== "null") {
|
||||||
try {
|
try {
|
||||||
console.log("🚀 채번 규칙 API 호출 시작:", ruleId);
|
const { previewNumberingCode } = await import("@/lib/api/numberingRule");
|
||||||
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
|
const response = await previewNumberingCode(ruleId);
|
||||||
const response = await generateNumberingCode(ruleId);
|
|
||||||
console.log("✅ 채번 규칙 API 응답:", response);
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
generatedValue = response.data.generatedCode;
|
generatedValue = response.data.generatedCode;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
// 실패 시 조용히 무시 (채번 규칙이 없어도 화면은 정상 로드)
|
||||||
console.error("❌ 채번 규칙 코드 생성 실패:", error);
|
} catch {
|
||||||
|
// 네트워크 에러 등 예외 상황은 조용히 무시
|
||||||
} finally {
|
} finally {
|
||||||
isGeneratingRef.current = false; // 생성 완료
|
isGeneratingRef.current = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 채번 규칙 ID가 없습니다");
|
|
||||||
isGeneratingRef.current = false;
|
isGeneratingRef.current = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,55 @@ export function UniversalFormModalComponent({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
// 🆕 beforeFormSave 이벤트 리스너 - ButtonPrimary 저장 시 formData를 전달
|
||||||
|
// 설정된 필드(columnName)만 병합하여 의도치 않은 덮어쓰기 방지
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeFormSave = (event: Event) => {
|
||||||
|
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||||
|
|
||||||
|
// 설정에 정의된 필드 columnName 목록 수집
|
||||||
|
const configuredFields = new Set<string>();
|
||||||
|
config.sections.forEach((section) => {
|
||||||
|
section.fields.forEach((field) => {
|
||||||
|
if (field.columnName) {
|
||||||
|
configuredFields.add(field.columnName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
|
||||||
|
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
|
||||||
|
|
||||||
|
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
|
||||||
|
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
|
||||||
|
// (UniversalFormModal이 해당 필드의 주인이므로)
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
// 설정에 정의된 필드만 병합
|
||||||
|
if (configuredFields.has(key)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
event.detail.formData[key] = value;
|
||||||
|
console.log(`[UniversalFormModal] 필드 병합: ${key} =`, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 반복 섹션 데이터도 병합 (필요한 경우)
|
||||||
|
if (Object.keys(repeatSections).length > 0) {
|
||||||
|
for (const [sectionId, items] of Object.entries(repeatSections)) {
|
||||||
|
const sectionKey = `_repeatSection_${sectionId}`;
|
||||||
|
event.detail.formData[sectionKey] = items;
|
||||||
|
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
};
|
||||||
|
}, [formData, repeatSections, config.sections]);
|
||||||
|
|
||||||
// 필드 레벨 linkedFieldGroup 데이터 로드
|
// 필드 레벨 linkedFieldGroup 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|
@ -1134,7 +1183,7 @@ export function UniversalFormModalComponent({
|
||||||
}}
|
}}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={fieldKey} className="w-full">
|
<SelectTrigger id={fieldKey} className="w-full" size="default">
|
||||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -1513,7 +1562,8 @@ export function UniversalFormModalComponent({
|
||||||
{/* 섹션들 */}
|
{/* 섹션들 */}
|
||||||
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
|
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
|
||||||
|
|
||||||
{/* 버튼 영역 */}
|
{/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
|
||||||
|
{config.modal.showSaveButton !== false && (
|
||||||
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
|
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||||
{config.modal.showResetButton && (
|
{config.modal.showResetButton && (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1530,18 +1580,6 @@ export function UniversalFormModalComponent({
|
||||||
{config.modal.resetButtonText || "초기화"}
|
{config.modal.resetButtonText || "초기화"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onCancel?.();
|
|
||||||
}}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{config.modal.cancelButtonText || "취소"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -1554,6 +1592,7 @@ export function UniversalFormModalComponent({
|
||||||
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
|
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
|
|
@ -1606,7 +1645,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
||||||
<SelectTrigger>
|
<SelectTrigger size="default">
|
||||||
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border rounded-md p-2 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-medium">저장 버튼 표시</span>
|
||||||
|
<Switch
|
||||||
|
checked={config.modal.showSaveButton !== false}
|
||||||
|
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HelpText>ButtonPrimary 컴포넌트로 저장 버튼을 별도 구성할 경우 끄세요</HelpText>
|
||||||
|
|
||||||
|
{config.modal.showSaveButton !== false && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">저장 버튼 텍스트</Label>
|
<Label className="text-[10px]">저장 버튼 텍스트</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -410,13 +421,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
className="h-7 text-xs mt-1"
|
className="h-7 text-xs mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<Label className="text-[10px]">취소 버튼 텍스트</Label>
|
|
||||||
<Input
|
|
||||||
value={config.modal.cancelButtonText || "취소"}
|
|
||||||
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
|
|
||||||
className="h-7 text-xs mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
@ -1896,7 +1901,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
<div className="space-y-2 pt-2 border-t">
|
<div className="space-y-2 pt-2 border-t">
|
||||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">참조 테이블 (옵션을 가져올 테이블)</Label>
|
<Label className="text-[10px]">참조 테이블</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedField.selectOptions?.tableName || ""}
|
value={selectedField.selectOptions?.tableName || ""}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -1908,7 +1913,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
<SelectTrigger className="h-7 text-xs mt-1">
|
||||||
<SelectValue placeholder="테이블 선택" />
|
<SelectValue placeholder="테이블 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -1919,10 +1924,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<HelpText>예: dept_info (부서 테이블)</HelpText>
|
<HelpText>드롭다운 목록을 가져올 테이블을 선택하세요</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">값 컬럼 (저장될 값)</Label>
|
<Label className="text-[10px]">조인할 컬럼</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedField.selectOptions?.valueColumn || ""}
|
value={selectedField.selectOptions?.valueColumn || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -1933,13 +1938,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="dept_code"
|
placeholder="customer_code"
|
||||||
className="h-6 text-[10px] mt-1"
|
className="h-7 text-xs mt-1"
|
||||||
/>
|
/>
|
||||||
<HelpText>선택 시 실제 저장되는 값 (예: D001)</HelpText>
|
<HelpText>
|
||||||
|
참조 테이블에서 조인할 컬럼을 선택하세요
|
||||||
|
<br />
|
||||||
|
예: customer_code, customer_id
|
||||||
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">라벨 컬럼 (화면에 표시될 텍스트)</Label>
|
<Label className="text-[10px]">표시할 컬럼</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedField.selectOptions?.labelColumn || ""}
|
value={selectedField.selectOptions?.labelColumn || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -1950,10 +1959,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="dept_name"
|
placeholder="customer_name"
|
||||||
className="h-6 text-[10px] mt-1"
|
className="h-7 text-xs mt-1"
|
||||||
/>
|
/>
|
||||||
<HelpText>드롭다운에 보여질 텍스트 (예: 영업부)</HelpText>
|
<HelpText>
|
||||||
|
드롭다운에 표시할 컬럼을 선택하세요
|
||||||
|
<br />
|
||||||
|
예: customer_name, company_name
|
||||||
|
</HelpText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const defaultConfig: UniversalFormModalConfig = {
|
||||||
size: "lg",
|
size: "lg",
|
||||||
closeOnOutsideClick: false,
|
closeOnOutsideClick: false,
|
||||||
showCloseButton: true,
|
showCloseButton: true,
|
||||||
|
showSaveButton: true,
|
||||||
saveButtonText: "저장",
|
saveButtonText: "저장",
|
||||||
cancelButtonText: "취소",
|
cancelButtonText: "취소",
|
||||||
showResetButton: false,
|
showResetButton: false,
|
||||||
|
|
|
||||||
|
|
@ -2651,6 +2651,55 @@ export class ButtonActionExecutor {
|
||||||
controlDataSource,
|
controlDataSource,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
|
||||||
|
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
|
||||||
|
if (hasFlowConfig) {
|
||||||
|
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
|
||||||
|
|
||||||
|
const { flowId } = config.dataflowConfig.flowConfig;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 노드 플로우 실행 API 호출
|
||||||
|
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||||||
|
|
||||||
|
// 데이터 소스 준비
|
||||||
|
let sourceData: any = context.formData || {};
|
||||||
|
|
||||||
|
// repeat-screen-modal 데이터가 있으면 병합
|
||||||
|
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
|
||||||
|
key.startsWith("_repeatScreenModal_"),
|
||||||
|
);
|
||||||
|
if (repeatScreenModalKeys.length > 0) {
|
||||||
|
console.log("📦 repeat-screen-modal 데이터 발견:", repeatScreenModalKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📦 노드 플로우에 전달할 데이터:", {
|
||||||
|
flowId,
|
||||||
|
dataSourceType: controlDataSource,
|
||||||
|
sourceData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeNodeFlow(flowId, {
|
||||||
|
dataSourceType: controlDataSource,
|
||||||
|
sourceData,
|
||||||
|
context: extendedContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("✅ 저장 후 노드 플로우 실행 완료:", result);
|
||||||
|
toast.success("제어 로직 실행이 완료되었습니다.");
|
||||||
|
} else {
|
||||||
|
console.error("❌ 저장 후 노드 플로우 실행 실패:", result);
|
||||||
|
toast.error("저장은 완료되었으나 제어 실행 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 저장 후 노드 플로우 실행 오류:", error);
|
||||||
|
toast.error(`제어 실행 오류: ${error.message || "알 수 없는 오류"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // 노드 플로우 실행 후 종료
|
||||||
|
}
|
||||||
|
|
||||||
// 관계 기반 제어 실행
|
// 관계 기반 제어 실행
|
||||||
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||||||
console.log("🔗 저장 후 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
|
console.log("🔗 저장 후 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,11 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tiptap/extension-placeholder": "^2.11.5",
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/extension-placeholder": "^2.27.1",
|
||||||
"@tiptap/pm": "^2.11.5",
|
"@tiptap/pm": "^2.11.5",
|
||||||
"@tiptap/react": "^2.11.5",
|
"@tiptap/react": "^2.27.1",
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
"@tiptap/starter-kit": "^2.27.1",
|
||||||
"@turf/buffer": "^7.2.0",
|
"@turf/buffer": "^7.2.0",
|
||||||
"@turf/helpers": "^7.2.0",
|
"@turf/helpers": "^7.2.0",
|
||||||
"@turf/intersect": "^7.2.0",
|
"@turf/intersect": "^7.2.0",
|
||||||
|
|
@ -3301,16 +3302,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/core": {
|
"node_modules/@tiptap/core": {
|
||||||
"version": "2.27.1",
|
"version": "3.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
|
||||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tiptap/pm": "^2.7.0"
|
"@tiptap/pm": "^3.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-blockquote": {
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
|
@ -3699,6 +3700,19 @@
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/core": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@tiptap/pm": "^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@turf/along": {
|
"node_modules/@turf/along": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
|
||||||
|
|
@ -6070,7 +6084,7 @@
|
||||||
"version": "20.19.24",
|
"version": "20.19.24",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||||
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
|
|
@ -6108,7 +6122,7 @@
|
||||||
"version": "19.2.2",
|
"version": "19.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
||||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
|
|
@ -12524,13 +12538,6 @@
|
||||||
"react-dom": ">=16"
|
"react-dom": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
|
||||||
"version": "19.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
|
|
||||||
"integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/react-leaflet": {
|
"node_modules/react-leaflet": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
|
@ -14190,7 +14197,7 @@
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@
|
||||||
"test:dataflow": "jest lib/services/__tests__/buttonDataflowPerformance.test.ts"
|
"test:dataflow": "jest lib/services/__tests__/buttonDataflowPerformance.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-placeholder": "^2.11.5",
|
|
||||||
"@tiptap/pm": "^2.11.5",
|
|
||||||
"@tiptap/react": "^2.11.5",
|
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -46,6 +42,11 @@
|
||||||
"@react-three/fiber": "^9.4.0",
|
"@react-three/fiber": "^9.4.0",
|
||||||
"@tanstack/react-query": "^5.86.0",
|
"@tanstack/react-query": "^5.86.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tiptap/core": "^3.13.0",
|
||||||
|
"@tiptap/extension-placeholder": "^2.27.1",
|
||||||
|
"@tiptap/pm": "^2.11.5",
|
||||||
|
"@tiptap/react": "^2.27.1",
|
||||||
|
"@tiptap/starter-kit": "^2.27.1",
|
||||||
"@turf/buffer": "^7.2.0",
|
"@turf/buffer": "^7.2.0",
|
||||||
"@turf/helpers": "^7.2.0",
|
"@turf/helpers": "^7.2.0",
|
||||||
"@turf/intersect": "^7.2.0",
|
"@turf/intersect": "^7.2.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue