Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/unified-components-renewal
This commit is contained in:
commit
a717f97b34
|
|
@ -2371,8 +2371,7 @@ export class MenuCopyService {
|
||||||
return { copiedCount, ruleIdMap };
|
return { copiedCount, ruleIdMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회
|
// 2. 대상 회사에 이미 존재하는 모든 채번 규칙 조회 (원본 ID + 새로 생성될 ID 모두 체크 필요)
|
||||||
const ruleIds = allRulesResult.rows.map((r) => r.rule_id);
|
|
||||||
const existingRulesResult = await client.query(
|
const existingRulesResult = await client.query(
|
||||||
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
|
`SELECT rule_id FROM numbering_rules WHERE company_code = $1`,
|
||||||
[targetCompanyCode]
|
[targetCompanyCode]
|
||||||
|
|
@ -2389,28 +2388,49 @@ export class MenuCopyService {
|
||||||
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
|
const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = [];
|
||||||
|
|
||||||
for (const rule of allRulesResult.rows) {
|
for (const rule of allRulesResult.rows) {
|
||||||
|
// 새 rule_id 계산: 회사코드 접두사 제거 후 대상 회사코드 추가
|
||||||
|
// 예: COMPANY_10_rule-123 -> rule-123 -> COMPANY_16_rule-123
|
||||||
|
// 예: rule-123 -> rule-123 -> COMPANY_16_rule-123
|
||||||
|
// 예: WACE_품목코드 -> 품목코드 -> COMPANY_16_품목코드
|
||||||
|
let baseName = rule.rule_id;
|
||||||
|
|
||||||
|
// 회사코드 접두사 패턴들을 순서대로 제거 시도
|
||||||
|
// 1. COMPANY_숫자_ 패턴 (예: COMPANY_10_)
|
||||||
|
// 2. 일반 접두사_ 패턴 (예: WACE_)
|
||||||
|
if (baseName.match(/^COMPANY_\d+_/)) {
|
||||||
|
baseName = baseName.replace(/^COMPANY_\d+_/, "");
|
||||||
|
} else if (baseName.includes("_")) {
|
||||||
|
baseName = baseName.replace(/^[^_]+_/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRuleId = `${targetCompanyCode}_${baseName}`;
|
||||||
|
|
||||||
if (existingRuleIds.has(rule.rule_id)) {
|
if (existingRuleIds.has(rule.rule_id)) {
|
||||||
// 기존 규칙은 동일한 ID로 매핑
|
// 원본 ID가 이미 존재 (동일한 ID로 매핑)
|
||||||
ruleIdMap.set(rule.rule_id, rule.rule_id);
|
ruleIdMap.set(rule.rule_id, rule.rule_id);
|
||||||
|
|
||||||
// 새 메뉴 ID로 연결 업데이트 필요
|
|
||||||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||||
if (newMenuObjid) {
|
if (newMenuObjid) {
|
||||||
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
|
rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid });
|
||||||
}
|
}
|
||||||
|
logger.info(` ♻️ 채번규칙 이미 존재 (원본 ID): ${rule.rule_id}`);
|
||||||
|
} else if (existingRuleIds.has(newRuleId)) {
|
||||||
|
// 새로 생성될 ID가 이미 존재 (기존 규칙으로 매핑)
|
||||||
|
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||||
|
|
||||||
|
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||||
|
if (newMenuObjid) {
|
||||||
|
rulesToUpdate.push({ ruleId: newRuleId, newMenuObjid });
|
||||||
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}`
|
` ♻️ 채번규칙 이미 존재 (대상 ID): ${rule.rule_id} -> ${newRuleId}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 새 rule_id 생성
|
// 새로 복사 필요
|
||||||
const originalSuffix = rule.rule_id.includes("_")
|
|
||||||
? rule.rule_id.replace(/^[^_]*_/, "")
|
|
||||||
: rule.rule_id;
|
|
||||||
const newRuleId = `${targetCompanyCode}_${originalSuffix}`;
|
|
||||||
|
|
||||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||||
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
|
originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId });
|
||||||
rulesToCopy.push({ ...rule, newRuleId });
|
rulesToCopy.push({ ...rule, newRuleId });
|
||||||
|
logger.info(` 📋 채번규칙 복사 예정: ${rule.rule_id} -> ${newRuleId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2425,6 +2445,24 @@ export class MenuCopyService {
|
||||||
|
|
||||||
const ruleParams = rulesToCopy.flatMap((r) => {
|
const ruleParams = rulesToCopy.flatMap((r) => {
|
||||||
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
const newMenuObjid = menuIdMap.get(r.menu_objid);
|
||||||
|
// scope_type = 'menu'인 경우 menu_objid가 반드시 필요함 (check 제약조건)
|
||||||
|
// menuIdMap에 없으면 원본 menu_objid가 복사된 메뉴 범위 밖이므로
|
||||||
|
// scope_type을 'table'로 변경하거나, 매핑이 없으면 null 처리
|
||||||
|
const finalMenuObjid = newMenuObjid !== undefined ? newMenuObjid : null;
|
||||||
|
// scope_type 결정 로직:
|
||||||
|
// 1. menu 스코프인데 menu_objid 매핑이 없는 경우
|
||||||
|
// - table_name이 있으면 'table' 스코프로 변경
|
||||||
|
// - table_name이 없으면 'global' 스코프로 변경
|
||||||
|
// 2. 그 외에는 원본 scope_type 유지
|
||||||
|
let finalScopeType = r.scope_type;
|
||||||
|
if (r.scope_type === "menu" && finalMenuObjid === null) {
|
||||||
|
if (r.table_name) {
|
||||||
|
finalScopeType = "table"; // table_name이 있으면 table 스코프
|
||||||
|
} else {
|
||||||
|
finalScopeType = "global"; // table_name도 없으면 global 스코프
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
r.newRuleId,
|
r.newRuleId,
|
||||||
r.rule_name,
|
r.rule_name,
|
||||||
|
|
@ -2436,8 +2474,8 @@ export class MenuCopyService {
|
||||||
r.column_name,
|
r.column_name,
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
userId,
|
userId,
|
||||||
newMenuObjid,
|
finalMenuObjid,
|
||||||
r.scope_type,
|
finalScopeType,
|
||||||
null,
|
null,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
@ -2458,8 +2496,11 @@ export class MenuCopyService {
|
||||||
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
|
// 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리
|
||||||
if (rulesToUpdate.length > 0) {
|
if (rulesToUpdate.length > 0) {
|
||||||
// CASE WHEN을 사용한 배치 업데이트
|
// CASE WHEN을 사용한 배치 업데이트
|
||||||
|
// menu_objid는 numeric 타입이므로 ::numeric 캐스팅 필요
|
||||||
const caseWhen = rulesToUpdate
|
const caseWhen = rulesToUpdate
|
||||||
.map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`)
|
.map(
|
||||||
|
(_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}::numeric`
|
||||||
|
)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
|
const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId);
|
||||||
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
|
const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]);
|
||||||
|
|
|
||||||
|
|
@ -976,6 +976,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
|
|
||||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||||
|
|
||||||
|
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
|
||||||
|
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
|
||||||
|
const hasUniversalFormModal = screenData.components.some(
|
||||||
|
(c) => {
|
||||||
|
// 최상위에 universal-form-modal이 있는 경우
|
||||||
|
if (c.componentType === "universal-form-modal") return true;
|
||||||
|
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
|
||||||
|
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
|
||||||
|
if (c.componentType === "conditional-container") return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||||
const enrichedFormData = {
|
const enrichedFormData = {
|
||||||
...(groupData.length > 0 ? groupData[0] : formData),
|
...(groupData.length > 0 ? groupData[0] : formData),
|
||||||
|
|
@ -1024,7 +1037,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
id: modalState.screenId!,
|
id: modalState.screenId!,
|
||||||
tableName: screenData.screenInfo?.tableName,
|
tableName: screenData.screenInfo?.tableName,
|
||||||
}}
|
}}
|
||||||
onSave={handleSave}
|
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
|
||||||
|
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
|
||||||
|
onSave={hasUniversalFormModal ? undefined : handleSave}
|
||||||
isInModal={true}
|
isInModal={true}
|
||||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||||
groupedData={groupedDataProp}
|
groupedData={groupedDataProp}
|
||||||
|
|
|
||||||
|
|
@ -150,46 +150,54 @@ export function ConditionalSectionViewer({
|
||||||
/* 실행 모드: 실제 화면 렌더링 */
|
/* 실행 모드: 실제 화면 렌더링 */
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* 화면 크기만큼의 절대 위치 캔버스 */}
|
{/* 화면 크기만큼의 절대 위치 캔버스 */}
|
||||||
<div
|
{/* UniversalFormModal이 있으면 onSave 전달하지 않음 (자체 저장 로직 사용) */}
|
||||||
className="relative mx-auto"
|
{(() => {
|
||||||
style={{
|
const hasUniversalFormModal = components.some(
|
||||||
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
|
(c) => c.componentType === "universal-form-modal"
|
||||||
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
|
);
|
||||||
minHeight: "200px",
|
return (
|
||||||
}}
|
<div
|
||||||
>
|
className="relative mx-auto"
|
||||||
{components.map((component) => {
|
style={{
|
||||||
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
|
||||||
|
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
|
||||||
return (
|
minHeight: "200px",
|
||||||
<div
|
}}
|
||||||
key={component.id}
|
>
|
||||||
className="absolute"
|
{components.map((component) => {
|
||||||
style={{
|
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||||
left: position.x || 0,
|
|
||||||
top: position.y || 0,
|
return (
|
||||||
width: size.width || 200,
|
<div
|
||||||
height: size.height || 40,
|
key={component.id}
|
||||||
zIndex: position.z || 1,
|
className="absolute"
|
||||||
}}
|
style={{
|
||||||
>
|
left: position.x || 0,
|
||||||
<DynamicComponentRenderer
|
top: position.y || 0,
|
||||||
component={component}
|
width: size.width || 200,
|
||||||
isInteractive={true}
|
height: size.height || 40,
|
||||||
screenId={screenInfo?.id}
|
zIndex: position.z || 1,
|
||||||
tableName={screenInfo?.tableName}
|
}}
|
||||||
userId={userId}
|
>
|
||||||
userName={userName}
|
<DynamicComponentRenderer
|
||||||
companyCode={user?.companyCode}
|
component={component}
|
||||||
formData={enhancedFormData}
|
isInteractive={true}
|
||||||
onFormDataChange={onFormDataChange}
|
screenId={screenInfo?.id}
|
||||||
groupedData={groupedData}
|
tableName={screenInfo?.tableName}
|
||||||
onSave={onSave}
|
userId={userId}
|
||||||
/>
|
userName={userName}
|
||||||
</div>
|
companyCode={user?.companyCode}
|
||||||
);
|
formData={enhancedFormData}
|
||||||
})}
|
onFormDataChange={onFormDataChange}
|
||||||
</div>
|
groupedData={groupedData}
|
||||||
|
onSave={hasUniversalFormModal ? undefined : onSave}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -12,9 +12,11 @@ import {
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Search, Loader2 } from "lucide-react";
|
import { Search, Loader2 } from "lucide-react";
|
||||||
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
||||||
import { ItemSelectionModalProps } from "./types";
|
import { ItemSelectionModalProps, ModalFilterConfig } from "./types";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
export function ItemSelectionModal({
|
export function ItemSelectionModal({
|
||||||
open,
|
open,
|
||||||
|
|
@ -29,27 +31,134 @@ export function ItemSelectionModal({
|
||||||
uniqueField,
|
uniqueField,
|
||||||
onSelect,
|
onSelect,
|
||||||
columnLabels = {},
|
columnLabels = {},
|
||||||
|
modalFilters = [],
|
||||||
}: ItemSelectionModalProps) {
|
}: ItemSelectionModalProps) {
|
||||||
const [localSearchText, setLocalSearchText] = useState("");
|
const [localSearchText, setLocalSearchText] = useState("");
|
||||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 모달 필터 값 상태
|
||||||
|
const [modalFilterValues, setModalFilterValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건
|
||||||
|
const combinedFilterCondition = useMemo(() => {
|
||||||
|
const combined = { ...filterCondition };
|
||||||
|
|
||||||
|
// 모달 필터 값 추가 (빈 값은 제외)
|
||||||
|
for (const [key, value] of Object.entries(modalFilterValues)) {
|
||||||
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
combined[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
}, [filterCondition, modalFilterValues]);
|
||||||
|
|
||||||
const { results, loading, error, search, clearSearch } = useEntitySearch({
|
const { results, loading, error, search, clearSearch } = useEntitySearch({
|
||||||
tableName: sourceTable,
|
tableName: sourceTable,
|
||||||
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
|
searchFields: sourceSearchFields.length > 0 ? sourceSearchFields : sourceColumns,
|
||||||
filterCondition,
|
filterCondition: combinedFilterCondition,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 모달 열릴 때 초기 검색
|
// 필터 옵션 로드 - 소스 테이블 컬럼의 distinct 값 조회
|
||||||
|
const loadFilterOptions = async (filter: ModalFilterConfig) => {
|
||||||
|
// 드롭다운 타입만 옵션 로드 필요 (select, category 지원)
|
||||||
|
const isDropdownType = filter.type === "select" || filter.type === "category";
|
||||||
|
if (!isDropdownType) return;
|
||||||
|
|
||||||
|
const cacheKey = `${sourceTable}.${filter.column}`;
|
||||||
|
|
||||||
|
// 이미 로드된 경우 스킵
|
||||||
|
if (categoryOptions[cacheKey]) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 소스 테이블에서 해당 컬럼의 데이터 조회 (POST 메서드 사용)
|
||||||
|
// 백엔드는 'size' 파라미터를 사용함
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 10000, // 모든 데이터 조회를 위해 큰 값 설정
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
// 응답 구조에 따라 rows 추출
|
||||||
|
const rows = response.data.data?.rows || response.data.data?.data || response.data.data || [];
|
||||||
|
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
// 컬럼 값 중복 제거
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const val = row[filter.column];
|
||||||
|
if (val !== null && val !== undefined && val !== "") {
|
||||||
|
uniqueValues.add(String(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬 후 옵션으로 변환
|
||||||
|
const options = Array.from(uniqueValues)
|
||||||
|
.sort()
|
||||||
|
.map((val) => ({
|
||||||
|
value: val,
|
||||||
|
label: val,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCategoryOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[cacheKey]: options,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`필터 옵션 로드 실패 (${cacheKey}):`, error);
|
||||||
|
setCategoryOptions((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[cacheKey]: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열릴 때 초기 검색 및 필터 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
// 모달 필터 기본값 설정 & 옵션 로드
|
||||||
|
const initialFilterValues: Record<string, any> = {};
|
||||||
|
for (const filter of modalFilters) {
|
||||||
|
if (filter.defaultValue !== undefined) {
|
||||||
|
initialFilterValues[filter.column] = filter.defaultValue;
|
||||||
|
}
|
||||||
|
// 드롭다운 타입이면 옵션 로드 (소스 테이블에서 distinct 값 조회)
|
||||||
|
const isDropdownType = filter.type === "select" || filter.type === "category";
|
||||||
|
if (isDropdownType) {
|
||||||
|
loadFilterOptions(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setModalFilterValues(initialFilterValues);
|
||||||
|
|
||||||
search("", 1); // 빈 검색어로 전체 목록 조회
|
search("", 1); // 빈 검색어로 전체 목록 조회
|
||||||
setSelectedItems([]);
|
setSelectedItems([]);
|
||||||
} else {
|
} else {
|
||||||
clearSearch();
|
clearSearch();
|
||||||
setLocalSearchText("");
|
setLocalSearchText("");
|
||||||
setSelectedItems([]);
|
setSelectedItems([]);
|
||||||
|
setModalFilterValues({});
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// 모달 필터 값 변경 시 재검색
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
search(localSearchText, 1);
|
||||||
|
}
|
||||||
|
}, [modalFilterValues]);
|
||||||
|
|
||||||
|
// 모달 필터 값 변경 핸들러
|
||||||
|
const handleModalFilterChange = (column: string, value: any) => {
|
||||||
|
setModalFilterValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
search(localSearchText, 1);
|
search(localSearchText, 1);
|
||||||
|
|
@ -202,6 +311,51 @@ export function ItemSelectionModal({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 필터 */}
|
||||||
|
{modalFilters.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3 items-center py-2 px-1 bg-muted/30 rounded-md">
|
||||||
|
{modalFilters.map((filter) => {
|
||||||
|
// 소스 테이블의 해당 컬럼에서 로드된 옵션
|
||||||
|
const options = categoryOptions[`${sourceTable}.${filter.column}`] || [];
|
||||||
|
|
||||||
|
// 드롭다운 타입인지 확인 (select, category 모두 드롭다운으로 처리)
|
||||||
|
const isDropdownType = filter.type === "select" || filter.type === "category";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={filter.column} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{filter.label}:</span>
|
||||||
|
{isDropdownType && (
|
||||||
|
<Select
|
||||||
|
value={modalFilterValues[filter.column] || "__all__"}
|
||||||
|
onValueChange={(value) => handleModalFilterChange(filter.column, value === "__all__" ? "" : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs w-[140px]">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">전체</SelectItem>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value || `__empty_${opt.label}__`}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{filter.type === "text" && (
|
||||||
|
<Input
|
||||||
|
value={modalFilterValues[filter.column] || ""}
|
||||||
|
onChange={(e) => handleModalFilterChange(filter.column, e.target.value)}
|
||||||
|
placeholder={filter.label}
|
||||||
|
className="h-7 text-xs w-[120px]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 선택된 항목 수 */}
|
{/* 선택된 항목 수 */}
|
||||||
{selectedItems.length > 0 && (
|
{selectedItems.length > 0 && (
|
||||||
<div className="text-sm text-primary">
|
<div className="text-sm text-primary">
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,9 @@ export function ModalRepeaterTableComponent({
|
||||||
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
|
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
|
||||||
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||||
|
|
||||||
|
// 모달 필터 설정
|
||||||
|
const modalFilters = componentConfig?.modalFilters || [];
|
||||||
|
|
||||||
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
|
||||||
const columnName = component?.columnName;
|
const columnName = component?.columnName;
|
||||||
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||||
|
|
@ -889,6 +892,7 @@ export function ModalRepeaterTableComponent({
|
||||||
uniqueField={uniqueField}
|
uniqueField={uniqueField}
|
||||||
onSelect={handleAddItems}
|
onSelect={handleAddItems}
|
||||||
columnLabels={columnLabels}
|
columnLabels={columnLabels}
|
||||||
|
modalFilters={modalFilters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ 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, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
|
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep, ModalFilterConfig } from "./types";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
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";
|
||||||
|
|
@ -842,6 +842,97 @@ export function ModalRepeaterTableConfigPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 필터 설정 */}
|
||||||
|
<div className="space-y-2 pt-4 border-t">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">모달 필터</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const filters = localConfig.modalFilters || [];
|
||||||
|
updateConfig({
|
||||||
|
modalFilters: [...filters, { column: "", label: "", type: "select" }],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!localConfig.sourceTable}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
모달에서 드롭다운으로 필터링할 컬럼을 설정합니다. 소스 테이블의 해당 컬럼에서 고유 값들이 자동으로 표시됩니다.
|
||||||
|
</p>
|
||||||
|
{(localConfig.modalFilters || []).length > 0 && (
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{(localConfig.modalFilters || []).map((filter, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 p-2 border rounded-md bg-muted/30">
|
||||||
|
<Select
|
||||||
|
value={filter.column}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const filters = [...(localConfig.modalFilters || [])];
|
||||||
|
filters[index] = { ...filters[index], column: value };
|
||||||
|
updateConfig({ modalFilters: filters });
|
||||||
|
}}
|
||||||
|
disabled={!localConfig.sourceTable || isLoadingColumns}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-[140px]">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sourceTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={filter.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const filters = [...(localConfig.modalFilters || [])];
|
||||||
|
filters[index] = { ...filters[index], label: e.target.value };
|
||||||
|
updateConfig({ modalFilters: filters });
|
||||||
|
}}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-8 text-xs w-[100px]"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filter.type}
|
||||||
|
onValueChange={(value: "select" | "text") => {
|
||||||
|
const filters = [...(localConfig.modalFilters || [])];
|
||||||
|
filters[index] = { ...filters[index], type: value };
|
||||||
|
updateConfig({ modalFilters: filters });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="select">드롭다운</SelectItem>
|
||||||
|
<SelectItem value="text">텍스트</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const filters = [...(localConfig.modalFilters || [])];
|
||||||
|
filters.splice(index, 1);
|
||||||
|
updateConfig({ modalFilters: filters });
|
||||||
|
}}
|
||||||
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 반복 테이블 컬럼 관리 */}
|
{/* 반복 테이블 컬럼 관리 */}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ import { CSS } from "@dnd-kit/utilities";
|
||||||
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
|
// SortableRow 컴포넌트 - 드래그 가능한 테이블 행
|
||||||
interface SortableRowProps {
|
interface SortableRowProps {
|
||||||
id: string;
|
id: string;
|
||||||
children: (props: {
|
children: (props: {
|
||||||
attributes: React.HTMLAttributes<HTMLElement>;
|
attributes: React.HTMLAttributes<HTMLElement>;
|
||||||
listeners: React.HTMLAttributes<HTMLElement> | undefined;
|
listeners: React.HTMLAttributes<HTMLElement> | undefined;
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
|
@ -40,14 +40,7 @@ interface SortableRowProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableRow({ id, children, className }: SortableRowProps) {
|
function SortableRow({ id, children, className }: SortableRowProps) {
|
||||||
const {
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id });
|
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -93,9 +86,9 @@ export function RepeaterTable({
|
||||||
}: RepeaterTableProps) {
|
}: RepeaterTableProps) {
|
||||||
// 컨테이너 ref - 실제 너비 측정용
|
// 컨테이너 ref - 실제 너비 측정용
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 균등 분배 모드 상태 (true일 때 테이블이 컨테이너에 맞춤)
|
// 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행)
|
||||||
const [isEqualizedMode, setIsEqualizedMode] = useState(false);
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
// DnD 센서 설정
|
// DnD 센서 설정
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
|
|
@ -106,7 +99,7 @@ export function RepeaterTable({
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 종료 핸들러
|
// 드래그 종료 핸들러
|
||||||
|
|
@ -140,15 +133,15 @@ export function RepeaterTable({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useState<{
|
const [editingCell, setEditingCell] = useState<{
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
field: string;
|
field: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 동적 데이터 소스 Popover 열림 상태
|
// 동적 데이터 소스 Popover 열림 상태
|
||||||
const [openPopover, setOpenPopover] = useState<string | null>(null);
|
const [openPopover, setOpenPopover] = useState<string | null>(null);
|
||||||
|
|
||||||
// 컬럼 너비 상태 관리
|
// 컬럼 너비 상태 관리
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
|
|
@ -157,7 +150,7 @@ export function RepeaterTable({
|
||||||
});
|
});
|
||||||
return widths;
|
return widths;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 기본 너비 저장 (리셋용)
|
// 기본 너비 저장 (리셋용)
|
||||||
const defaultWidths = React.useMemo(() => {
|
const defaultWidths = React.useMemo(() => {
|
||||||
const widths: Record<string, number> = {};
|
const widths: Record<string, number> = {};
|
||||||
|
|
@ -166,10 +159,10 @@ export function RepeaterTable({
|
||||||
});
|
});
|
||||||
return widths;
|
return widths;
|
||||||
}, [columns]);
|
}, [columns]);
|
||||||
|
|
||||||
// 리사이즈 상태
|
// 리사이즈 상태
|
||||||
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
|
||||||
|
|
||||||
// 리사이즈 핸들러
|
// 리사이즈 핸들러
|
||||||
const handleMouseDown = (e: React.MouseEvent, field: string) => {
|
const handleMouseDown = (e: React.MouseEvent, field: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -178,104 +171,171 @@ export function RepeaterTable({
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startWidth: columnWidths[field] || 120,
|
startWidth: columnWidths[field] || 120,
|
||||||
});
|
});
|
||||||
// 수동 조정 시 균등 분배 모드 해제
|
|
||||||
setIsEqualizedMode(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 확장 상태 추적 (토글용)
|
|
||||||
const [expandedColumns, setExpandedColumns] = useState<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// 데이터 기준 최적 너비 계산
|
// 컨테이너 가용 너비 계산
|
||||||
const calculateAutoFitWidth = (field: string): number => {
|
const getAvailableWidth = (): number => {
|
||||||
const column = columns.find(col => col.field === field);
|
if (!containerRef.current) return 800;
|
||||||
if (!column) return 120;
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
// 드래그 핸들(32px) + 체크박스 컬럼(40px) + border(2px)
|
||||||
|
return containerWidth - 74;
|
||||||
|
};
|
||||||
|
|
||||||
// 헤더 텍스트 길이 (대략 8px per character + padding)
|
// 텍스트 너비 계산 (한글/영문/숫자 혼합 고려)
|
||||||
const headerWidth = (column.label?.length || field.length) * 8 + 40;
|
const measureTextWidth = (text: string): number => {
|
||||||
|
if (!text) return 0;
|
||||||
|
let width = 0;
|
||||||
|
for (const char of text) {
|
||||||
|
if (/[가-힣]/.test(char)) {
|
||||||
|
width += 15; // 한글 (text-xs 12px 기준)
|
||||||
|
} else if (/[a-zA-Z]/.test(char)) {
|
||||||
|
width += 9; // 영문
|
||||||
|
} else if (/[0-9]/.test(char)) {
|
||||||
|
width += 8; // 숫자
|
||||||
|
} else if (/[_\-.]/.test(char)) {
|
||||||
|
width += 6; // 특수문자
|
||||||
|
} else if (/[\(\)]/.test(char)) {
|
||||||
|
width += 6; // 괄호
|
||||||
|
} else {
|
||||||
|
width += 8; // 기타
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
};
|
||||||
|
|
||||||
// 데이터 중 가장 긴 텍스트 찾기
|
// 해당 컬럼의 가장 긴 글자 너비 계산
|
||||||
|
// equalWidth: 균등 분배 시 너비 (값이 없는 컬럼의 최소값으로 사용)
|
||||||
|
const calculateColumnContentWidth = (field: string, equalWidth: number): number => {
|
||||||
|
const column = columns.find((col) => col.field === field);
|
||||||
|
if (!column) return equalWidth;
|
||||||
|
|
||||||
|
// 날짜 필드는 110px (yyyy-MM-dd)
|
||||||
|
if (column.type === "date") {
|
||||||
|
return 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해당 컬럼에 값이 있는지 확인
|
||||||
|
let hasValue = false;
|
||||||
let maxDataWidth = 0;
|
let maxDataWidth = 0;
|
||||||
data.forEach(row => {
|
|
||||||
|
data.forEach((row) => {
|
||||||
const value = row[field];
|
const value = row[field];
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
hasValue = true;
|
||||||
let displayText = String(value);
|
let displayText = String(value);
|
||||||
|
|
||||||
// 숫자는 천단위 구분자 포함
|
if (typeof value === "number") {
|
||||||
if (typeof value === 'number') {
|
|
||||||
displayText = value.toLocaleString();
|
displayText = value.toLocaleString();
|
||||||
}
|
}
|
||||||
// 날짜는 yyyy-mm-dd 형식
|
|
||||||
if (column.type === 'date' && displayText.includes('T')) {
|
const textWidth = measureTextWidth(displayText) + 20; // padding
|
||||||
displayText = displayText.split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 대략적인 너비 계산 (8px per character + padding)
|
|
||||||
const textWidth = displayText.length * 8 + 32;
|
|
||||||
maxDataWidth = Math.max(maxDataWidth, textWidth);
|
maxDataWidth = Math.max(maxDataWidth, textWidth);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 헤더와 데이터 중 큰 값 사용, 최소 60px, 최대 400px
|
// 값이 없으면 균등 분배 너비 사용
|
||||||
const optimalWidth = Math.max(headerWidth, maxDataWidth);
|
if (!hasValue) {
|
||||||
return Math.min(Math.max(optimalWidth, 60), 400);
|
return equalWidth;
|
||||||
};
|
}
|
||||||
|
|
||||||
// 더블클릭으로 auto-fit / 기본 너비 토글
|
// 헤더 텍스트 너비 (동적 데이터 소스가 있으면 headerLabel 사용)
|
||||||
const handleDoubleClick = (field: string) => {
|
let headerText = column.label || field;
|
||||||
// 개별 컬럼 조정 시 균등 분배 모드 해제
|
if (column.dynamicDataSource?.enabled && column.dynamicDataSource.options.length > 0) {
|
||||||
setIsEqualizedMode(false);
|
const activeOptionId = activeDataSources[field] || column.dynamicDataSource.defaultOptionId;
|
||||||
|
const activeOption = column.dynamicDataSource.options.find((opt) => opt.id === activeOptionId)
|
||||||
setExpandedColumns(prev => {
|
|| column.dynamicDataSource.options[0];
|
||||||
const newSet = new Set(prev);
|
if (activeOption?.headerLabel) {
|
||||||
if (newSet.has(field)) {
|
headerText = activeOption.headerLabel;
|
||||||
// 확장 상태 → 기본 너비로 복구
|
|
||||||
newSet.delete(field);
|
|
||||||
setColumnWidths(prevWidths => ({
|
|
||||||
...prevWidths,
|
|
||||||
[field]: defaultWidths[field] || 120,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// 기본 상태 → 데이터 기준 auto-fit
|
|
||||||
newSet.add(field);
|
|
||||||
const autoWidth = calculateAutoFitWidth(field);
|
|
||||||
setColumnWidths(prevWidths => ({
|
|
||||||
...prevWidths,
|
|
||||||
[field]: autoWidth,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
return newSet;
|
}
|
||||||
});
|
const headerWidth = measureTextWidth(headerText) + 32; // padding + 드롭다운 아이콘
|
||||||
|
|
||||||
|
// 헤더와 데이터 중 큰 값 사용
|
||||||
|
return Math.max(headerWidth, maxDataWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 균등 분배 트리거 감지
|
// 헤더 더블클릭: 해당 컬럼만 글자 너비에 맞춤
|
||||||
useEffect(() => {
|
const handleDoubleClick = (field: string) => {
|
||||||
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
const availableWidth = getAvailableWidth();
|
||||||
if (!containerRef.current) return;
|
|
||||||
|
|
||||||
// 실제 컨테이너 너비 측정
|
|
||||||
const containerWidth = containerRef.current.offsetWidth;
|
|
||||||
|
|
||||||
// 체크박스 컬럼 너비(40px) + 테이블 border(2px) 제외한 가용 너비 계산
|
|
||||||
const checkboxColumnWidth = 40;
|
|
||||||
const borderWidth = 2;
|
|
||||||
const availableWidth = containerWidth - checkboxColumnWidth - borderWidth;
|
|
||||||
|
|
||||||
// 컬럼 수로 나눠서 균등 분배 (최소 60px 보장)
|
|
||||||
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
||||||
|
const contentWidth = calculateColumnContentWidth(field, equalWidth);
|
||||||
|
setColumnWidths((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: contentWidth,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 균등 분배: 컬럼 수로 테이블 너비를 균등 분배
|
||||||
|
const applyEqualizeWidths = () => {
|
||||||
|
const availableWidth = getAvailableWidth();
|
||||||
|
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
||||||
|
|
||||||
const newWidths: Record<string, number> = {};
|
const newWidths: Record<string, number> = {};
|
||||||
columns.forEach((col) => {
|
columns.forEach((col) => {
|
||||||
newWidths[col.field] = equalWidth;
|
newWidths[col.field] = equalWidth;
|
||||||
});
|
});
|
||||||
|
|
||||||
setColumnWidths(newWidths);
|
setColumnWidths(newWidths);
|
||||||
setExpandedColumns(new Set()); // 확장 상태 초기화
|
};
|
||||||
setIsEqualizedMode(true); // 균등 분배 모드 활성화
|
|
||||||
}, [equalizeWidthsTrigger, columns]);
|
// 자동 맞춤: 각 컬럼을 글자 너비에 맞추고, 컨테이너보다 작으면 남는 공간 분배
|
||||||
|
const applyAutoFitWidths = () => {
|
||||||
|
if (columns.length === 0) return;
|
||||||
|
|
||||||
|
// 균등 분배 너비 계산 (값이 없는 컬럼의 최소값)
|
||||||
|
const availableWidth = getAvailableWidth();
|
||||||
|
const equalWidth = Math.max(60, Math.floor(availableWidth / columns.length));
|
||||||
|
|
||||||
|
// 1. 각 컬럼의 글자 너비 계산 (값이 없으면 균등 분배 너비 사용)
|
||||||
|
const newWidths: Record<string, number> = {};
|
||||||
|
columns.forEach((col) => {
|
||||||
|
newWidths[col.field] = calculateColumnContentWidth(col.field, equalWidth);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 컨테이너 너비와 비교
|
||||||
|
const totalContentWidth = Object.values(newWidths).reduce((sum, w) => sum + w, 0);
|
||||||
|
|
||||||
|
// 3. 컨테이너보다 작으면 남는 공간을 균등 분배 (테이블 꽉 참 유지)
|
||||||
|
if (totalContentWidth < availableWidth) {
|
||||||
|
const extraSpace = availableWidth - totalContentWidth;
|
||||||
|
const extraPerColumn = Math.floor(extraSpace / columns.length);
|
||||||
|
columns.forEach((col) => {
|
||||||
|
newWidths[col.field] += extraPerColumn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 컨테이너보다 크면 그대로 (스크롤 생성됨)
|
||||||
|
|
||||||
|
setColumnWidths(newWidths);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 마운트 시 균등 분배 적용
|
||||||
|
useEffect(() => {
|
||||||
|
if (initializedRef.current) return;
|
||||||
|
if (!containerRef.current || columns.length === 0) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
applyEqualizeWidths();
|
||||||
|
initializedRef.current = true;
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
// 트리거 감지: 1=균등분배, 2=자동맞춤
|
||||||
|
useEffect(() => {
|
||||||
|
if (equalizeWidthsTrigger === undefined || equalizeWidthsTrigger === 0) return;
|
||||||
|
|
||||||
|
// 홀수면 자동맞춤, 짝수면 균등분배 (토글 방식)
|
||||||
|
if (equalizeWidthsTrigger % 2 === 1) {
|
||||||
|
applyAutoFitWidths();
|
||||||
|
} else {
|
||||||
|
applyEqualizeWidths();
|
||||||
|
}
|
||||||
|
}, [equalizeWidthsTrigger]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
if (!resizing) return;
|
if (!resizing) return;
|
||||||
const diff = e.clientX - resizing.startX;
|
const diff = e.clientX - resizing.startX;
|
||||||
|
|
@ -285,14 +345,14 @@ export function RepeaterTable({
|
||||||
[resizing.field]: newWidth,
|
[resizing.field]: newWidth,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
setResizing(null);
|
setResizing(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
|
@ -336,13 +396,8 @@ export function RepeaterTable({
|
||||||
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
|
const isAllSelected = data.length > 0 && selectedRows.size === data.length;
|
||||||
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
|
const isIndeterminate = selectedRows.size > 0 && selectedRows.size < data.length;
|
||||||
|
|
||||||
const renderCell = (
|
const renderCell = (row: any, column: RepeaterColumnConfig, rowIndex: number) => {
|
||||||
row: any,
|
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||||
column: RepeaterColumnConfig,
|
|
||||||
rowIndex: number
|
|
||||||
) => {
|
|
||||||
const isEditing =
|
|
||||||
editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
|
||||||
const value = row[column.field];
|
const value = row[column.field];
|
||||||
|
|
||||||
// 계산 필드는 편집 불가
|
// 계산 필드는 편집 불가
|
||||||
|
|
@ -359,14 +414,8 @@ export function RepeaterTable({
|
||||||
return num.toLocaleString("ko-KR");
|
return num.toLocaleString("ko-KR");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <div className="px-2 py-1">{column.type === "number" ? formatNumber(value) : value || "-"}</div>;
|
||||||
<div className="px-2 py-1">
|
|
||||||
{column.type === "number"
|
|
||||||
? formatNumber(value)
|
|
||||||
: value || "-"}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편집 가능한 필드
|
// 편집 가능한 필드
|
||||||
|
|
@ -377,22 +426,22 @@ export function RepeaterTable({
|
||||||
if (value === undefined || value === null || value === "") return "";
|
if (value === undefined || value === null || value === "") return "";
|
||||||
const num = typeof value === "number" ? value : parseFloat(value);
|
const num = typeof value === "number" ? value : parseFloat(value);
|
||||||
if (isNaN(num)) return "";
|
if (isNaN(num)) return "";
|
||||||
// 정수면 소수점 없이, 소수면 소수점 유지
|
return num.toString();
|
||||||
if (Number.isInteger(num)) {
|
|
||||||
return num.toString();
|
|
||||||
} else {
|
|
||||||
return num.toString();
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
value={displayValue}
|
value={displayValue}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
const val = e.target.value;
|
||||||
}
|
// 숫자와 소수점만 허용
|
||||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
if (val === "" || /^-?\d*\.?\d*$/.test(val)) {
|
||||||
|
handleCellEdit(rowIndex, column.field, val === "" ? 0 : parseFloat(val) || 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-8 w-full min-w-0 rounded-none border-gray-200 text-right text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -414,25 +463,21 @@ export function RepeaterTable({
|
||||||
}
|
}
|
||||||
return String(val);
|
return String(val);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formatDateValue(value)}
|
value={formatDateValue(value)}
|
||||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
onClick={(e) => (e.target as HTMLInputElement).showPicker?.()}
|
||||||
|
className="h-8 w-full min-w-0 cursor-pointer rounded-none border border-gray-200 bg-white px-2 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-inner-spin-button]:hidden"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select value={value || ""} onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}>
|
||||||
value={value || ""}
|
<SelectTrigger className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
|
||||||
onValueChange={(newValue) =>
|
|
||||||
handleCellEdit(rowIndex, column.field, newValue)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full">
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -451,7 +496,7 @@ export function RepeaterTable({
|
||||||
type="text"
|
type="text"
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||||
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none min-w-0 w-full"
|
className="h-8 w-full min-w-0 rounded-none border-gray-200 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -461,126 +506,124 @@ export function RepeaterTable({
|
||||||
const sortableItems = data.map((_, idx) => `row-${idx}`);
|
const sortableItems = data.map((_, idx) => `row-${idx}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div ref={containerRef} className="border border-gray-200 bg-white">
|
<div ref={containerRef} className="border border-gray-200 bg-white">
|
||||||
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
<div className="max-h-[400px] overflow-x-auto overflow-y-auto">
|
||||||
<table
|
<table
|
||||||
className={cn(
|
className="border-collapse text-xs"
|
||||||
"text-xs border-collapse",
|
style={{
|
||||||
isEqualizedMode && "w-full"
|
width: `max(100%, ${Object.values(columnWidths).reduce((sum, w) => sum + w, 0) + 74}px)`,
|
||||||
)}
|
}}
|
||||||
style={isEqualizedMode ? undefined : { minWidth: "max-content" }}
|
|
||||||
>
|
>
|
||||||
<thead className="bg-gray-50 sticky top-0 z-10">
|
<thead className="sticky top-0 z-10 bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
{/* 드래그 핸들 헤더 */}
|
{/* 드래그 핸들 헤더 */}
|
||||||
<th className="px-1 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-8">
|
<th className="w-8 border-r border-b border-gray-200 px-1 py-2 text-center font-medium text-gray-700">
|
||||||
<span className="sr-only">순서</span>
|
<span className="sr-only">순서</span>
|
||||||
</th>
|
</th>
|
||||||
{/* 체크박스 헤더 */}
|
{/* 체크박스 헤더 */}
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-700 border-b border-r border-gray-200 w-10">
|
<th className="w-10 border-r border-b border-gray-200 px-3 py-2 text-center font-medium text-gray-700">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
// @ts-ignore - indeterminate는 HTML 속성
|
// @ts-expect-error - indeterminate는 HTML 속성
|
||||||
data-indeterminate={isIndeterminate}
|
data-indeterminate={isIndeterminate}
|
||||||
onCheckedChange={handleSelectAll}
|
onCheckedChange={handleSelectAll}
|
||||||
className={cn(
|
className={cn("border-gray-400", isIndeterminate && "data-[state=checked]:bg-primary")}
|
||||||
"border-gray-400",
|
|
||||||
isIndeterminate && "data-[state=checked]:bg-primary"
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||||
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||||
const activeOption = hasDynamicSource
|
const activeOption = hasDynamicSource
|
||||||
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
? col.dynamicDataSource!.options.find((opt) => opt.id === activeOptionId) ||
|
||||||
: null;
|
col.dynamicDataSource!.options[0]
|
||||||
|
: null;
|
||||||
const isExpanded = expandedColumns.has(col.field);
|
|
||||||
|
return (
|
||||||
return (
|
<th
|
||||||
<th
|
key={col.field}
|
||||||
key={col.field}
|
className="group relative cursor-pointer border-r border-b border-gray-200 px-3 py-2 text-left font-medium whitespace-nowrap text-gray-700 select-none"
|
||||||
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
|
style={{ width: `${columnWidths[col.field]}px` }}
|
||||||
style={{ width: `${columnWidths[col.field]}px` }}
|
onDoubleClick={() => handleDoubleClick(col.field)}
|
||||||
onDoubleClick={() => handleDoubleClick(col.field)}
|
title="더블클릭하여 글자 너비에 맞춤"
|
||||||
title={isExpanded ? "더블클릭하여 기본 너비로 복구" : "더블클릭하여 내용에 맞게 확장"}
|
>
|
||||||
>
|
<div className="pointer-events-none flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between pointer-events-none">
|
<div className="pointer-events-auto flex items-center gap-1">
|
||||||
<div className="flex items-center gap-1 pointer-events-auto">
|
{hasDynamicSource ? (
|
||||||
{hasDynamicSource ? (
|
<Popover
|
||||||
<Popover
|
open={openPopover === col.field}
|
||||||
open={openPopover === col.field}
|
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
||||||
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
|
|
||||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 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">
|
<PopoverTrigger asChild>
|
||||||
데이터 소스 선택
|
|
||||||
</div>
|
|
||||||
{col.dynamicDataSource!.options.map((option) => (
|
|
||||||
<button
|
<button
|
||||||
key={option.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
|
||||||
onDataSourceChange?.(col.field, option.id);
|
|
||||||
setOpenPopover(null);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
|
"inline-flex items-center gap-1 transition-colors hover:text-blue-600",
|
||||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
"-mx-1 rounded px-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500",
|
||||||
"focus:outline-none focus-visible:bg-accent",
|
|
||||||
activeOption?.id === option.id && "bg-accent/50"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Check
|
{/* 컬럼명 - 선택된 옵션라벨 형식으로 표시 */}
|
||||||
className={cn(
|
<span>
|
||||||
"h-3 w-3",
|
{activeOption?.headerLabel || `${col.label} - ${activeOption?.label || ''}`}
|
||||||
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
</span>
|
||||||
)}
|
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||||
/>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</PopoverTrigger>
|
||||||
</PopoverContent>
|
<PopoverContent className="w-auto min-w-[160px] p-1" align="start" sideOffset={4}>
|
||||||
</Popover>
|
<div className="text-muted-foreground mb-1 border-b px-2 py-1 text-[10px]">
|
||||||
) : (
|
데이터 소스 선택
|
||||||
<>
|
</div>
|
||||||
{col.label}
|
{col.dynamicDataSource!.options.map((option) => (
|
||||||
{col.required && <span className="text-red-500 ml-1">*</span>}
|
<button
|
||||||
</>
|
key={option.id}
|
||||||
)}
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onDataSourceChange?.(col.field, option.id);
|
||||||
|
setOpenPopover(null);
|
||||||
|
// 옵션 변경 시 해당 컬럼 너비 재계산
|
||||||
|
if (option.headerLabel) {
|
||||||
|
const newHeaderWidth = measureTextWidth(option.headerLabel) + 32;
|
||||||
|
setColumnWidths((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[col.field]: Math.max(prev[col.field] || 60, newHeaderWidth),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||||
|
"focus-visible:bg-accent focus:outline-none",
|
||||||
|
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.required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 리사이즈 핸들 */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute top-0 right-0 bottom-0 w-1 cursor-col-resize opacity-0 transition-opacity group-hover:opacity-100 hover:bg-blue-500"
|
||||||
|
onMouseDown={(e) => handleMouseDown(e, col.field)}
|
||||||
|
title="드래그하여 너비 조정"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 리사이즈 핸들 */}
|
</th>
|
||||||
<div
|
);
|
||||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
|
})}
|
||||||
onMouseDown={(e) => handleMouseDown(e, col.field)}
|
|
||||||
title="드래그하여 너비 조정"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
|
||||||
|
|
@ -589,7 +632,7 @@ export function RepeaterTable({
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length + 2}
|
colSpan={columns.length + 2}
|
||||||
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
|
className="border-b border-gray-200 px-4 py-8 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
추가된 항목이 없습니다
|
추가된 항목이 없습니다
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -600,19 +643,19 @@ export function RepeaterTable({
|
||||||
key={`row-${rowIndex}`}
|
key={`row-${rowIndex}`}
|
||||||
id={`row-${rowIndex}`}
|
id={`row-${rowIndex}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:bg-blue-50/50 transition-colors",
|
"transition-colors hover:bg-blue-50/50",
|
||||||
selectedRows.has(rowIndex) && "bg-blue-50"
|
selectedRows.has(rowIndex) && "bg-blue-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{({ attributes, listeners, isDragging }) => (
|
{({ attributes, listeners, isDragging }) => (
|
||||||
<>
|
<>
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
<td className="px-1 py-1 text-center border-b border-r border-gray-200">
|
<td className="border-r border-b border-gray-200 px-1 py-1 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-grab p-1 rounded hover:bg-gray-100 transition-colors",
|
"cursor-grab rounded p-1 transition-colors hover:bg-gray-100",
|
||||||
isDragging && "cursor-grabbing"
|
isDragging && "cursor-grabbing",
|
||||||
)}
|
)}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
|
|
@ -621,7 +664,7 @@ export function RepeaterTable({
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
{/* 체크박스 */}
|
{/* 체크박스 */}
|
||||||
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
|
<td className="border-r border-b border-gray-200 px-3 py-1 text-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedRows.has(rowIndex)}
|
checked={selectedRows.has(rowIndex)}
|
||||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
onCheckedChange={(checked) => handleRowSelect(rowIndex, !!checked)}
|
||||||
|
|
@ -630,10 +673,13 @@ export function RepeaterTable({
|
||||||
</td>
|
</td>
|
||||||
{/* 데이터 컬럼들 */}
|
{/* 데이터 컬럼들 */}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.field}
|
key={col.field}
|
||||||
className="px-1 py-1 border-b border-r border-gray-200 overflow-hidden"
|
className="overflow-hidden border-r border-b border-gray-200 px-1 py-1"
|
||||||
style={{ width: `${columnWidths[col.field]}px`, maxWidth: `${columnWidths[col.field]}px` }}
|
style={{
|
||||||
|
width: `${columnWidths[col.field]}px`,
|
||||||
|
maxWidth: `${columnWidths[col.field]}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{renderCell(row, col, rowIndex)}
|
{renderCell(row, col, rowIndex)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -651,4 +697,3 @@ export function RepeaterTable({
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface ModalRepeaterTableProps {
|
||||||
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
|
modalTitle: string; // 모달 제목 (예: "품목 검색 및 선택")
|
||||||
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
|
modalButtonText?: string; // 모달 열기 버튼 텍스트 (기본: "품목 검색")
|
||||||
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
|
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
|
||||||
|
modalFilters?: ModalFilterConfig[]; // 모달 내 필터 설정
|
||||||
|
|
||||||
// Repeater 테이블 설정
|
// Repeater 테이블 설정
|
||||||
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
|
columns: RepeaterColumnConfig[]; // 테이블 컬럼 설정
|
||||||
|
|
@ -75,6 +76,7 @@ export interface DynamicDataSourceConfig {
|
||||||
export interface DynamicDataSourceOption {
|
export interface DynamicDataSourceOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string; // 표시 라벨 (예: "거래처별 단가")
|
label: string; // 표시 라벨 (예: "거래처별 단가")
|
||||||
|
headerLabel?: string; // 헤더에 표시될 전체 라벨 (예: "단가 - 거래처별 단가")
|
||||||
|
|
||||||
// 조회 방식
|
// 조회 방식
|
||||||
sourceType: "table" | "multiTable" | "api";
|
sourceType: "table" | "multiTable" | "api";
|
||||||
|
|
@ -175,6 +177,14 @@ export interface CalculationRule {
|
||||||
dependencies: string[]; // 의존하는 필드들
|
dependencies: string[]; // 의존하는 필드들
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모달 필터 설정 (간소화된 버전)
|
||||||
|
export interface ModalFilterConfig {
|
||||||
|
column: string; // 필터 대상 컬럼 (소스 테이블의 컬럼명)
|
||||||
|
label: string; // 필터 라벨 (UI에 표시될 이름)
|
||||||
|
type: "select" | "text"; // select: 드롭다운 (distinct 값), text: 텍스트 입력
|
||||||
|
defaultValue?: string; // 기본값
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemSelectionModalProps {
|
export interface ItemSelectionModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -188,4 +198,7 @@ export interface ItemSelectionModalProps {
|
||||||
uniqueField?: string;
|
uniqueField?: string;
|
||||||
onSelect: (items: Record<string, unknown>[]) => void;
|
onSelect: (items: Record<string, unknown>[]) => void;
|
||||||
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
|
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
|
||||||
|
|
||||||
|
// 모달 내부 필터 (사용자 선택 가능)
|
||||||
|
modalFilters?: ModalFilterConfig[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,884 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, Columns, AlignJustify } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
// 기존 ModalRepeaterTable 컴포넌트 재사용
|
||||||
|
import { RepeaterTable } from "../modal-repeater-table/RepeaterTable";
|
||||||
|
import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
|
||||||
|
import { RepeaterColumnConfig, CalculationRule, DynamicDataSourceOption } from "../modal-repeater-table/types";
|
||||||
|
|
||||||
|
// 타입 정의
|
||||||
|
import {
|
||||||
|
TableSectionConfig,
|
||||||
|
TableColumnConfig,
|
||||||
|
ValueMappingConfig,
|
||||||
|
TableJoinCondition,
|
||||||
|
FormDataState,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
interface TableSectionRendererProps {
|
||||||
|
sectionId: string;
|
||||||
|
tableConfig: TableSectionConfig;
|
||||||
|
formData: FormDataState;
|
||||||
|
onFormDataChange: (field: string, value: any) => void;
|
||||||
|
onTableDataChange: (data: any[]) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TableColumnConfig를 RepeaterColumnConfig로 변환
|
||||||
|
* columnModes 또는 lookup이 있으면 dynamicDataSource로 변환
|
||||||
|
*/
|
||||||
|
function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
|
||||||
|
const baseColumn: RepeaterColumnConfig = {
|
||||||
|
field: col.field,
|
||||||
|
label: col.label,
|
||||||
|
type: col.type,
|
||||||
|
editable: col.editable ?? true,
|
||||||
|
calculated: col.calculated ?? false,
|
||||||
|
width: col.width || "150px",
|
||||||
|
required: col.required,
|
||||||
|
defaultValue: col.defaultValue,
|
||||||
|
selectOptions: col.selectOptions,
|
||||||
|
// valueMapping은 별도로 처리
|
||||||
|
};
|
||||||
|
|
||||||
|
// lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능)
|
||||||
|
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
|
||||||
|
baseColumn.dynamicDataSource = {
|
||||||
|
enabled: true,
|
||||||
|
options: col.lookup.options.map((option) => ({
|
||||||
|
id: option.id,
|
||||||
|
// "컬럼명 - 옵션라벨" 형식으로 헤더에 표시
|
||||||
|
label: option.displayLabel || option.label,
|
||||||
|
// 헤더에 표시될 전체 라벨 (컬럼명 - 옵션라벨)
|
||||||
|
headerLabel: `${col.label} - ${option.displayLabel || option.label}`,
|
||||||
|
sourceType: "table" as const,
|
||||||
|
tableConfig: {
|
||||||
|
tableName: option.tableName,
|
||||||
|
valueColumn: option.valueColumn,
|
||||||
|
joinConditions: option.conditions.map((cond) => ({
|
||||||
|
sourceField: cond.sourceField,
|
||||||
|
targetField: cond.targetColumn,
|
||||||
|
// sourceType에 따른 데이터 출처 설정
|
||||||
|
sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable"
|
||||||
|
fromFormData: cond.sourceType === "sectionField",
|
||||||
|
sectionId: cond.sectionId,
|
||||||
|
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
|
||||||
|
externalLookup: cond.externalLookup,
|
||||||
|
// 값 변환 설정 전달 (레거시 호환)
|
||||||
|
transform: cond.transform?.enabled ? {
|
||||||
|
tableName: cond.transform.tableName,
|
||||||
|
matchColumn: cond.transform.matchColumn,
|
||||||
|
resultColumn: cond.transform.resultColumn,
|
||||||
|
} : undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
// 조회 유형 정보 추가
|
||||||
|
lookupType: option.type,
|
||||||
|
})),
|
||||||
|
defaultOptionId: col.lookup.options.find((o) => o.isDefault)?.id || col.lookup.options[0]?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// columnModes를 dynamicDataSource로 변환 (기존 로직 유지)
|
||||||
|
else if (col.columnModes && col.columnModes.length > 0) {
|
||||||
|
baseColumn.dynamicDataSource = {
|
||||||
|
enabled: true,
|
||||||
|
options: col.columnModes.map((mode) => ({
|
||||||
|
id: mode.id,
|
||||||
|
label: mode.label,
|
||||||
|
sourceType: "table" as const,
|
||||||
|
// 실제 조회 로직은 TableSectionRenderer에서 처리
|
||||||
|
tableConfig: {
|
||||||
|
tableName: mode.valueMapping?.externalRef?.tableName || "",
|
||||||
|
valueColumn: mode.valueMapping?.externalRef?.valueColumn || "",
|
||||||
|
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
|
||||||
|
sourceField: jc.sourceField,
|
||||||
|
targetField: jc.targetColumn,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TableCalculationRule을 CalculationRule로 변환
|
||||||
|
*/
|
||||||
|
function convertToCalculationRule(calc: { resultField: string; formula: string; dependencies: string[] }): CalculationRule {
|
||||||
|
return {
|
||||||
|
result: calc.resultField,
|
||||||
|
formula: calc.formula,
|
||||||
|
dependencies: calc.dependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 변환 함수: 중간 테이블을 통해 값을 변환
|
||||||
|
* 예: 거래처 이름 "(무)테스트업체" → 거래처 코드 "CUST-0002"
|
||||||
|
*/
|
||||||
|
async function transformValue(
|
||||||
|
value: any,
|
||||||
|
transform: { tableName: string; matchColumn: string; resultColumn: string }
|
||||||
|
): Promise<any> {
|
||||||
|
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 정확히 일치하는 검색
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${transform.tableName}/data`,
|
||||||
|
{
|
||||||
|
search: {
|
||||||
|
[transform.matchColumn]: {
|
||||||
|
value: value,
|
||||||
|
operator: "equals"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size: 1,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
||||||
|
const transformedValue = response.data.data.data[0][transform.resultColumn];
|
||||||
|
return transformedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`변환 실패: ${transform.tableName}.${transform.matchColumn} = "${value}" 인 행을 찾을 수 없습니다.`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("값 변환 오류:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 테이블에서 조건 값을 조회하는 함수
|
||||||
|
* LookupCondition.sourceType이 "externalTable"인 경우 사용
|
||||||
|
*/
|
||||||
|
async function fetchExternalLookupValue(
|
||||||
|
externalLookup: {
|
||||||
|
tableName: string;
|
||||||
|
matchColumn: string;
|
||||||
|
matchSourceType: "currentRow" | "sourceTable" | "sectionField";
|
||||||
|
matchSourceField: string;
|
||||||
|
matchSectionId?: string;
|
||||||
|
resultColumn: string;
|
||||||
|
},
|
||||||
|
rowData: any,
|
||||||
|
sourceData: any,
|
||||||
|
formData: FormDataState
|
||||||
|
): Promise<any> {
|
||||||
|
// 1. 비교 값 가져오기
|
||||||
|
let matchValue: any;
|
||||||
|
if (externalLookup.matchSourceType === "currentRow") {
|
||||||
|
matchValue = rowData[externalLookup.matchSourceField];
|
||||||
|
} else if (externalLookup.matchSourceType === "sourceTable") {
|
||||||
|
matchValue = sourceData?.[externalLookup.matchSourceField];
|
||||||
|
} else {
|
||||||
|
matchValue = formData[externalLookup.matchSourceField];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchValue === undefined || matchValue === null || matchValue === "") {
|
||||||
|
console.warn(`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${externalLookup.tableName}/data`,
|
||||||
|
{
|
||||||
|
search: {
|
||||||
|
[externalLookup.matchColumn]: {
|
||||||
|
value: matchValue,
|
||||||
|
operator: "equals"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
size: 1,
|
||||||
|
page: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
||||||
|
return response.data.data.data[0][externalLookup.resultColumn];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`);
|
||||||
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("외부 테이블 조회 오류:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 테이블에서 값을 조회하는 함수
|
||||||
|
*
|
||||||
|
* @param tableName - 조회할 테이블명
|
||||||
|
* @param valueColumn - 가져올 컬럼명
|
||||||
|
* @param joinConditions - 조인 조건 목록
|
||||||
|
* @param rowData - 현재 행 데이터 (설정된 컬럼 필드)
|
||||||
|
* @param sourceData - 원본 소스 데이터 (_sourceData)
|
||||||
|
* @param formData - 폼 데이터 (다른 섹션 필드)
|
||||||
|
*/
|
||||||
|
async function fetchExternalValue(
|
||||||
|
tableName: string,
|
||||||
|
valueColumn: string,
|
||||||
|
joinConditions: TableJoinCondition[],
|
||||||
|
rowData: any,
|
||||||
|
sourceData: any,
|
||||||
|
formData: FormDataState
|
||||||
|
): Promise<any> {
|
||||||
|
if (joinConditions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const whereConditions: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const condition of joinConditions) {
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
// 값 출처에 따라 가져오기 (4가지 소스 타입 지원)
|
||||||
|
if (condition.sourceType === "row") {
|
||||||
|
// 현재 행 데이터 (설정된 컬럼 필드)
|
||||||
|
value = rowData[condition.sourceField];
|
||||||
|
} else if (condition.sourceType === "sourceData") {
|
||||||
|
// 원본 소스 테이블 데이터 (_sourceData)
|
||||||
|
value = sourceData?.[condition.sourceField];
|
||||||
|
} else if (condition.sourceType === "formData") {
|
||||||
|
// formData에서 가져오기 (다른 섹션)
|
||||||
|
value = formData[condition.sourceField];
|
||||||
|
} else if (condition.sourceType === "externalTable" && condition.externalLookup) {
|
||||||
|
// 외부 테이블에서 조회하여 가져오기
|
||||||
|
value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환
|
||||||
|
if (condition.transform) {
|
||||||
|
value = await transformValue(value, condition.transform);
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자형 ID 변환
|
||||||
|
let convertedValue = value;
|
||||||
|
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
|
||||||
|
const numValue = Number(value);
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
convertedValue = numValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
|
||||||
|
whereConditions[condition.targetColumn] = {
|
||||||
|
value: convertedValue,
|
||||||
|
operator: "equals"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
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;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("외부 테이블 조회 오류:", error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 섹션 렌더러
|
||||||
|
* UniversalFormModal 내에서 테이블 형식의 데이터를 표시하고 편집
|
||||||
|
*/
|
||||||
|
export function TableSectionRenderer({
|
||||||
|
sectionId,
|
||||||
|
tableConfig,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
onTableDataChange,
|
||||||
|
className,
|
||||||
|
}: TableSectionRendererProps) {
|
||||||
|
// 테이블 데이터 상태
|
||||||
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 체크박스 선택 상태
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
|
||||||
|
const [widthTrigger, setWidthTrigger] = useState(0);
|
||||||
|
|
||||||
|
// 동적 데이터 소스 활성화 상태
|
||||||
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
|
||||||
|
const [batchAppliedFields, setBatchAppliedFields] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
|
||||||
|
const initialDataLoadedRef = React.useRef(false);
|
||||||
|
|
||||||
|
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
||||||
|
useEffect(() => {
|
||||||
|
// 이미 초기화되었으면 스킵
|
||||||
|
if (initialDataLoadedRef.current) return;
|
||||||
|
|
||||||
|
const tableSectionKey = `_tableSection_${sectionId}`;
|
||||||
|
const initialData = formData[tableSectionKey];
|
||||||
|
|
||||||
|
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||||
|
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
|
||||||
|
sectionId,
|
||||||
|
itemCount: initialData.length,
|
||||||
|
});
|
||||||
|
setTableData(initialData);
|
||||||
|
initialDataLoadedRef.current = true;
|
||||||
|
}
|
||||||
|
}, [sectionId, formData]);
|
||||||
|
|
||||||
|
// RepeaterColumnConfig로 변환
|
||||||
|
const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn);
|
||||||
|
|
||||||
|
// 계산 규칙 변환
|
||||||
|
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
|
||||||
|
|
||||||
|
// 계산 로직
|
||||||
|
const calculateRow = useCallback(
|
||||||
|
(row: any): any => {
|
||||||
|
if (calculationRules.length === 0) return row;
|
||||||
|
|
||||||
|
const updatedRow = { ...row };
|
||||||
|
|
||||||
|
for (const rule of calculationRules) {
|
||||||
|
try {
|
||||||
|
let formula = rule.formula;
|
||||||
|
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||||
|
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
|
||||||
|
|
||||||
|
for (const dep of dependencies) {
|
||||||
|
if (dep === rule.result) continue;
|
||||||
|
const value = parseFloat(row[dep]) || 0;
|
||||||
|
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Function(`return ${formula}`)();
|
||||||
|
updatedRow[rule.result] = result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`계산 오류 (${rule.formula}):`, error);
|
||||||
|
updatedRow[rule.result] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRow;
|
||||||
|
},
|
||||||
|
[calculationRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateAll = useCallback(
|
||||||
|
(data: any[]): any[] => {
|
||||||
|
return data.map((row) => calculateRow(row));
|
||||||
|
},
|
||||||
|
[calculateRow]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함)
|
||||||
|
const handleDataChange = useCallback(
|
||||||
|
(newData: any[]) => {
|
||||||
|
let processedData = newData;
|
||||||
|
|
||||||
|
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
|
||||||
|
const batchApplyColumns = tableConfig.columns.filter(
|
||||||
|
(col) => col.type === "date" && col.batchApply === true
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const dateCol of batchApplyColumns) {
|
||||||
|
// 이미 일괄 적용된 컬럼은 건너뜀
|
||||||
|
if (batchAppliedFields.has(dateCol.field)) continue;
|
||||||
|
|
||||||
|
// 해당 컬럼에 값이 있는 행과 없는 행 분류
|
||||||
|
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
|
||||||
|
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
|
||||||
|
|
||||||
|
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
|
||||||
|
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
||||||
|
const selectedDate = itemsWithDate[0][dateCol.field];
|
||||||
|
|
||||||
|
// 모든 행에 동일한 날짜 적용
|
||||||
|
processedData = processedData.map((item) => ({
|
||||||
|
...item,
|
||||||
|
[dateCol.field]: selectedDate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 플래그 활성화 (이후 개별 수정 가능)
|
||||||
|
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTableData(processedData);
|
||||||
|
onTableDataChange(processedData);
|
||||||
|
},
|
||||||
|
[onTableDataChange, tableConfig.columns, batchAppliedFields]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 행 변경 핸들러
|
||||||
|
const handleRowChange = useCallback(
|
||||||
|
(index: number, newRow: any) => {
|
||||||
|
const calculatedRow = calculateRow(newRow);
|
||||||
|
const newData = [...tableData];
|
||||||
|
newData[index] = calculatedRow;
|
||||||
|
handleDataChange(newData);
|
||||||
|
},
|
||||||
|
[tableData, calculateRow, handleDataChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 행 삭제 핸들러
|
||||||
|
const handleRowDelete = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const newData = tableData.filter((_, i) => i !== index);
|
||||||
|
handleDataChange(newData);
|
||||||
|
},
|
||||||
|
[tableData, handleDataChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 선택된 항목 일괄 삭제
|
||||||
|
const handleBulkDelete = useCallback(() => {
|
||||||
|
if (selectedRows.size === 0) return;
|
||||||
|
const newData = tableData.filter((_, index) => !selectedRows.has(index));
|
||||||
|
handleDataChange(newData);
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
|
||||||
|
// 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋
|
||||||
|
if (newData.length === 0) {
|
||||||
|
setBatchAppliedFields(new Set());
|
||||||
|
}
|
||||||
|
}, [tableData, selectedRows, handleDataChange]);
|
||||||
|
|
||||||
|
// 아이템 추가 핸들러 (모달에서 선택)
|
||||||
|
const handleAddItems = useCallback(
|
||||||
|
async (items: any[]) => {
|
||||||
|
// 각 아이템에 대해 valueMapping 적용
|
||||||
|
const mappedItems = await Promise.all(
|
||||||
|
items.map(async (sourceItem) => {
|
||||||
|
const newItem: any = {};
|
||||||
|
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
const mapping = col.valueMapping;
|
||||||
|
|
||||||
|
// 0. lookup 설정이 있는 경우 (동적 조회)
|
||||||
|
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
|
||||||
|
// 현재 활성화된 옵션 또는 기본 옵션 사용
|
||||||
|
const activeOptionId = activeDataSources[col.field];
|
||||||
|
const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0];
|
||||||
|
const selectedOption = activeOptionId
|
||||||
|
? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption
|
||||||
|
: defaultOption;
|
||||||
|
|
||||||
|
if (selectedOption) {
|
||||||
|
// sameTable 타입: 소스 데이터에서 직접 값 복사
|
||||||
|
if (selectedOption.type === "sameTable") {
|
||||||
|
const value = sourceItem[selectedOption.valueColumn];
|
||||||
|
if (value !== undefined) {
|
||||||
|
newItem[col.field] = value;
|
||||||
|
}
|
||||||
|
// _sourceData에 원본 저장 (나중에 다른 옵션으로 전환 시 사용)
|
||||||
|
newItem._sourceData = sourceItem;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// relatedTable, combinedLookup: 외부 테이블 조회
|
||||||
|
// 조인 조건 구성 (4가지 소스 타입 지원)
|
||||||
|
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
|
||||||
|
// sourceType 매핑
|
||||||
|
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
|
||||||
|
if (cond.sourceType === "currentRow") {
|
||||||
|
sourceType = "row";
|
||||||
|
} else if (cond.sourceType === "sourceTable") {
|
||||||
|
sourceType = "sourceData";
|
||||||
|
} else if (cond.sourceType === "externalTable") {
|
||||||
|
sourceType = "externalTable";
|
||||||
|
} else {
|
||||||
|
sourceType = "formData";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceType,
|
||||||
|
sourceField: cond.sourceField,
|
||||||
|
targetColumn: cond.targetColumn,
|
||||||
|
// 외부 테이블 조회 설정
|
||||||
|
externalLookup: cond.externalLookup,
|
||||||
|
// 값 변환 설정 전달 (레거시 호환)
|
||||||
|
transform: cond.transform?.enabled ? {
|
||||||
|
tableName: cond.transform.tableName,
|
||||||
|
matchColumn: cond.transform.matchColumn,
|
||||||
|
resultColumn: cond.transform.resultColumn,
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 테이블에서 값 조회 (sourceItem이 _sourceData 역할)
|
||||||
|
const value = await fetchExternalValue(
|
||||||
|
selectedOption.tableName,
|
||||||
|
selectedOption.valueColumn,
|
||||||
|
joinConditions,
|
||||||
|
{ ...sourceItem, ...newItem }, // rowData (현재 행)
|
||||||
|
sourceItem, // sourceData (소스 테이블 원본)
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
newItem[col.field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// _sourceData에 원본 저장
|
||||||
|
newItem._sourceData = sourceItem;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 먼저 col.sourceField 확인 (간단 매핑)
|
||||||
|
if (!mapping && col.sourceField) {
|
||||||
|
// sourceField가 명시적으로 설정된 경우
|
||||||
|
if (sourceItem[col.sourceField] !== undefined) {
|
||||||
|
newItem[col.field] = sourceItem[col.sourceField];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping) {
|
||||||
|
// 매핑 없으면 소스에서 동일 필드명으로 복사
|
||||||
|
if (sourceItem[col.field] !== undefined) {
|
||||||
|
newItem[col.field] = sourceItem[col.field];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. valueMapping이 있는 경우 (고급 매핑)
|
||||||
|
switch (mapping.type) {
|
||||||
|
case "source":
|
||||||
|
// 소스 테이블에서 복사
|
||||||
|
const srcField = mapping.sourceField || col.sourceField || col.field;
|
||||||
|
if (sourceItem[srcField] !== undefined) {
|
||||||
|
newItem[col.field] = sourceItem[srcField];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "manual":
|
||||||
|
// 사용자 입력 (빈 값 또는 기본값)
|
||||||
|
newItem[col.field] = col.defaultValue ?? undefined;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "internal":
|
||||||
|
// formData에서 값 가져오기
|
||||||
|
if (mapping.internalField) {
|
||||||
|
newItem[col.field] = formData[mapping.internalField];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "external":
|
||||||
|
// 외부 테이블에서 조회
|
||||||
|
if (mapping.externalRef) {
|
||||||
|
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
|
||||||
|
const value = await fetchExternalValue(
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
joinConditions,
|
||||||
|
{ ...sourceItem, ...newItem }, // rowData
|
||||||
|
sourceItem, // sourceData
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
if (value !== undefined) {
|
||||||
|
newItem[col.field] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값 적용
|
||||||
|
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
|
||||||
|
newItem[col.field] = col.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItem;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 계산 필드 업데이트
|
||||||
|
const calculatedItems = calculateAll(mappedItems);
|
||||||
|
|
||||||
|
// 기존 데이터에 추가
|
||||||
|
const newData = [...tableData, ...calculatedItems];
|
||||||
|
handleDataChange(newData);
|
||||||
|
},
|
||||||
|
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 컬럼 모드/조회 옵션 변경 핸들러
|
||||||
|
const handleDataSourceChange = useCallback(
|
||||||
|
async (columnField: string, optionId: string) => {
|
||||||
|
setActiveDataSources((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[columnField]: optionId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 해당 컬럼의 모든 행 데이터 재조회
|
||||||
|
const column = tableConfig.columns.find((col) => col.field === columnField);
|
||||||
|
|
||||||
|
// lookup 설정이 있는 경우 (새로운 조회 기능)
|
||||||
|
if (column?.lookup?.enabled && column.lookup.options) {
|
||||||
|
const selectedOption = column.lookup.options.find((opt) => opt.id === optionId);
|
||||||
|
if (!selectedOption) return;
|
||||||
|
|
||||||
|
// sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음)
|
||||||
|
if (selectedOption.type === "sameTable") {
|
||||||
|
const updatedData = tableData.map((row) => {
|
||||||
|
// sourceField에서 값을 가져와 해당 컬럼에 복사
|
||||||
|
// row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴
|
||||||
|
const sourceData = row._sourceData || row;
|
||||||
|
const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField];
|
||||||
|
return { ...row, [columnField]: newValue };
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculatedData = calculateAll(updatedData);
|
||||||
|
handleDataChange(calculatedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모든 행에 대해 새 값 조회
|
||||||
|
const updatedData = await Promise.all(
|
||||||
|
tableData.map(async (row) => {
|
||||||
|
let newValue: any = row[columnField];
|
||||||
|
|
||||||
|
// 조인 조건 구성 (4가지 소스 타입 지원)
|
||||||
|
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
|
||||||
|
// sourceType 매핑
|
||||||
|
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
|
||||||
|
if (cond.sourceType === "currentRow") {
|
||||||
|
sourceType = "row";
|
||||||
|
} else if (cond.sourceType === "sourceTable") {
|
||||||
|
sourceType = "sourceData";
|
||||||
|
} else if (cond.sourceType === "externalTable") {
|
||||||
|
sourceType = "externalTable";
|
||||||
|
} else {
|
||||||
|
sourceType = "formData";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceType,
|
||||||
|
sourceField: cond.sourceField,
|
||||||
|
targetColumn: cond.targetColumn,
|
||||||
|
// 외부 테이블 조회 설정
|
||||||
|
externalLookup: cond.externalLookup,
|
||||||
|
// 값 변환 설정 전달 (레거시 호환)
|
||||||
|
transform: cond.transform?.enabled ? {
|
||||||
|
tableName: cond.transform.tableName,
|
||||||
|
matchColumn: cond.transform.matchColumn,
|
||||||
|
resultColumn: cond.transform.resultColumn,
|
||||||
|
} : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 테이블에서 값 조회 (_sourceData 전달)
|
||||||
|
const sourceData = row._sourceData || row;
|
||||||
|
const value = await fetchExternalValue(
|
||||||
|
selectedOption.tableName,
|
||||||
|
selectedOption.valueColumn,
|
||||||
|
joinConditions,
|
||||||
|
row,
|
||||||
|
sourceData,
|
||||||
|
formData
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
newValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...row, [columnField]: newValue };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 계산 필드 업데이트
|
||||||
|
const calculatedData = calculateAll(updatedData);
|
||||||
|
handleDataChange(calculatedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 columnModes 처리 (레거시 호환)
|
||||||
|
if (!column?.columnModes) return;
|
||||||
|
|
||||||
|
const selectedMode = column.columnModes.find((mode) => mode.id === optionId);
|
||||||
|
if (!selectedMode) return;
|
||||||
|
|
||||||
|
// 모든 행에 대해 새 값 조회
|
||||||
|
const updatedData = await Promise.all(
|
||||||
|
tableData.map(async (row) => {
|
||||||
|
const mapping = selectedMode.valueMapping;
|
||||||
|
let newValue: any = row[columnField];
|
||||||
|
const sourceData = row._sourceData || row;
|
||||||
|
|
||||||
|
if (mapping.type === "external" && mapping.externalRef) {
|
||||||
|
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
|
||||||
|
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, formData);
|
||||||
|
if (value !== undefined) {
|
||||||
|
newValue = value;
|
||||||
|
}
|
||||||
|
} else if (mapping.type === "source" && mapping.sourceField) {
|
||||||
|
newValue = row[mapping.sourceField];
|
||||||
|
} else if (mapping.type === "internal" && mapping.internalField) {
|
||||||
|
newValue = formData[mapping.internalField];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...row, [columnField]: newValue };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 계산 필드 업데이트
|
||||||
|
const calculatedData = calculateAll(updatedData);
|
||||||
|
handleDataChange(calculatedData);
|
||||||
|
},
|
||||||
|
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 소스 테이블 정보
|
||||||
|
const { source, filters, uiConfig } = tableConfig;
|
||||||
|
const sourceTable = source.tableName;
|
||||||
|
const sourceColumns = source.displayColumns;
|
||||||
|
const sourceSearchFields = source.searchColumns;
|
||||||
|
const columnLabels = source.columnLabels || {};
|
||||||
|
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
|
||||||
|
const addButtonText = uiConfig?.addButtonText || "항목 검색";
|
||||||
|
const multiSelect = uiConfig?.multiSelect ?? true;
|
||||||
|
|
||||||
|
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
|
||||||
|
const baseFilterCondition: Record<string, any> = {};
|
||||||
|
if (filters?.preFilters) {
|
||||||
|
for (const filter of filters.preFilters) {
|
||||||
|
// 간단한 "=" 연산자만 처리 (확장 가능)
|
||||||
|
if (filter.operator === "=") {
|
||||||
|
baseFilterCondition[filter.column] = filter.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
|
||||||
|
const modalFiltersForModal = useMemo(() => {
|
||||||
|
if (!filters?.modalFilters) return [];
|
||||||
|
return filters.modalFilters.map((filter) => ({
|
||||||
|
column: filter.column,
|
||||||
|
label: filter.label || filter.column,
|
||||||
|
// category 타입을 select로 변환 (ModalFilterConfig 호환)
|
||||||
|
type: filter.type === "category" ? "select" as const : filter.type as "text" | "select",
|
||||||
|
options: filter.options,
|
||||||
|
categoryRef: filter.categoryRef,
|
||||||
|
defaultValue: filter.defaultValue,
|
||||||
|
}));
|
||||||
|
}, [filters?.modalFilters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
{/* 추가 버튼 영역 */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{tableData.length > 0 && `${tableData.length}개 항목`}
|
||||||
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
||||||
|
</span>
|
||||||
|
{columns.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setWidthTrigger((prev) => prev + 1)}
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
|
||||||
|
>
|
||||||
|
{widthTrigger % 2 === 0 ? (
|
||||||
|
<>
|
||||||
|
<AlignJustify className="h-3.5 w-3.5 mr-1" />
|
||||||
|
자동 맞춤
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Columns className="h-3.5 w-3.5 mr-1" />
|
||||||
|
균등 분배
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleBulkDelete}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
선택 삭제 ({selectedRows.size})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => setModalOpen(true)}
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
{addButtonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repeater 테이블 */}
|
||||||
|
<RepeaterTable
|
||||||
|
columns={columns}
|
||||||
|
data={tableData}
|
||||||
|
onDataChange={handleDataChange}
|
||||||
|
onRowChange={handleRowChange}
|
||||||
|
onRowDelete={handleRowDelete}
|
||||||
|
activeDataSources={activeDataSources}
|
||||||
|
onDataSourceChange={handleDataSourceChange}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
onSelectionChange={setSelectedRows}
|
||||||
|
equalizeWidthsTrigger={widthTrigger}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 항목 선택 모달 */}
|
||||||
|
<ItemSelectionModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
sourceTable={sourceTable}
|
||||||
|
sourceColumns={sourceColumns}
|
||||||
|
sourceSearchFields={sourceSearchFields}
|
||||||
|
multiSelect={multiSelect}
|
||||||
|
filterCondition={baseFilterCondition}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
alreadySelected={tableData}
|
||||||
|
uniqueField={tableConfig.saveConfig?.uniqueField}
|
||||||
|
onSelect={handleAddItems}
|
||||||
|
columnLabels={columnLabels}
|
||||||
|
modalFilters={modalFiltersForModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ import {
|
||||||
OptionalFieldGroupConfig,
|
OptionalFieldGroupConfig,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { defaultConfig, generateUniqueId } from "./config";
|
import { defaultConfig, generateUniqueId } from "./config";
|
||||||
|
import { TableSectionRenderer } from "./TableSectionRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
* 🔗 연쇄 드롭다운 Select 필드 컴포넌트
|
||||||
|
|
@ -194,6 +195,10 @@ export function UniversalFormModalComponent({
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
|
||||||
|
const [originalGroupedData, setOriginalGroupedData] = useState<any[]>([]);
|
||||||
|
const groupedDataInitializedRef = useRef(false);
|
||||||
|
|
||||||
// 삭제 확인 다이얼로그
|
// 삭제 확인 다이얼로그
|
||||||
const [deleteDialog, setDeleteDialog] = useState<{
|
const [deleteDialog, setDeleteDialog] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -303,6 +308,12 @@ export function UniversalFormModalComponent({
|
||||||
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
|
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
|
||||||
|
if (originalGroupedData.length > 0) {
|
||||||
|
event.detail.formData._originalGroupedData = originalGroupedData;
|
||||||
|
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
|
|
@ -310,7 +321,37 @@ export function UniversalFormModalComponent({
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
|
||||||
};
|
};
|
||||||
}, [formData, repeatSections, config.sections]);
|
}, [formData, repeatSections, config.sections, originalGroupedData]);
|
||||||
|
|
||||||
|
// 🆕 수정 모드: _groupedData가 있으면 테이블 섹션 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!_groupedData || _groupedData.length === 0) return;
|
||||||
|
if (groupedDataInitializedRef.current) return; // 이미 초기화됨
|
||||||
|
|
||||||
|
// 테이블 타입 섹션 찾기
|
||||||
|
const tableSection = config.sections.find((s) => s.type === "table");
|
||||||
|
if (!tableSection) {
|
||||||
|
console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
|
||||||
|
sectionId: tableSection.id,
|
||||||
|
itemCount: _groupedData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 원본 데이터 저장 (수정/삭제 추적용)
|
||||||
|
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
|
||||||
|
|
||||||
|
// 테이블 섹션 데이터 설정
|
||||||
|
const tableSectionKey = `_tableSection_${tableSection.id}`;
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tableSectionKey]: _groupedData,
|
||||||
|
}));
|
||||||
|
|
||||||
|
groupedDataInitializedRef.current = true;
|
||||||
|
}, [_groupedData, config.sections]);
|
||||||
|
|
||||||
// 필드 레벨 linkedFieldGroup 데이터 로드
|
// 필드 레벨 linkedFieldGroup 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -372,9 +413,12 @@ export function UniversalFormModalComponent({
|
||||||
items.push(createRepeatItem(section, i));
|
items.push(createRepeatItem(section, i));
|
||||||
}
|
}
|
||||||
newRepeatSections[section.id] = items;
|
newRepeatSections[section.id] = items;
|
||||||
|
} else if (section.type === "table") {
|
||||||
|
// 테이블 섹션은 필드 초기화 스킵 (TableSectionRenderer에서 처리)
|
||||||
|
continue;
|
||||||
} else {
|
} else {
|
||||||
// 일반 섹션 필드 초기화
|
// 일반 섹션 필드 초기화
|
||||||
for (const field of section.fields || []) {
|
for (const field of (section.fields || [])) {
|
||||||
// 기본값 설정
|
// 기본값 설정
|
||||||
let value = field.defaultValue ?? "";
|
let value = field.defaultValue ?? "";
|
||||||
|
|
||||||
|
|
@ -448,7 +492,7 @@ export function UniversalFormModalComponent({
|
||||||
_index: index,
|
_index: index,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const field of section.fields || []) {
|
for (const field of (section.fields || [])) {
|
||||||
item[field.columnName] = field.defaultValue ?? "";
|
item[field.columnName] = field.defaultValue ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,9 +523,9 @@ export function UniversalFormModalComponent({
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
if (section.repeatable) continue;
|
if (section.repeatable || section.type === "table") continue;
|
||||||
|
|
||||||
for (const field of section.fields || []) {
|
for (const field of (section.fields || [])) {
|
||||||
if (
|
if (
|
||||||
field.numberingRule?.enabled &&
|
field.numberingRule?.enabled &&
|
||||||
field.numberingRule?.generateOnOpen &&
|
field.numberingRule?.generateOnOpen &&
|
||||||
|
|
@ -781,9 +825,9 @@ export function UniversalFormModalComponent({
|
||||||
const missingFields: string[] = [];
|
const missingFields: string[] = [];
|
||||||
|
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
if (section.repeatable) continue; // 반복 섹션은 별도 검증
|
if (section.repeatable || section.type === "table") continue; // 반복 섹션 및 테이블 섹션은 별도 검증
|
||||||
|
|
||||||
for (const field of section.fields || []) {
|
for (const field of (section.fields || [])) {
|
||||||
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
|
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
|
||||||
const value = formData[field.columnName];
|
const value = formData[field.columnName];
|
||||||
if (value === undefined || value === null || value === "") {
|
if (value === undefined || value === null || value === "") {
|
||||||
|
|
@ -799,17 +843,28 @@ export function UniversalFormModalComponent({
|
||||||
// 단일 행 저장
|
// 단일 행 저장
|
||||||
const saveSingleRow = useCallback(async () => {
|
const saveSingleRow = useCallback(async () => {
|
||||||
const dataToSave = { ...formData };
|
const dataToSave = { ...formData };
|
||||||
|
|
||||||
|
// 테이블 섹션 데이터 추출 (별도 저장용)
|
||||||
|
const tableSectionData: Record<string, any[]> = {};
|
||||||
|
|
||||||
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
|
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
|
||||||
Object.keys(dataToSave).forEach((key) => {
|
Object.keys(dataToSave).forEach((key) => {
|
||||||
if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
|
if (key.startsWith("_tableSection_")) {
|
||||||
|
// 테이블 섹션 데이터는 별도로 저장
|
||||||
|
const sectionId = key.replace("_tableSection_", "");
|
||||||
|
tableSectionData[sectionId] = dataToSave[key] || [];
|
||||||
|
delete dataToSave[key];
|
||||||
|
} else if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
|
||||||
delete dataToSave[key];
|
delete dataToSave[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
|
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
for (const field of section.fields || []) {
|
// 테이블 타입 섹션은 건너뛰기
|
||||||
|
if (section.type === "table") continue;
|
||||||
|
|
||||||
|
for (const field of (section.fields || [])) {
|
||||||
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
|
||||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||||
if (response.success && response.data?.generatedCode) {
|
if (response.success && response.data?.generatedCode) {
|
||||||
|
|
@ -822,12 +877,140 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
|
||||||
|
// targetTable이 없거나 메인 테이블과 같은 경우
|
||||||
|
const tableSectionsForMainTable = config.sections.filter(
|
||||||
|
(s) => s.type === "table" &&
|
||||||
|
(!s.tableConfig?.saveConfig?.targetTable ||
|
||||||
|
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableSectionsForMainTable.length > 0) {
|
||||||
|
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
||||||
|
const commonFieldsData: Record<string, any> = {};
|
||||||
|
const { sectionSaveModes } = config.saveConfig;
|
||||||
|
|
||||||
|
// 필드 타입 섹션에서 공통 저장 필드 수집
|
||||||
|
for (const section of config.sections) {
|
||||||
|
if (section.type === "table") continue;
|
||||||
|
|
||||||
|
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
|
||||||
|
const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
|
||||||
|
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||||
|
|
||||||
|
if (section.fields) {
|
||||||
|
for (const field of section.fields) {
|
||||||
|
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||||
|
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||||
|
|
||||||
|
if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
|
||||||
|
commonFieldsData[field.columnName] = dataToSave[field.columnName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
|
||||||
|
for (const tableSection of tableSectionsForMainTable) {
|
||||||
|
const sectionData = tableSectionData[tableSection.id] || [];
|
||||||
|
|
||||||
|
if (sectionData.length > 0) {
|
||||||
|
// 품목별로 행 저장
|
||||||
|
for (const item of sectionData) {
|
||||||
|
const rowToSave = { ...commonFieldsData, ...item };
|
||||||
|
|
||||||
|
// _sourceData 등 내부 메타데이터 제거
|
||||||
|
Object.keys(rowToSave).forEach((key) => {
|
||||||
|
if (key.startsWith("_")) {
|
||||||
|
delete rowToSave[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${config.saveConfig.tableName}/add`,
|
||||||
|
rowToSave
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.data?.success) {
|
||||||
|
throw new Error(response.data?.message || "품목 저장 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
|
||||||
|
delete tableSectionData[tableSection.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
|
||||||
|
// 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
|
||||||
|
const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
|
||||||
|
if (!hasOtherTableSections) {
|
||||||
|
return; // 메인 테이블에 저장할 품목이 없으면 종료
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
|
||||||
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
|
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
|
||||||
|
|
||||||
if (!response.data?.success) {
|
if (!response.data?.success) {
|
||||||
throw new Error(response.data?.message || "저장 실패");
|
throw new Error(response.data?.message || "저장 실패");
|
||||||
}
|
}
|
||||||
}, [config.sections, config.saveConfig.tableName, formData]);
|
|
||||||
|
// 테이블 섹션 데이터 저장 (별도 테이블에)
|
||||||
|
for (const section of config.sections) {
|
||||||
|
if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) {
|
||||||
|
const sectionData = tableSectionData[section.id];
|
||||||
|
if (sectionData && sectionData.length > 0) {
|
||||||
|
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
|
||||||
|
const mainRecordId = response.data?.data?.id;
|
||||||
|
|
||||||
|
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
||||||
|
const commonFieldsData: Record<string, any> = {};
|
||||||
|
const { sectionSaveModes } = config.saveConfig;
|
||||||
|
|
||||||
|
if (sectionSaveModes && sectionSaveModes.length > 0) {
|
||||||
|
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
|
||||||
|
for (const otherSection of config.sections) {
|
||||||
|
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
|
||||||
|
|
||||||
|
const sectionMode = sectionSaveModes.find((s) => s.sectionId === otherSection.id);
|
||||||
|
const defaultMode = otherSection.type === "table" ? "individual" : "common";
|
||||||
|
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||||
|
|
||||||
|
// 필드 타입 섹션의 필드들 처리
|
||||||
|
if (otherSection.type !== "table" && otherSection.fields) {
|
||||||
|
for (const field of otherSection.fields) {
|
||||||
|
// 필드별 오버라이드 확인
|
||||||
|
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||||
|
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||||
|
|
||||||
|
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
|
||||||
|
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
|
||||||
|
commonFieldsData[field.columnName] = formData[field.columnName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of sectionData) {
|
||||||
|
// 공통 필드 병합 + 개별 품목 데이터
|
||||||
|
const itemToSave = { ...commonFieldsData, ...item };
|
||||||
|
|
||||||
|
// 메인 레코드와 연결이 필요한 경우
|
||||||
|
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
||||||
|
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.post(
|
||||||
|
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
|
||||||
|
itemToSave
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, config.saveConfig.sectionSaveModes, formData]);
|
||||||
|
|
||||||
// 다중 행 저장 (겸직 등)
|
// 다중 행 저장 (겸직 등)
|
||||||
const saveMultipleRows = useCallback(async () => {
|
const saveMultipleRows = useCallback(async () => {
|
||||||
|
|
@ -901,9 +1084,9 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 저장 시점 채번규칙 처리 (메인 행만)
|
// 저장 시점 채번규칙 처리 (메인 행만)
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
if (section.repeatable) continue;
|
if (section.repeatable || section.type === "table") continue;
|
||||||
|
|
||||||
for (const field of section.fields || []) {
|
for (const field of (section.fields || [])) {
|
||||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
||||||
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
||||||
|
|
@ -951,7 +1134,7 @@ export function UniversalFormModalComponent({
|
||||||
// 1. 메인 테이블 데이터 구성
|
// 1. 메인 테이블 데이터 구성
|
||||||
const mainData: Record<string, any> = {};
|
const mainData: Record<string, any> = {};
|
||||||
config.sections.forEach((section) => {
|
config.sections.forEach((section) => {
|
||||||
if (section.repeatable) return; // 반복 섹션은 제외
|
if (section.repeatable || section.type === "table") return; // 반복 섹션 및 테이블 타입 제외
|
||||||
(section.fields || []).forEach((field) => {
|
(section.fields || []).forEach((field) => {
|
||||||
const value = formData[field.columnName];
|
const value = formData[field.columnName];
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
if (value !== undefined && value !== null && value !== "") {
|
||||||
|
|
@ -962,9 +1145,9 @@ export function UniversalFormModalComponent({
|
||||||
|
|
||||||
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
// 1-1. 채번규칙 처리 (저장 시점에 실제 순번 할당)
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
if (section.repeatable) continue;
|
if (section.repeatable || section.type === "table") continue;
|
||||||
|
|
||||||
for (const field of section.fields || []) {
|
for (const field of (section.fields || [])) {
|
||||||
// 채번규칙이 활성화된 필드 처리
|
// 채번규칙이 활성화된 필드 처리
|
||||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||||
// 신규 생성이거나 값이 없는 경우에만 채번
|
// 신규 생성이거나 값이 없는 경우에만 채번
|
||||||
|
|
@ -1054,7 +1237,7 @@ export function UniversalFormModalComponent({
|
||||||
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
|
// 또는 메인 섹션의 필드 중 같은 이름이 있으면 매핑
|
||||||
else {
|
else {
|
||||||
config.sections.forEach((section) => {
|
config.sections.forEach((section) => {
|
||||||
if (section.repeatable) return;
|
if (section.repeatable || section.type === "table") return;
|
||||||
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
|
const matchingField = (section.fields || []).find((f) => f.columnName === mapping.targetColumn);
|
||||||
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
if (matchingField && mainData[matchingField.columnName] !== undefined) {
|
||||||
mainFieldMappings!.push({
|
mainFieldMappings!.push({
|
||||||
|
|
@ -1535,10 +1718,36 @@ export function UniversalFormModalComponent({
|
||||||
const isCollapsed = collapsedSections.has(section.id);
|
const isCollapsed = collapsedSections.has(section.id);
|
||||||
const sectionColumns = section.columns || 2;
|
const sectionColumns = section.columns || 2;
|
||||||
|
|
||||||
|
// 반복 섹션
|
||||||
if (section.repeatable) {
|
if (section.repeatable) {
|
||||||
return renderRepeatableSection(section, isCollapsed);
|
return renderRepeatableSection(section, isCollapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 타입 섹션
|
||||||
|
if (section.type === "table" && section.tableConfig) {
|
||||||
|
return (
|
||||||
|
<Card key={section.id} className="mb-4">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">{section.title}</CardTitle>
|
||||||
|
{section.description && <CardDescription className="text-xs">{section.description}</CardDescription>}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TableSectionRenderer
|
||||||
|
sectionId={section.id}
|
||||||
|
tableConfig={section.tableConfig}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={handleFieldChange}
|
||||||
|
onTableDataChange={(data) => {
|
||||||
|
// 테이블 섹션 데이터를 formData에 저장
|
||||||
|
handleFieldChange(`_tableSection_${section.id}`, data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 필드 타입 섹션
|
||||||
return (
|
return (
|
||||||
<Card key={section.id} className="mb-4">
|
<Card key={section.id} className="mb-4">
|
||||||
{section.collapsible ? (
|
{section.collapsible ? (
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
Database,
|
Database,
|
||||||
Layout,
|
Layout,
|
||||||
|
Table,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -27,9 +28,11 @@ import {
|
||||||
FormSectionConfig,
|
FormSectionConfig,
|
||||||
FormFieldConfig,
|
FormFieldConfig,
|
||||||
MODAL_SIZE_OPTIONS,
|
MODAL_SIZE_OPTIONS,
|
||||||
|
SECTION_TYPE_OPTIONS,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
defaultSectionConfig,
|
defaultSectionConfig,
|
||||||
|
defaultTableSectionConfig,
|
||||||
generateSectionId,
|
generateSectionId,
|
||||||
} from "./config";
|
} from "./config";
|
||||||
|
|
||||||
|
|
@ -37,6 +40,7 @@ import {
|
||||||
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
|
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
|
||||||
import { SaveSettingsModal } from "./modals/SaveSettingsModal";
|
import { SaveSettingsModal } from "./modals/SaveSettingsModal";
|
||||||
import { SectionLayoutModal } from "./modals/SectionLayoutModal";
|
import { SectionLayoutModal } from "./modals/SectionLayoutModal";
|
||||||
|
import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
|
||||||
|
|
||||||
// 도움말 텍스트 컴포넌트
|
// 도움말 텍스트 컴포넌트
|
||||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -57,6 +61,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false);
|
const [saveSettingsModalOpen, setSaveSettingsModalOpen] = useState(false);
|
||||||
const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false);
|
const [sectionLayoutModalOpen, setSectionLayoutModalOpen] = useState(false);
|
||||||
const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false);
|
const [fieldDetailModalOpen, setFieldDetailModalOpen] = useState(false);
|
||||||
|
const [tableSectionSettingsModalOpen, setTableSectionSettingsModalOpen] = useState(false);
|
||||||
const [selectedSection, setSelectedSection] = useState<FormSectionConfig | null>(null);
|
const [selectedSection, setSelectedSection] = useState<FormSectionConfig | null>(null);
|
||||||
const [selectedField, setSelectedField] = useState<FormFieldConfig | null>(null);
|
const [selectedField, setSelectedField] = useState<FormFieldConfig | null>(null);
|
||||||
|
|
||||||
|
|
@ -95,23 +100,25 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||||
const data = response.data?.data;
|
// API 응답 구조: { success, data: { columns: [...], total, page, ... } }
|
||||||
|
const columns = response.data?.data?.columns;
|
||||||
|
|
||||||
if (response.data?.success && Array.isArray(data)) {
|
if (response.data?.success && Array.isArray(columns)) {
|
||||||
setTableColumns((prev) => ({
|
setTableColumns((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[tableName]: data.map(
|
[tableName]: columns.map(
|
||||||
(c: {
|
(c: {
|
||||||
columnName?: string;
|
columnName?: string;
|
||||||
column_name?: string;
|
column_name?: string;
|
||||||
dataType?: string;
|
dataType?: string;
|
||||||
data_type?: string;
|
data_type?: string;
|
||||||
|
displayName?: string;
|
||||||
columnComment?: string;
|
columnComment?: string;
|
||||||
column_comment?: string;
|
column_comment?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
name: c.columnName || c.column_name || "",
|
name: c.columnName || c.column_name || "",
|
||||||
type: c.dataType || c.data_type || "text",
|
type: c.dataType || c.data_type || "text",
|
||||||
label: c.columnComment || c.column_comment || c.columnName || c.column_name || "",
|
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
@ -159,17 +166,55 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
);
|
);
|
||||||
|
|
||||||
// 섹션 관리
|
// 섹션 관리
|
||||||
const addSection = useCallback(() => {
|
const addSection = useCallback((type: "fields" | "table" = "fields") => {
|
||||||
const newSection: FormSectionConfig = {
|
const newSection: FormSectionConfig = {
|
||||||
...defaultSectionConfig,
|
...defaultSectionConfig,
|
||||||
id: generateSectionId(),
|
id: generateSectionId(),
|
||||||
title: `섹션 ${config.sections.length + 1}`,
|
title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
|
||||||
|
type,
|
||||||
|
fields: type === "fields" ? [] : undefined,
|
||||||
|
tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
|
||||||
};
|
};
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
sections: [...config.sections, newSection],
|
sections: [...config.sections, newSection],
|
||||||
});
|
});
|
||||||
}, [config, onChange]);
|
}, [config, onChange]);
|
||||||
|
|
||||||
|
// 섹션 타입 변경
|
||||||
|
const changeSectionType = useCallback(
|
||||||
|
(sectionId: string, newType: "fields" | "table") => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
sections: config.sections.map((s) => {
|
||||||
|
if (s.id !== sectionId) return s;
|
||||||
|
|
||||||
|
if (newType === "table") {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
type: "table",
|
||||||
|
fields: undefined,
|
||||||
|
tableConfig: { ...defaultTableSectionConfig },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
type: "fields",
|
||||||
|
fields: [],
|
||||||
|
tableConfig: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[config, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테이블 섹션 설정 모달 열기
|
||||||
|
const handleOpenTableSectionSettings = (section: FormSectionConfig) => {
|
||||||
|
setSelectedSection(section);
|
||||||
|
setTableSectionSettingsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const updateSection = useCallback(
|
const updateSection = useCallback(
|
||||||
(sectionId: string, updates: Partial<FormSectionConfig>) => {
|
(sectionId: string, updates: Partial<FormSectionConfig>) => {
|
||||||
|
|
@ -365,39 +410,56 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
|
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
|
||||||
<Button size="sm" variant="outline" onClick={addSection} className="h-9 text-xs w-full max-w-full">
|
{/* 섹션 추가 버튼들 */}
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<div className="flex gap-2 w-full min-w-0">
|
||||||
섹션 추가
|
<Button size="sm" variant="outline" onClick={() => addSection("fields")} className="h-9 text-xs flex-1 min-w-0">
|
||||||
</Button>
|
<Plus className="h-4 w-4 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">필드 섹션</span>
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => addSection("table")} className="h-9 text-xs flex-1 min-w-0">
|
||||||
|
<Table className="h-4 w-4 mr-1 shrink-0" />
|
||||||
|
<span className="truncate">테이블 섹션</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
폼을 여러 섹션으로 나누어 구성할 수 있습니다.
|
필드 섹션: 일반 입력 필드들을 배치합니다.
|
||||||
<br />
|
<br />
|
||||||
예: 기본 정보, 배송 정보, 결제 정보
|
테이블 섹션: 품목 목록 등 반복 테이블 형식 데이터를 관리합니다.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
|
||||||
{config.sections.length === 0 ? (
|
{config.sections.length === 0 ? (
|
||||||
<div className="text-center py-12 border border-dashed rounded-lg w-full bg-muted/20">
|
<div className="text-center py-12 border border-dashed rounded-lg w-full bg-muted/20">
|
||||||
<p className="text-sm text-muted-foreground mb-2 font-medium">섹션이 없습니다</p>
|
<p className="text-sm text-muted-foreground mb-2 font-medium">섹션이 없습니다</p>
|
||||||
<p className="text-xs text-muted-foreground">"섹션 추가" 버튼으로 폼 섹션을 만드세요</p>
|
<p className="text-xs text-muted-foreground">위 버튼으로 섹션을 추가하세요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 w-full min-w-0">
|
<div className="space-y-3 w-full min-w-0">
|
||||||
{config.sections.map((section, index) => (
|
{config.sections.map((section, index) => (
|
||||||
<div key={section.id} className="border rounded-lg p-3 bg-card w-full min-w-0 overflow-hidden space-y-3">
|
<div key={section.id} className="border rounded-lg p-3 bg-card w-full min-w-0 overflow-hidden space-y-3">
|
||||||
{/* 헤더: 제목 + 삭제 */}
|
{/* 헤더: 제목 + 타입 배지 + 삭제 */}
|
||||||
<div className="flex items-start justify-between gap-3 w-full min-w-0">
|
<div className="flex items-start justify-between gap-3 w-full min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<span className="text-sm font-medium truncate">{section.title}</span>
|
<span className="text-sm font-medium truncate">{section.title}</span>
|
||||||
{section.repeatable && (
|
{section.type === "table" ? (
|
||||||
|
<Badge variant="outline" className="text-xs px-1.5 py-0.5 text-purple-600 bg-purple-50 border-purple-200">
|
||||||
|
테이블
|
||||||
|
</Badge>
|
||||||
|
) : section.repeatable ? (
|
||||||
<Badge variant="outline" className="text-xs px-1.5 py-0.5">
|
<Badge variant="outline" className="text-xs px-1.5 py-0.5">
|
||||||
반복
|
반복
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="text-xs px-2 py-0.5">
|
{section.type === "table" ? (
|
||||||
{section.fields.length}개 필드
|
<Badge variant="secondary" className="text-xs px-2 py-0.5">
|
||||||
</Badge>
|
{section.tableConfig?.source?.tableName || "(소스 미설정)"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="text-xs px-2 py-0.5">
|
||||||
|
{(section.fields || []).length}개 필드
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -435,10 +497,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필드 목록 */}
|
{/* 필드 목록 (필드 타입만) */}
|
||||||
{section.fields.length > 0 && (
|
{section.type !== "table" && (section.fields || []).length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
||||||
{section.fields.slice(0, 4).map((field) => (
|
{(section.fields || []).slice(0, 4).map((field) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={field.id}
|
key={field.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -447,24 +509,56 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
{field.label}
|
{field.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{section.fields.length > 4 && (
|
{(section.fields || []).length > 4 && (
|
||||||
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
|
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
|
||||||
+{section.fields.length - 4}
|
+{(section.fields || []).length - 4}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
||||||
|
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
||||||
|
{section.tableConfig.columns.slice(0, 4).map((col) => (
|
||||||
|
<Badge
|
||||||
|
key={col.field}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{section.tableConfig.columns.length > 4 && (
|
||||||
|
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
|
||||||
|
+{section.tableConfig.columns.length - 4}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 레이아웃 설정 버튼 */}
|
{/* 설정 버튼 (타입에 따라 다름) */}
|
||||||
<Button
|
{section.type === "table" ? (
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={() => handleOpenSectionLayout(section)}
|
variant="outline"
|
||||||
className="h-9 text-xs w-full"
|
onClick={() => handleOpenTableSectionSettings(section)}
|
||||||
>
|
className="h-9 text-xs w-full"
|
||||||
<Layout className="h-4 w-4 mr-2" />
|
>
|
||||||
레이아웃 설정
|
<Table className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
테이블 설정
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenSectionLayout(section)}
|
||||||
|
className="h-9 text-xs w-full"
|
||||||
|
>
|
||||||
|
<Layout className="h-4 w-4 mr-2" />
|
||||||
|
레이아웃 설정
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -530,7 +624,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
const updatedSection = {
|
const updatedSection = {
|
||||||
...selectedSection,
|
...selectedSection,
|
||||||
// 기본 필드 목록에서 업데이트
|
// 기본 필드 목록에서 업데이트
|
||||||
fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
|
fields: (selectedSection.fields || []).map((f) => (f.id === updatedField.id ? updatedField : f)),
|
||||||
// 옵셔널 필드 그룹 내 필드도 업데이트
|
// 옵셔널 필드 그룹 내 필드도 업데이트
|
||||||
optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({
|
optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
|
|
@ -558,6 +652,46 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
||||||
onLoadTableColumns={loadTableColumns}
|
onLoadTableColumns={loadTableColumns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 섹션 설정 모달 */}
|
||||||
|
{selectedSection && selectedSection.type === "table" && (
|
||||||
|
<TableSectionSettingsModal
|
||||||
|
open={tableSectionSettingsModalOpen}
|
||||||
|
onOpenChange={setTableSectionSettingsModalOpen}
|
||||||
|
section={selectedSection}
|
||||||
|
onSave={(updates) => {
|
||||||
|
const updatedSection = {
|
||||||
|
...selectedSection,
|
||||||
|
...updates,
|
||||||
|
};
|
||||||
|
|
||||||
|
// config 업데이트
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
sections: config.sections.map((s) =>
|
||||||
|
s.id === selectedSection.id ? updatedSection : s
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedSection(updatedSection);
|
||||||
|
setTableSectionSettingsModalOpen(false);
|
||||||
|
}}
|
||||||
|
tables={tables.map(t => ({ table_name: t.name, comment: t.label }))}
|
||||||
|
tableColumns={Object.fromEntries(
|
||||||
|
Object.entries(tableColumns).map(([tableName, cols]) => [
|
||||||
|
tableName,
|
||||||
|
cols.map(c => ({
|
||||||
|
column_name: c.name,
|
||||||
|
data_type: c.type,
|
||||||
|
is_nullable: "YES",
|
||||||
|
comment: c.label,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
)}
|
||||||
|
onLoadTableColumns={loadTableColumns}
|
||||||
|
allSections={config.sections as FormSectionConfig[]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,16 @@
|
||||||
* 범용 폼 모달 컴포넌트 기본 설정
|
* 범용 폼 모달 컴포넌트 기본 설정
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { UniversalFormModalConfig } from "./types";
|
import {
|
||||||
|
UniversalFormModalConfig,
|
||||||
|
TableSectionConfig,
|
||||||
|
TableColumnConfig,
|
||||||
|
ValueMappingConfig,
|
||||||
|
ColumnModeConfig,
|
||||||
|
TablePreFilter,
|
||||||
|
TableModalFilter,
|
||||||
|
TableCalculationRule,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
export const defaultConfig: UniversalFormModalConfig = {
|
export const defaultConfig: UniversalFormModalConfig = {
|
||||||
|
|
@ -77,6 +86,7 @@ export const defaultSectionConfig = {
|
||||||
id: "",
|
id: "",
|
||||||
title: "새 섹션",
|
title: "새 섹션",
|
||||||
description: "",
|
description: "",
|
||||||
|
type: "fields" as const,
|
||||||
collapsible: false,
|
collapsible: false,
|
||||||
defaultCollapsed: false,
|
defaultCollapsed: false,
|
||||||
columns: 2,
|
columns: 2,
|
||||||
|
|
@ -95,6 +105,97 @@ export const defaultSectionConfig = {
|
||||||
linkedFieldGroups: [],
|
linkedFieldGroups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 테이블 섹션 관련 기본값
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 기본 테이블 섹션 설정
|
||||||
|
export const defaultTableSectionConfig: TableSectionConfig = {
|
||||||
|
source: {
|
||||||
|
tableName: "",
|
||||||
|
displayColumns: [],
|
||||||
|
searchColumns: [],
|
||||||
|
columnLabels: {},
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
preFilters: [],
|
||||||
|
modalFilters: [],
|
||||||
|
},
|
||||||
|
columns: [],
|
||||||
|
calculations: [],
|
||||||
|
saveConfig: {
|
||||||
|
targetTable: undefined,
|
||||||
|
uniqueField: undefined,
|
||||||
|
},
|
||||||
|
uiConfig: {
|
||||||
|
addButtonText: "항목 검색",
|
||||||
|
modalTitle: "항목 검색 및 선택",
|
||||||
|
multiSelect: true,
|
||||||
|
maxHeight: "400px",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 테이블 컬럼 설정
|
||||||
|
export const defaultTableColumnConfig: TableColumnConfig = {
|
||||||
|
field: "",
|
||||||
|
label: "",
|
||||||
|
type: "text",
|
||||||
|
editable: true,
|
||||||
|
calculated: false,
|
||||||
|
required: false,
|
||||||
|
width: "150px",
|
||||||
|
minWidth: "60px",
|
||||||
|
maxWidth: "400px",
|
||||||
|
defaultValue: undefined,
|
||||||
|
selectOptions: [],
|
||||||
|
valueMapping: undefined,
|
||||||
|
columnModes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 값 매핑 설정
|
||||||
|
export const defaultValueMappingConfig: ValueMappingConfig = {
|
||||||
|
type: "source",
|
||||||
|
sourceField: "",
|
||||||
|
externalRef: undefined,
|
||||||
|
internalField: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 컬럼 모드 설정
|
||||||
|
export const defaultColumnModeConfig: ColumnModeConfig = {
|
||||||
|
id: "",
|
||||||
|
label: "",
|
||||||
|
isDefault: false,
|
||||||
|
valueMapping: {
|
||||||
|
type: "source",
|
||||||
|
sourceField: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 사전 필터 설정
|
||||||
|
export const defaultPreFilterConfig: TablePreFilter = {
|
||||||
|
column: "",
|
||||||
|
operator: "=",
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 모달 필터 설정
|
||||||
|
export const defaultModalFilterConfig: TableModalFilter = {
|
||||||
|
column: "",
|
||||||
|
label: "",
|
||||||
|
type: "category",
|
||||||
|
categoryRef: undefined,
|
||||||
|
options: [],
|
||||||
|
optionsFromTable: undefined,
|
||||||
|
defaultValue: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본 계산 규칙 설정
|
||||||
|
export const defaultCalculationRuleConfig: TableCalculationRule = {
|
||||||
|
resultField: "",
|
||||||
|
formula: "",
|
||||||
|
dependencies: [],
|
||||||
|
};
|
||||||
|
|
||||||
// 기본 옵셔널 필드 그룹 설정
|
// 기본 옵셔널 필드 그룹 설정
|
||||||
export const defaultOptionalFieldGroupConfig = {
|
export const defaultOptionalFieldGroupConfig = {
|
||||||
id: "",
|
id: "",
|
||||||
|
|
@ -184,3 +285,18 @@ export const generateFieldId = (): string => {
|
||||||
export const generateLinkedFieldGroupId = (): string => {
|
export const generateLinkedFieldGroupId = (): string => {
|
||||||
return generateUniqueId("linked");
|
return generateUniqueId("linked");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 유틸리티: 테이블 컬럼 ID 생성
|
||||||
|
export const generateTableColumnId = (): string => {
|
||||||
|
return generateUniqueId("tcol");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유틸리티: 컬럼 모드 ID 생성
|
||||||
|
export const generateColumnModeId = (): string => {
|
||||||
|
return generateUniqueId("mode");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유틸리티: 필터 ID 생성
|
||||||
|
export const generateFilterId = (): string => {
|
||||||
|
return generateUniqueId("filter");
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
|
||||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Plus, Trash2, Database, Layers } from "lucide-react";
|
import { Plus, Trash2, Database, Layers, Info } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig } from "../types";
|
import { SaveConfig, SubTableSaveConfig, SubTableFieldMapping, FormSectionConfig, FormFieldConfig, SectionSaveMode } from "../types";
|
||||||
|
|
||||||
// 도움말 텍스트 컴포넌트
|
// 도움말 텍스트 컴포넌트
|
||||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
|
@ -219,19 +220,112 @@ export function SaveSettingsModal({
|
||||||
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
|
const getAllFields = (): { columnName: string; label: string; sectionTitle: string }[] => {
|
||||||
const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
|
const fields: { columnName: string; label: string; sectionTitle: string }[] = [];
|
||||||
sections.forEach((section) => {
|
sections.forEach((section) => {
|
||||||
section.fields.forEach((field) => {
|
// 필드 타입 섹션만 처리 (테이블 타입은 fields가 undefined)
|
||||||
fields.push({
|
if (section.fields && Array.isArray(section.fields)) {
|
||||||
columnName: field.columnName,
|
section.fields.forEach((field) => {
|
||||||
label: field.label,
|
fields.push({
|
||||||
sectionTitle: section.title,
|
columnName: field.columnName,
|
||||||
|
label: field.label,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
return fields;
|
return fields;
|
||||||
};
|
};
|
||||||
|
|
||||||
const allFields = getAllFields();
|
const allFields = getAllFields();
|
||||||
|
|
||||||
|
// 섹션별 저장 방식 조회 (없으면 기본값 반환)
|
||||||
|
const getSectionSaveMode = (sectionId: string, sectionType: "fields" | "table"): "common" | "individual" => {
|
||||||
|
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
|
||||||
|
if (sectionMode) {
|
||||||
|
return sectionMode.saveMode;
|
||||||
|
}
|
||||||
|
// 기본값: fields 타입은 공통 저장, table 타입은 개별 저장
|
||||||
|
return sectionType === "fields" ? "common" : "individual";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드별 저장 방식 조회 (오버라이드 확인)
|
||||||
|
const getFieldSaveMode = (sectionId: string, fieldName: string, sectionType: "fields" | "table"): "common" | "individual" => {
|
||||||
|
const sectionMode = localSaveConfig.sectionSaveModes?.find((s) => s.sectionId === sectionId);
|
||||||
|
if (sectionMode) {
|
||||||
|
// 필드별 오버라이드 확인
|
||||||
|
const fieldOverride = sectionMode.fieldOverrides?.find((f) => f.fieldName === fieldName);
|
||||||
|
if (fieldOverride) {
|
||||||
|
return fieldOverride.saveMode;
|
||||||
|
}
|
||||||
|
return sectionMode.saveMode;
|
||||||
|
}
|
||||||
|
// 기본값
|
||||||
|
return sectionType === "fields" ? "common" : "individual";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 섹션별 저장 방식 업데이트
|
||||||
|
const updateSectionSaveMode = (sectionId: string, mode: "common" | "individual") => {
|
||||||
|
const currentModes = localSaveConfig.sectionSaveModes || [];
|
||||||
|
const existingIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
|
||||||
|
|
||||||
|
let newModes: SectionSaveMode[];
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
newModes = [...currentModes];
|
||||||
|
newModes[existingIndex] = { ...newModes[existingIndex], saveMode: mode };
|
||||||
|
} else {
|
||||||
|
newModes = [...currentModes, { sectionId, saveMode: mode }];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSaveConfig({ sectionSaveModes: newModes });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드별 오버라이드 토글
|
||||||
|
const toggleFieldOverride = (sectionId: string, fieldName: string, sectionType: "fields" | "table") => {
|
||||||
|
const currentModes = localSaveConfig.sectionSaveModes || [];
|
||||||
|
const sectionIndex = currentModes.findIndex((s) => s.sectionId === sectionId);
|
||||||
|
|
||||||
|
// 섹션 설정이 없으면 먼저 생성
|
||||||
|
let newModes = [...currentModes];
|
||||||
|
if (sectionIndex < 0) {
|
||||||
|
const defaultMode = sectionType === "fields" ? "common" : "individual";
|
||||||
|
newModes.push({ sectionId, saveMode: defaultMode, fieldOverrides: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = newModes.findIndex((s) => s.sectionId === sectionId);
|
||||||
|
const sectionMode = newModes[targetIndex];
|
||||||
|
const currentFieldOverrides = sectionMode.fieldOverrides || [];
|
||||||
|
const fieldOverrideIndex = currentFieldOverrides.findIndex((f) => f.fieldName === fieldName);
|
||||||
|
|
||||||
|
let newFieldOverrides;
|
||||||
|
if (fieldOverrideIndex >= 0) {
|
||||||
|
// 이미 오버라이드가 있으면 제거 (섹션 기본값으로 돌아감)
|
||||||
|
newFieldOverrides = currentFieldOverrides.filter((f) => f.fieldName !== fieldName);
|
||||||
|
} else {
|
||||||
|
// 오버라이드 추가 (섹션 기본값의 반대)
|
||||||
|
const oppositeMode = sectionMode.saveMode === "common" ? "individual" : "common";
|
||||||
|
newFieldOverrides = [...currentFieldOverrides, { fieldName, saveMode: oppositeMode }];
|
||||||
|
}
|
||||||
|
|
||||||
|
newModes[targetIndex] = { ...sectionMode, fieldOverrides: newFieldOverrides };
|
||||||
|
updateSaveConfig({ sectionSaveModes: newModes });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 섹션의 필드 목록 가져오기
|
||||||
|
const getSectionFields = (section: FormSectionConfig): { fieldName: string; label: string }[] => {
|
||||||
|
if (section.type === "table" && section.tableConfig) {
|
||||||
|
// 테이블 타입: tableConfig.columns에서 필드 목록 가져오기
|
||||||
|
return (section.tableConfig.columns || []).map((col) => ({
|
||||||
|
fieldName: col.field,
|
||||||
|
label: col.label,
|
||||||
|
}));
|
||||||
|
} else if (section.fields) {
|
||||||
|
// 필드 타입: fields에서 목록 가져오기
|
||||||
|
return section.fields.map((field) => ({
|
||||||
|
fieldName: field.columnName,
|
||||||
|
label: field.label,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||||
|
|
@ -721,6 +815,150 @@ export function SaveSettingsModal({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 섹션별 저장 방식 */}
|
||||||
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="h-4 w-4 text-green-600" />
|
||||||
|
<h3 className="text-xs font-semibold">섹션별 저장 방식</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1.5">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Info className="h-3.5 w-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">공통 저장:</span> 이 섹션의 필드 값이 모든 품목 행에 <span className="font-medium">동일하게</span> 저장됩니다
|
||||||
|
<br />
|
||||||
|
<span className="text-[9px] text-muted-foreground/80">예: 수주번호, 거래처, 수주일 - 품목이 3개면 3개 행 모두 같은 값</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
<span className="font-medium text-foreground">개별 저장:</span> 이 섹션의 필드 값이 각 품목마다 <span className="font-medium">다르게</span> 저장됩니다
|
||||||
|
<br />
|
||||||
|
<span className="text-[9px] text-muted-foreground/80">예: 품목코드, 수량, 단가 - 품목마다 다른 값</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 섹션 목록 */}
|
||||||
|
{sections.length === 0 ? (
|
||||||
|
<div className="text-center py-4 border border-dashed rounded-lg">
|
||||||
|
<p className="text-[10px] text-muted-foreground">섹션이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Accordion type="multiple" className="space-y-2">
|
||||||
|
{sections.map((section) => {
|
||||||
|
const sectionType = section.type || "fields";
|
||||||
|
const currentMode = getSectionSaveMode(section.id, sectionType);
|
||||||
|
const sectionFields = getSectionFields(section);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItem
|
||||||
|
key={section.id}
|
||||||
|
value={section.id}
|
||||||
|
className={cn(
|
||||||
|
"border rounded-lg",
|
||||||
|
currentMode === "common" ? "bg-blue-50/30" : "bg-orange-50/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-3 py-2 text-xs hover:no-underline">
|
||||||
|
<div className="flex items-center justify-between flex-1 mr-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{section.title}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[8px] h-4",
|
||||||
|
sectionType === "table" ? "border-orange-300 text-orange-600" : "border-blue-300 text-blue-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sectionType === "table" ? "테이블" : "필드"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={currentMode === "common" ? "default" : "secondary"}
|
||||||
|
className="text-[8px] h-4"
|
||||||
|
>
|
||||||
|
{currentMode === "common" ? "공통 저장" : "개별 저장"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-3 pb-3 space-y-3">
|
||||||
|
{/* 저장 방식 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] font-medium">저장 방식</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={currentMode}
|
||||||
|
onValueChange={(value) => updateSectionSaveMode(section.id, value as "common" | "individual")}
|
||||||
|
className="flex gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<RadioGroupItem value="common" id={`${section.id}-common`} className="h-3 w-3" />
|
||||||
|
<Label htmlFor={`${section.id}-common`} className="text-[10px] cursor-pointer">
|
||||||
|
공통 저장
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1.5">
|
||||||
|
<RadioGroupItem value="individual" id={`${section.id}-individual`} className="h-3 w-3" />
|
||||||
|
<Label htmlFor={`${section.id}-individual`} className="text-[10px] cursor-pointer">
|
||||||
|
개별 저장
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필드 목록 */}
|
||||||
|
{sectionFields.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[10px] font-medium">필드 목록 ({sectionFields.length}개)</Label>
|
||||||
|
<HelpText>필드를 클릭하면 섹션 기본값과 다르게 설정할 수 있습니다</HelpText>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{sectionFields.map((field) => {
|
||||||
|
const fieldMode = getFieldSaveMode(section.id, field.fieldName, sectionType);
|
||||||
|
const isOverridden = fieldMode !== currentMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={field.fieldName}
|
||||||
|
onClick={() => toggleFieldOverride(section.id, field.fieldName, sectionType)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between px-2 py-1.5 rounded border text-left transition-colors",
|
||||||
|
isOverridden
|
||||||
|
? "border-amber-300 bg-amber-50"
|
||||||
|
: "border-gray-200 bg-white hover:bg-gray-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[9px] truncate flex-1">
|
||||||
|
{field.label}
|
||||||
|
<span className="text-muted-foreground ml-1">({field.fieldName})</span>
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={fieldMode === "common" ? "default" : "secondary"}
|
||||||
|
className={cn(
|
||||||
|
"text-[7px] h-3.5 ml-1 shrink-0",
|
||||||
|
isOverridden && "ring-1 ring-amber-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fieldMode === "common" ? "공통" : "개별"}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 저장 후 동작 */}
|
{/* 저장 후 동작 */}
|
||||||
<div className="space-y-2 border rounded-lg p-3 bg-card">
|
<div className="space-y-2 border rounded-lg p-3 bg-card">
|
||||||
<h3 className="text-xs font-semibold">저장 후 동작</h3>
|
<h3 className="text-xs font-semibold">저장 후 동작</h3>
|
||||||
|
|
|
||||||
|
|
@ -37,13 +37,19 @@ export function SectionLayoutModal({
|
||||||
onOpenFieldDetail,
|
onOpenFieldDetail,
|
||||||
}: SectionLayoutModalProps) {
|
}: SectionLayoutModalProps) {
|
||||||
|
|
||||||
// 로컬 상태로 섹션 관리
|
// 로컬 상태로 섹션 관리 (fields가 없으면 빈 배열로 초기화)
|
||||||
const [localSection, setLocalSection] = useState<FormSectionConfig>(section);
|
const [localSection, setLocalSection] = useState<FormSectionConfig>(() => ({
|
||||||
|
...section,
|
||||||
|
fields: section.fields || [],
|
||||||
|
}));
|
||||||
|
|
||||||
// open이 변경될 때마다 데이터 동기화
|
// open이 변경될 때마다 데이터 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setLocalSection(section);
|
setLocalSection({
|
||||||
|
...section,
|
||||||
|
fields: section.fields || [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [open, section]);
|
}, [open, section]);
|
||||||
|
|
||||||
|
|
@ -59,42 +65,45 @@ export function SectionLayoutModal({
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// fields 배열 (안전한 접근)
|
||||||
|
const fields = localSection.fields || [];
|
||||||
|
|
||||||
// 필드 추가
|
// 필드 추가
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
const newField: FormFieldConfig = {
|
const newField: FormFieldConfig = {
|
||||||
...defaultFieldConfig,
|
...defaultFieldConfig,
|
||||||
id: generateFieldId(),
|
id: generateFieldId(),
|
||||||
label: `새 필드 ${localSection.fields.length + 1}`,
|
label: `새 필드 ${fields.length + 1}`,
|
||||||
columnName: `field_${localSection.fields.length + 1}`,
|
columnName: `field_${fields.length + 1}`,
|
||||||
};
|
};
|
||||||
updateSection({
|
updateSection({
|
||||||
fields: [...localSection.fields, newField],
|
fields: [...fields, newField],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 삭제
|
// 필드 삭제
|
||||||
const removeField = (fieldId: string) => {
|
const removeField = (fieldId: string) => {
|
||||||
updateSection({
|
updateSection({
|
||||||
fields: localSection.fields.filter((f) => f.id !== fieldId),
|
fields: fields.filter((f) => f.id !== fieldId),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 업데이트
|
// 필드 업데이트
|
||||||
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
|
const updateField = (fieldId: string, updates: Partial<FormFieldConfig>) => {
|
||||||
updateSection({
|
updateSection({
|
||||||
fields: localSection.fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
|
fields: fields.map((f) => (f.id === fieldId ? { ...f, ...updates } : f)),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필드 이동
|
// 필드 이동
|
||||||
const moveField = (fieldId: string, direction: "up" | "down") => {
|
const moveField = (fieldId: string, direction: "up" | "down") => {
|
||||||
const index = localSection.fields.findIndex((f) => f.id === fieldId);
|
const index = fields.findIndex((f) => f.id === fieldId);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
|
|
||||||
if (direction === "up" && index === 0) return;
|
if (direction === "up" && index === 0) return;
|
||||||
if (direction === "down" && index === localSection.fields.length - 1) return;
|
if (direction === "down" && index === fields.length - 1) return;
|
||||||
|
|
||||||
const newFields = [...localSection.fields];
|
const newFields = [...fields];
|
||||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
[newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]];
|
[newFields[index], newFields[targetIndex]] = [newFields[targetIndex], newFields[index]];
|
||||||
|
|
||||||
|
|
@ -317,7 +326,7 @@ export function SectionLayoutModal({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-xs font-semibold">필드 목록</h3>
|
<h3 className="text-xs font-semibold">필드 목록</h3>
|
||||||
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
|
<Badge variant="secondary" className="text-[9px] px-1.5 py-0">
|
||||||
{localSection.fields.length}개
|
{fields.length}개
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2">
|
<Button size="sm" variant="outline" onClick={addField} className="h-7 text-[10px] px-2">
|
||||||
|
|
@ -330,14 +339,14 @@ export function SectionLayoutModal({
|
||||||
필드를 추가하고 순서를 변경할 수 있습니다. "상세 설정"에서 필드 타입과 옵션을 설정하세요.
|
필드를 추가하고 순서를 변경할 수 있습니다. "상세 설정"에서 필드 타입과 옵션을 설정하세요.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
|
||||||
{localSection.fields.length === 0 ? (
|
{fields.length === 0 ? (
|
||||||
<div className="text-center py-8 border border-dashed rounded-lg">
|
<div className="text-center py-8 border border-dashed rounded-lg">
|
||||||
<p className="text-sm text-muted-foreground mb-2">필드가 없습니다</p>
|
<p className="text-sm text-muted-foreground mb-2">필드가 없습니다</p>
|
||||||
<p className="text-xs text-muted-foreground">위의 "필드 추가" 버튼으로 필드를 추가하세요</p>
|
<p className="text-xs text-muted-foreground">위의 "필드 추가" 버튼으로 필드를 추가하세요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{localSection.fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<div
|
<div
|
||||||
key={field.id}
|
key={field.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -363,7 +372,7 @@ export function SectionLayoutModal({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => moveField(field.id, "down")}
|
onClick={() => moveField(field.id, "down")}
|
||||||
disabled={index === localSection.fields.length - 1}
|
disabled={index === fields.length - 1}
|
||||||
className="h-3 w-5 p-0"
|
className="h-3 w-5 p-0"
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-2.5 w-2.5" />
|
<ChevronDown className="h-2.5 w-2.5" />
|
||||||
|
|
@ -929,7 +938,7 @@ export function SectionLayoutModal({
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} className="h-9 text-sm">
|
<Button onClick={handleSave} className="h-9 text-sm">
|
||||||
저장 ({localSection.fields.length}개 필드)
|
저장 ({fields.length}개 필드)
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -184,7 +184,12 @@ export interface FormSectionConfig {
|
||||||
description?: string;
|
description?: string;
|
||||||
collapsible?: boolean; // 접을 수 있는지 (기본: false)
|
collapsible?: boolean; // 접을 수 있는지 (기본: false)
|
||||||
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
|
defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false)
|
||||||
fields: FormFieldConfig[];
|
|
||||||
|
// 섹션 타입: fields (기본) 또는 table (테이블 형식)
|
||||||
|
type?: "fields" | "table";
|
||||||
|
|
||||||
|
// type: "fields" 일 때 사용
|
||||||
|
fields?: FormFieldConfig[];
|
||||||
|
|
||||||
// 반복 섹션 (겸직 등)
|
// 반복 섹션 (겸직 등)
|
||||||
repeatable?: boolean;
|
repeatable?: boolean;
|
||||||
|
|
@ -199,6 +204,294 @@ export interface FormSectionConfig {
|
||||||
// 섹션 레이아웃
|
// 섹션 레이아웃
|
||||||
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
|
columns?: number; // 필드 배치 컬럼 수 (기본: 2)
|
||||||
gap?: string; // 필드 간 간격
|
gap?: string; // 필드 간 간격
|
||||||
|
|
||||||
|
// type: "table" 일 때 사용
|
||||||
|
tableConfig?: TableSectionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 테이블 섹션 관련 타입 정의
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 섹션 설정
|
||||||
|
* 모달 내에서 테이블 형식으로 데이터를 표시하고 편집하는 섹션
|
||||||
|
*/
|
||||||
|
export interface TableSectionConfig {
|
||||||
|
// 1. 소스 설정 (검색 모달에서 데이터를 가져올 테이블)
|
||||||
|
source: {
|
||||||
|
tableName: string; // 소스 테이블명 (예: item_info)
|
||||||
|
displayColumns: string[]; // 모달에 표시할 컬럼
|
||||||
|
searchColumns: string[]; // 검색 가능한 컬럼
|
||||||
|
columnLabels?: Record<string, string>; // 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 필터 설정
|
||||||
|
filters?: {
|
||||||
|
// 사전 필터 (항상 적용, 사용자에게 노출되지 않음)
|
||||||
|
preFilters?: TablePreFilter[];
|
||||||
|
|
||||||
|
// 모달 내 필터 UI (사용자가 선택 가능)
|
||||||
|
modalFilters?: TableModalFilter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 테이블 컬럼 설정
|
||||||
|
columns: TableColumnConfig[];
|
||||||
|
|
||||||
|
// 4. 계산 규칙
|
||||||
|
calculations?: TableCalculationRule[];
|
||||||
|
|
||||||
|
// 5. 저장 설정
|
||||||
|
saveConfig?: {
|
||||||
|
targetTable?: string; // 다른 테이블에 저장 시 (미지정 시 메인 테이블)
|
||||||
|
uniqueField?: string; // 중복 체크 필드
|
||||||
|
};
|
||||||
|
|
||||||
|
// 6. UI 설정
|
||||||
|
uiConfig?: {
|
||||||
|
addButtonText?: string; // 추가 버튼 텍스트 (기본: "품목 검색")
|
||||||
|
modalTitle?: string; // 모달 제목 (기본: "항목 검색 및 선택")
|
||||||
|
multiSelect?: boolean; // 다중 선택 허용 (기본: true)
|
||||||
|
maxHeight?: string; // 테이블 최대 높이 (기본: "400px")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사전 필터 조건
|
||||||
|
* 검색 시 항상 적용되는 필터 조건
|
||||||
|
*/
|
||||||
|
export interface TablePreFilter {
|
||||||
|
column: string; // 필터할 컬럼
|
||||||
|
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "notIn" | "like";
|
||||||
|
value: any; // 필터 값
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 내 필터 설정
|
||||||
|
* 사용자가 선택할 수 있는 필터 UI
|
||||||
|
*/
|
||||||
|
export interface TableModalFilter {
|
||||||
|
column: string; // 필터할 컬럼
|
||||||
|
label: string; // 필터 라벨
|
||||||
|
type: "category" | "text"; // 필터 타입 (category: 드롭다운, text: 텍스트 입력)
|
||||||
|
|
||||||
|
// 카테고리 참조 (type: "category"일 때) - 테이블에서 컬럼의 distinct 값 조회
|
||||||
|
categoryRef?: {
|
||||||
|
tableName: string; // 테이블명 (예: "item_info")
|
||||||
|
columnName: string; // 컬럼명 (예: "division")
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정적 옵션 (직접 입력한 경우)
|
||||||
|
options?: { value: string; label: string }[];
|
||||||
|
|
||||||
|
// 테이블에서 동적 로드 (테이블 컬럼 조회)
|
||||||
|
optionsFromTable?: {
|
||||||
|
tableName: string;
|
||||||
|
valueColumn: string;
|
||||||
|
labelColumn: string;
|
||||||
|
distinct?: boolean; // 중복 제거 (기본: true)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
defaultValue?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼 설정
|
||||||
|
*/
|
||||||
|
export interface TableColumnConfig {
|
||||||
|
field: string; // 필드명 (저장할 컬럼명)
|
||||||
|
label: string; // 컬럼 헤더 라벨
|
||||||
|
type: "text" | "number" | "date" | "select"; // 입력 타입
|
||||||
|
|
||||||
|
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
||||||
|
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
||||||
|
|
||||||
|
// 편집 설정
|
||||||
|
editable?: boolean; // 편집 가능 여부 (기본: true)
|
||||||
|
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
|
||||||
|
required?: boolean; // 필수 입력 여부
|
||||||
|
|
||||||
|
// 너비 설정
|
||||||
|
width?: string; // 기본 너비 (예: "150px")
|
||||||
|
minWidth?: string; // 최소 너비
|
||||||
|
maxWidth?: string; // 최대 너비
|
||||||
|
|
||||||
|
// 기본값
|
||||||
|
defaultValue?: any;
|
||||||
|
|
||||||
|
// Select 옵션 (type이 "select"일 때)
|
||||||
|
selectOptions?: { value: string; label: string }[];
|
||||||
|
|
||||||
|
// 값 매핑 (핵심 기능) - 고급 설정용
|
||||||
|
valueMapping?: ValueMappingConfig;
|
||||||
|
|
||||||
|
// 컬럼 모드 전환 (동적 데이터 소스)
|
||||||
|
columnModes?: ColumnModeConfig[];
|
||||||
|
|
||||||
|
// 조회 설정 (동적 값 조회)
|
||||||
|
lookup?: LookupConfig;
|
||||||
|
|
||||||
|
// 날짜 일괄 적용 (type이 "date"일 때만 사용)
|
||||||
|
// 활성화 시 첫 번째 날짜 입력 시 모든 행에 동일한 날짜가 자동 적용됨
|
||||||
|
batchApply?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 조회(Lookup) 설정 관련 타입 정의
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 유형
|
||||||
|
* - sameTable: 동일 테이블 조회 (소스 테이블에서 다른 컬럼 값)
|
||||||
|
* - relatedTable: 연관 테이블 조회 (현재 행 기준으로 다른 테이블에서)
|
||||||
|
* - combinedLookup: 복합 조건 조회 (다른 섹션 필드 + 현재 행 조합)
|
||||||
|
*/
|
||||||
|
export type LookupType = "sameTable" | "relatedTable" | "combinedLookup";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 변환 설정
|
||||||
|
* 예: 거래처 이름 → 거래처 코드로 변환
|
||||||
|
*/
|
||||||
|
export interface LookupTransform {
|
||||||
|
enabled: boolean; // 변환 사용 여부
|
||||||
|
tableName: string; // 변환 테이블 (예: customer_mng)
|
||||||
|
matchColumn: string; // 찾을 컬럼 (예: customer_name)
|
||||||
|
resultColumn: string; // 가져올 컬럼 (예: customer_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 테이블 조회 설정
|
||||||
|
* 다른 테이블에서 조건 값을 조회하여 사용 (이름→코드 변환 등)
|
||||||
|
*/
|
||||||
|
export interface ExternalTableLookup {
|
||||||
|
tableName: string; // 조회할 테이블
|
||||||
|
matchColumn: string; // 조회 조건 컬럼 (WHERE 절에서 비교할 컬럼)
|
||||||
|
matchSourceType: "currentRow" | "sourceTable" | "sectionField"; // 비교값 출처
|
||||||
|
matchSourceField: string; // 비교값 필드명
|
||||||
|
matchSectionId?: string; // sectionField인 경우 섹션 ID
|
||||||
|
resultColumn: string; // 가져올 컬럼 (SELECT 절)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 조건 설정
|
||||||
|
*
|
||||||
|
* sourceType 설명:
|
||||||
|
* - "currentRow": 테이블에 설정된 컬럼 필드값 (rowData에서 가져옴, 예: part_code, quantity)
|
||||||
|
* - "sourceTable": 원본 소스 테이블의 컬럼값 (_sourceData에서 가져옴, 예: item_number, company_code)
|
||||||
|
* - "sectionField": 폼의 다른 섹션 필드값 (formData에서 가져옴, 예: partner_id)
|
||||||
|
* - "externalTable": 외부 테이블에서 조회한 값 (다른 테이블에서 값을 조회해서 조건으로 사용)
|
||||||
|
*/
|
||||||
|
export interface LookupCondition {
|
||||||
|
sourceType: "currentRow" | "sourceTable" | "sectionField" | "externalTable"; // 값 출처
|
||||||
|
sourceField: string; // 출처의 필드명 (참조할 필드)
|
||||||
|
sectionId?: string; // sectionField인 경우 섹션 ID
|
||||||
|
targetColumn: string; // 조회 테이블의 컬럼
|
||||||
|
|
||||||
|
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
|
||||||
|
externalLookup?: ExternalTableLookup;
|
||||||
|
|
||||||
|
// 값 변환 설정 (선택) - 이름→코드 등 변환이 필요할 때 (레거시 호환)
|
||||||
|
transform?: LookupTransform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회 옵션 설정
|
||||||
|
* 하나의 컬럼에 여러 조회 방식을 정의하고 헤더에서 선택 가능
|
||||||
|
*/
|
||||||
|
export interface LookupOption {
|
||||||
|
id: string; // 옵션 고유 ID
|
||||||
|
label: string; // 옵션 라벨 (예: "기준단가", "거래처별 단가")
|
||||||
|
displayLabel?: string; // 헤더 드롭다운에 표시될 텍스트 (예: "기준단가" → "단가 (기준단가)")
|
||||||
|
type: LookupType; // 조회 유형
|
||||||
|
|
||||||
|
// 조회 테이블 설정
|
||||||
|
tableName: string; // 조회할 테이블
|
||||||
|
valueColumn: string; // 가져올 컬럼
|
||||||
|
|
||||||
|
// 조회 조건 (여러 조건 AND로 결합)
|
||||||
|
conditions: LookupCondition[];
|
||||||
|
|
||||||
|
// 기본 옵션 여부
|
||||||
|
isDefault?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 조회 설정
|
||||||
|
*/
|
||||||
|
export interface LookupConfig {
|
||||||
|
enabled: boolean; // 조회 사용 여부
|
||||||
|
options: LookupOption[]; // 조회 옵션 목록
|
||||||
|
defaultOptionId?: string; // 기본 선택 옵션 ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 매핑 설정
|
||||||
|
* 컬럼 값을 어디서 가져올지 정의
|
||||||
|
*/
|
||||||
|
export interface ValueMappingConfig {
|
||||||
|
type: "source" | "manual" | "external" | "internal";
|
||||||
|
|
||||||
|
// type: "source" - 소스 테이블에서 복사
|
||||||
|
sourceField?: string; // 소스 테이블의 컬럼명
|
||||||
|
|
||||||
|
// type: "external" - 외부 테이블 조회
|
||||||
|
externalRef?: {
|
||||||
|
tableName: string; // 조회할 테이블
|
||||||
|
valueColumn: string; // 가져올 컬럼
|
||||||
|
joinConditions: TableJoinCondition[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// type: "internal" - formData의 다른 필드 값 직접 사용
|
||||||
|
internalField?: string; // formData의 필드명
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 조인 조건
|
||||||
|
* 외부 테이블 조회 시 사용하는 조인 조건
|
||||||
|
*
|
||||||
|
* sourceType 설명:
|
||||||
|
* - "row": 현재 행의 설정된 컬럼 (rowData)
|
||||||
|
* - "sourceData": 원본 소스 테이블 데이터 (_sourceData)
|
||||||
|
* - "formData": 폼의 다른 섹션 필드 (formData)
|
||||||
|
* - "externalTable": 외부 테이블에서 조회한 값
|
||||||
|
*/
|
||||||
|
export interface TableJoinCondition {
|
||||||
|
sourceType: "row" | "sourceData" | "formData" | "externalTable"; // 값 출처
|
||||||
|
sourceField: string; // 출처의 필드명
|
||||||
|
targetColumn: string; // 조회 테이블의 컬럼
|
||||||
|
operator?: "=" | "!=" | ">" | "<" | ">=" | "<="; // 연산자 (기본: "=")
|
||||||
|
|
||||||
|
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
|
||||||
|
externalLookup?: ExternalTableLookup;
|
||||||
|
|
||||||
|
// 값 변환 설정 (선택) - 이름→코드 등 중간 변환이 필요할 때 (레거시 호환)
|
||||||
|
transform?: {
|
||||||
|
tableName: string; // 변환 테이블 (예: customer_mng)
|
||||||
|
matchColumn: string; // 찾을 컬럼 (예: customer_name)
|
||||||
|
resultColumn: string; // 가져올 컬럼 (예: customer_code)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 모드 설정
|
||||||
|
* 하나의 컬럼에서 여러 데이터 소스를 전환하여 사용
|
||||||
|
*/
|
||||||
|
export interface ColumnModeConfig {
|
||||||
|
id: string; // 모드 고유 ID
|
||||||
|
label: string; // 모드 라벨 (예: "기준 단가", "거래처별 단가")
|
||||||
|
isDefault?: boolean; // 기본 모드 여부
|
||||||
|
valueMapping: ValueMappingConfig; // 이 모드의 값 매핑
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 계산 규칙
|
||||||
|
* 다른 컬럼 값을 기반으로 자동 계산
|
||||||
|
*/
|
||||||
|
export interface TableCalculationRule {
|
||||||
|
resultField: string; // 결과를 저장할 필드
|
||||||
|
formula: string; // 계산 공식 (예: "quantity * unit_price")
|
||||||
|
dependencies: string[]; // 의존하는 필드들
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다중 행 저장 설정
|
// 다중 행 저장 설정
|
||||||
|
|
@ -214,6 +507,21 @@ export interface MultiRowSaveConfig {
|
||||||
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
|
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 섹션별 저장 방식 설정
|
||||||
|
* 공통 저장: 해당 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 (예: 수주번호, 거래처)
|
||||||
|
* 개별 저장: 해당 섹션의 필드 값이 각 품목마다 다르게 저장됩니다 (예: 품목코드, 수량, 단가)
|
||||||
|
*/
|
||||||
|
export interface SectionSaveMode {
|
||||||
|
sectionId: string;
|
||||||
|
saveMode: "common" | "individual"; // 공통 저장 / 개별 저장
|
||||||
|
// 필드별 세부 설정 (선택사항 - 섹션 기본값과 다르게 설정할 필드)
|
||||||
|
fieldOverrides?: {
|
||||||
|
fieldName: string;
|
||||||
|
saveMode: "common" | "individual";
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
// 저장 설정
|
// 저장 설정
|
||||||
export interface SaveConfig {
|
export interface SaveConfig {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
@ -225,6 +533,9 @@ export interface SaveConfig {
|
||||||
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
||||||
customApiSave?: CustomApiSaveConfig;
|
customApiSave?: CustomApiSaveConfig;
|
||||||
|
|
||||||
|
// 섹션별 저장 방식 설정
|
||||||
|
sectionSaveModes?: SectionSaveMode[];
|
||||||
|
|
||||||
// 저장 후 동작 (간편 설정)
|
// 저장 후 동작 (간편 설정)
|
||||||
showToast?: boolean; // 토스트 메시지 (기본: true)
|
showToast?: boolean; // 토스트 메시지 (기본: true)
|
||||||
refreshParent?: boolean; // 부모 새로고침 (기본: true)
|
refreshParent?: boolean; // 부모 새로고침 (기본: true)
|
||||||
|
|
@ -432,3 +743,69 @@ export const LINKED_FIELD_DISPLAY_FORMAT_OPTIONS = [
|
||||||
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
|
{ value: "code_name", label: "코드 - 이름 (예: SALES - 영업부)" },
|
||||||
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
|
{ value: "name_code", label: "이름 (코드) (예: 영업부 (SALES))" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 테이블 섹션 관련 상수
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 섹션 타입 옵션
|
||||||
|
export const SECTION_TYPE_OPTIONS = [
|
||||||
|
{ value: "fields", label: "필드 타입" },
|
||||||
|
{ value: "table", label: "테이블 타입" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 테이블 컬럼 타입 옵션
|
||||||
|
export const TABLE_COLUMN_TYPE_OPTIONS = [
|
||||||
|
{ value: "text", label: "텍스트" },
|
||||||
|
{ value: "number", label: "숫자" },
|
||||||
|
{ value: "date", label: "날짜" },
|
||||||
|
{ value: "select", label: "선택(드롭다운)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 값 매핑 타입 옵션
|
||||||
|
export const VALUE_MAPPING_TYPE_OPTIONS = [
|
||||||
|
{ value: "source", label: "소스 테이블에서 복사" },
|
||||||
|
{ value: "manual", label: "사용자 직접 입력" },
|
||||||
|
{ value: "external", label: "외부 테이블 조회" },
|
||||||
|
{ value: "internal", label: "폼 데이터 참조" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 조인 조건 소스 타입 옵션
|
||||||
|
export const JOIN_SOURCE_TYPE_OPTIONS = [
|
||||||
|
{ value: "row", label: "현재 행 데이터" },
|
||||||
|
{ value: "formData", label: "폼 필드 값" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 필터 연산자 옵션
|
||||||
|
export const FILTER_OPERATOR_OPTIONS = [
|
||||||
|
{ value: "=", label: "같음 (=)" },
|
||||||
|
{ value: "!=", label: "다름 (!=)" },
|
||||||
|
{ value: ">", label: "큼 (>)" },
|
||||||
|
{ value: "<", label: "작음 (<)" },
|
||||||
|
{ value: ">=", label: "크거나 같음 (>=)" },
|
||||||
|
{ value: "<=", label: "작거나 같음 (<=)" },
|
||||||
|
{ value: "in", label: "포함 (IN)" },
|
||||||
|
{ value: "notIn", label: "미포함 (NOT IN)" },
|
||||||
|
{ value: "like", label: "유사 (LIKE)" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 모달 필터 타입 옵션
|
||||||
|
export const MODAL_FILTER_TYPE_OPTIONS = [
|
||||||
|
{ value: "category", label: "테이블 조회" },
|
||||||
|
{ value: "text", label: "텍스트 입력" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 조회 유형 옵션
|
||||||
|
export const LOOKUP_TYPE_OPTIONS = [
|
||||||
|
{ value: "sameTable", label: "동일 테이블 조회" },
|
||||||
|
{ value: "relatedTable", label: "연관 테이블 조회" },
|
||||||
|
{ value: "combinedLookup", label: "복합 조건 조회" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 조회 조건 소스 타입 옵션
|
||||||
|
export const LOOKUP_CONDITION_SOURCE_OPTIONS = [
|
||||||
|
{ value: "currentRow", label: "현재 행" },
|
||||||
|
{ value: "sourceTable", label: "소스 테이블" },
|
||||||
|
{ value: "sectionField", label: "다른 섹션" },
|
||||||
|
{ value: "externalTable", label: "외부 테이블" },
|
||||||
|
] as const;
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,14 @@ export class ButtonActionExecutor {
|
||||||
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
|
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
|
||||||
|
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
|
||||||
|
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
|
||||||
|
if (universalFormModalResult.handled) {
|
||||||
|
console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료");
|
||||||
|
return universalFormModalResult.success;
|
||||||
|
}
|
||||||
|
|
||||||
// 폼 유효성 검사
|
// 폼 유효성 검사
|
||||||
if (config.validateForm) {
|
if (config.validateForm) {
|
||||||
const validation = this.validateFormData(formData);
|
const validation = this.validateFormData(formData);
|
||||||
|
|
@ -1479,6 +1487,244 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
|
||||||
|
* 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장
|
||||||
|
* 수정 모드: INSERT/UPDATE/DELETE 지원
|
||||||
|
*/
|
||||||
|
private static async handleUniversalFormModalTableSectionSave(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext,
|
||||||
|
formData: Record<string, any>,
|
||||||
|
): Promise<{ handled: boolean; success: boolean }> {
|
||||||
|
const { tableName, screenId } = context;
|
||||||
|
|
||||||
|
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
|
||||||
|
const universalFormModalKey = Object.keys(formData).find((key) => {
|
||||||
|
const value = formData[key];
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
// _tableSection_ 키가 있는지 확인
|
||||||
|
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!universalFormModalKey) {
|
||||||
|
return { handled: false, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
|
||||||
|
|
||||||
|
const modalData = formData[universalFormModalKey];
|
||||||
|
|
||||||
|
// _tableSection_ 데이터 추출
|
||||||
|
const tableSectionData: Record<string, any[]> = {};
|
||||||
|
const commonFieldsData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
|
||||||
|
// modalData 내부 또는 최상위 formData에서 찾음
|
||||||
|
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(modalData)) {
|
||||||
|
if (key.startsWith("_tableSection_")) {
|
||||||
|
const sectionId = key.replace("_tableSection_", "");
|
||||||
|
tableSectionData[sectionId] = value as any[];
|
||||||
|
} else if (!key.startsWith("_")) {
|
||||||
|
// _로 시작하지 않는 필드는 공통 필드로 처리
|
||||||
|
commonFieldsData[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", {
|
||||||
|
commonFields: Object.keys(commonFieldsData),
|
||||||
|
tableSections: Object.keys(tableSectionData),
|
||||||
|
tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })),
|
||||||
|
originalGroupedDataCount: originalGroupedData.length,
|
||||||
|
isEditMode: originalGroupedData.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
|
||||||
|
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
|
||||||
|
if (!hasTableSectionData && originalGroupedData.length === 0) {
|
||||||
|
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환");
|
||||||
|
return { handled: false, success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 사용자 정보 추가
|
||||||
|
if (!context.userId) {
|
||||||
|
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = {
|
||||||
|
writer: context.userId,
|
||||||
|
created_by: context.userId,
|
||||||
|
updated_by: context.userId,
|
||||||
|
company_code: context.companyCode || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
let insertedCount = 0;
|
||||||
|
let updatedCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
// 각 테이블 섹션 처리
|
||||||
|
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
|
||||||
|
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`);
|
||||||
|
|
||||||
|
// 1️⃣ 신규 품목 INSERT (id가 없는 항목)
|
||||||
|
const newItems = currentItems.filter((item) => !item.id);
|
||||||
|
for (const item of newItems) {
|
||||||
|
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
||||||
|
|
||||||
|
// 내부 메타데이터 제거
|
||||||
|
Object.keys(rowToSave).forEach((key) => {
|
||||||
|
if (key.startsWith("_")) {
|
||||||
|
delete rowToSave[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("➕ [INSERT] 신규 품목:", rowToSave);
|
||||||
|
|
||||||
|
const saveResult = await DynamicFormApi.saveFormData({
|
||||||
|
screenId: screenId!,
|
||||||
|
tableName: tableName!,
|
||||||
|
data: rowToSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!saveResult.success) {
|
||||||
|
throw new Error(saveResult.message || "신규 품목 저장 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
|
||||||
|
const existingItems = currentItems.filter((item) => item.id);
|
||||||
|
for (const item of existingItems) {
|
||||||
|
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
|
||||||
|
|
||||||
|
if (!originalItem) {
|
||||||
|
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`);
|
||||||
|
// 원본이 없으면 신규로 처리
|
||||||
|
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
||||||
|
Object.keys(rowToSave).forEach((key) => {
|
||||||
|
if (key.startsWith("_")) {
|
||||||
|
delete rowToSave[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
delete rowToSave.id; // id 제거하여 INSERT
|
||||||
|
|
||||||
|
const saveResult = await DynamicFormApi.saveFormData({
|
||||||
|
screenId: screenId!,
|
||||||
|
tableName: tableName!,
|
||||||
|
data: rowToSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!saveResult.success) {
|
||||||
|
throw new Error(saveResult.message || "품목 저장 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경 사항 확인 (공통 필드 포함)
|
||||||
|
const currentDataWithCommon = { ...commonFieldsData, ...item };
|
||||||
|
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`);
|
||||||
|
|
||||||
|
// 변경된 필드만 추출하여 부분 업데이트
|
||||||
|
const updateResult = await DynamicFormApi.updateFormDataPartial(
|
||||||
|
item.id,
|
||||||
|
originalItem,
|
||||||
|
currentDataWithCommon,
|
||||||
|
tableName!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updateResult.success) {
|
||||||
|
throw new Error(updateResult.message || "품목 수정 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCount++;
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
|
||||||
|
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean));
|
||||||
|
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
|
||||||
|
|
||||||
|
for (const deletedItem of deletedItems) {
|
||||||
|
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`);
|
||||||
|
|
||||||
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id);
|
||||||
|
|
||||||
|
if (!deleteResult.success) {
|
||||||
|
throw new Error(deleteResult.message || "품목 삭제 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 메시지 생성
|
||||||
|
const resultParts: string[] = [];
|
||||||
|
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
|
||||||
|
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
|
||||||
|
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
|
||||||
|
|
||||||
|
const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음";
|
||||||
|
|
||||||
|
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
|
||||||
|
toast.success(`저장 완료: ${resultMessage}`);
|
||||||
|
|
||||||
|
// 저장 성공 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("saveSuccess"));
|
||||||
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||||
|
// EditModal 닫기 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||||
|
|
||||||
|
return { handled: true, success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error);
|
||||||
|
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
||||||
|
return { handled: true, success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 객체 간 변경 사항 확인
|
||||||
|
*/
|
||||||
|
private static checkForChanges(original: Record<string, any>, current: Record<string, any>): boolean {
|
||||||
|
// 비교할 필드 목록 (메타데이터 제외)
|
||||||
|
const fieldsToCompare = new Set([
|
||||||
|
...Object.keys(original).filter((k) => !k.startsWith("_")),
|
||||||
|
...Object.keys(current).filter((k) => !k.startsWith("_")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const field of fieldsToCompare) {
|
||||||
|
// 시스템 필드는 비교에서 제외
|
||||||
|
if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalValue = original[field];
|
||||||
|
const currentValue = current[field];
|
||||||
|
|
||||||
|
// null/undefined 통일 처리
|
||||||
|
const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue);
|
||||||
|
const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue);
|
||||||
|
|
||||||
|
if (normalizedOriginal !== normalizedCurrent) {
|
||||||
|
console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
|
* 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
|
||||||
* ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
|
* ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue