feat(repeat-screen-modal): 연동 저장, 자동 채번, SUM_EXT 참조 제한 기능 추가

- SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능
- RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성
- externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능
- triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너
- TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김)
- beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선
This commit is contained in:
SeongHyun Kim 2025-12-10 17:13:39 +09:00
parent ae6f022f88
commit 512e1e30d1
6 changed files with 950 additions and 65 deletions

View File

@ -2141,3 +2141,4 @@ export async function multiTableSave(
client.release(); client.release();
} }
} }

View File

@ -99,25 +99,99 @@ export function RepeatScreenModalComponent({
contentRowId: string; contentRowId: string;
} | null>(null); } | null>(null);
// 🆕 v3.13: 외부에서 저장 트리거 가능하도록 이벤트 리스너 추가
useEffect(() => {
const handleTriggerSave = async (event: Event) => {
if (!(event instanceof CustomEvent)) return;
console.log("[RepeatScreenModal] triggerRepeatScreenModalSave 이벤트 수신");
try {
setIsSaving(true);
// 기존 데이터 저장
if (cardMode === "withTable") {
await saveGroupedData();
} else {
await saveSimpleData();
}
// 외부 테이블 데이터 저장
await saveExternalTableData();
// 연동 저장 처리 (syncSaves)
await processSyncSaves();
console.log("[RepeatScreenModal] 외부 트리거 저장 완료");
// 저장 완료 이벤트 발생
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
detail: { success: true }
}));
// 성공 콜백 실행
if (event.detail?.onSuccess) {
event.detail.onSuccess();
}
} catch (error: any) {
console.error("[RepeatScreenModal] 외부 트리거 저장 실패:", error);
// 저장 실패 이벤트 발생
window.dispatchEvent(new CustomEvent("repeatScreenModalSaveComplete", {
detail: { success: false, error: error.message }
}));
// 실패 콜백 실행
if (event.detail?.onError) {
event.detail.onError(error);
}
} finally {
setIsSaving(false);
}
};
window.addEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
return () => {
window.removeEventListener("triggerRepeatScreenModalSave", handleTriggerSave as EventListener);
};
}, [cardMode, groupedCardsData, externalTableData, contentRows]);
// 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합 // 🆕 v3.9: beforeFormSave 이벤트 핸들러 - ButtonPrimary 저장 시 externalTableData를 formData에 병합
useEffect(() => { useEffect(() => {
const handleBeforeFormSave = (event: Event) => { const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return; if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신"); console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비 // 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
const saveDataByTable: Record<string, any[]> = {}; const saveDataByTable: Record<string, any[]> = {};
for (const [key, rows] of Object.entries(externalTableData)) { for (const [key, rows] of Object.entries(externalTableData)) {
// key 형식: cardId-contentRowId
const keyParts = key.split("-");
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
// contentRow 찾기 // contentRow 찾기
const contentRow = contentRows.find((r) => key.includes(r.id)); const contentRow = contentRows.find((r) => key.includes(r.id));
if (!contentRow?.tableDataSource?.enabled) continue; if (!contentRow?.tableDataSource?.enabled) continue;
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
const card = groupedCardsData.find((c) => c._cardId === cardId);
const representativeData = card?._representativeData || {};
const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable; const targetTable = contentRow.tableCrud?.targetTable || contentRow.tableDataSource.sourceTable;
// dirty 행만 필터링 (삭제된 행 제외) // dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted); // 🆕 v3.13: _isNew 행도 포함 (새로 추가된 행은 _isDirty가 없을 수 있음)
const dirtyRows = rows.filter((row) => (row._isDirty || row._isNew) && !row._isDeleted);
console.log(`[RepeatScreenModal] beforeFormSave - ${targetTable} 행 필터링:`, {
totalRows: rows.length,
dirtyRows: dirtyRows.length,
rowDetails: rows.map(r => ({ _isDirty: r._isDirty, _isNew: r._isNew, _isDeleted: r._isDeleted }))
});
if (dirtyRows.length === 0) continue; if (dirtyRows.length === 0) continue;
@ -126,8 +200,9 @@ export function RepeatScreenModalComponent({
.filter((col) => col.editable) .filter((col) => col.editable)
.map((col) => col.field); .map((col) => col.field);
const joinKeys = (contentRow.tableDataSource.joinConditions || []) // 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
.map((cond) => cond.sourceKey); const joinConditions = contentRow.tableDataSource.joinConditions || [];
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
const allowedFields = [...new Set([...editableFields, ...joinKeys])]; const allowedFields = [...new Set([...editableFields, ...joinKeys])];
@ -145,6 +220,17 @@ export function RepeatScreenModalComponent({
} }
} }
// 🆕 v3.13: joinConditions를 사용하여 FK 값 자동 채우기
// 예: sales_order_id (sourceKey) = card의 id (targetKey)
for (const joinCond of joinConditions) {
const { sourceKey, targetKey } = joinCond;
// sourceKey가 저장 데이터에 없거나 null인 경우, 카드의 대표 데이터에서 targetKey 값을 가져옴
if (!saveData[sourceKey] && representativeData[targetKey] !== undefined) {
saveData[sourceKey] = representativeData[targetKey];
console.log(`[RepeatScreenModal] beforeFormSave - FK 자동 채우기: ${sourceKey} = ${representativeData[targetKey]} (from card.${targetKey})`);
}
}
// _isNew 플래그 유지 // _isNew 플래그 유지
saveData._isNew = row._isNew; saveData._isNew = row._isNew;
saveData._targetTable = targetTable; saveData._targetTable = targetTable;
@ -599,15 +685,17 @@ export function RepeatScreenModalComponent({
// 각 카드의 집계 재계산 // 각 카드의 집계 재계산
const updatedCards = groupedCardsData.map((card) => { const updatedCards = groupedCardsData.map((card) => {
// 🆕 v3.11: 모든 외부 테이블 행의 데이터를 합침 // 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
const externalRowsByTableId: Record<string, any[]> = {};
const allExternalRows: any[] = []; const allExternalRows: any[] = [];
for (const tableRow of tableRowsWithExternalSource) { for (const tableRow of tableRowsWithExternalSource) {
const key = `${card._cardId}-${tableRow.id}`; const key = `${card._cardId}-${tableRow.id}`;
// 🆕 v3.7: 삭제된 행은 집계에서 제외 // 🆕 v3.7: 삭제된 행은 집계에서 제외
const rows = (extData[key] || []).filter((row) => !row._isDeleted); const rows = (extData[key] || []).filter((row) => !row._isDeleted);
externalRowsByTableId[tableRow.id] = rows;
allExternalRows.push(...rows); allExternalRows.push(...rows);
} }
const externalRows = allExternalRows;
// 집계 재계산 // 집계 재계산
const newAggregations: Record<string, number> = {}; const newAggregations: Record<string, number> = {};
@ -622,7 +710,7 @@ export function RepeatScreenModalComponent({
if (isExternalTable) { if (isExternalTable) {
// 외부 테이블 집계 // 외부 테이블 집계
newAggregations[agg.resultField] = calculateColumnAggregation( newAggregations[agg.resultField] = calculateColumnAggregation(
externalRows, allExternalRows,
agg.sourceField || "", agg.sourceField || "",
agg.type || "sum" agg.type || "sum"
); );
@ -632,12 +720,28 @@ export function RepeatScreenModalComponent({
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum"); calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
} }
} else if (sourceType === "formula" && agg.formula) { } else if (sourceType === "formula" && agg.formula) {
// 🆕 v3.11: externalTableRefs 기반으로 필터링된 외부 데이터 사용
let filteredExternalRows: any[];
if (agg.externalTableRefs && agg.externalTableRefs.length > 0) {
// 특정 테이블만 참조
filteredExternalRows = [];
for (const tableId of agg.externalTableRefs) {
if (externalRowsByTableId[tableId]) {
filteredExternalRows.push(...externalRowsByTableId[tableId]);
}
}
} else {
// 모든 외부 테이블 데이터 사용 (기존 동작)
filteredExternalRows = allExternalRows;
}
// 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산 // 가상 집계 (연산식) - 외부 테이블 데이터 포함하여 재계산
newAggregations[agg.resultField] = evaluateFormulaWithContext( newAggregations[agg.resultField] = evaluateFormulaWithContext(
agg.formula, agg.formula,
card._representativeData, card._representativeData,
card._rows, card._rows,
externalRows, filteredExternalRows,
newAggregations // 이전 집계 결과 참조 newAggregations // 이전 집계 결과 참조
); );
} }
@ -660,8 +764,8 @@ export function RepeatScreenModalComponent({
}); });
}; };
// 🆕 v3.1: 외부 테이블 행 추가 // 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => { const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
const key = `${cardId}-${contentRowId}`; const key = `${cardId}-${contentRowId}`;
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId); const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
const representativeData = (card as GroupedCardData)?._representativeData || card || {}; const representativeData = (card as GroupedCardData)?._representativeData || card || {};
@ -713,6 +817,41 @@ export function RepeatScreenModalComponent({
} }
} }
// 🆕 v3.13: 자동 채번 처리
const rowNumbering = contentRow.tableCrud?.rowNumbering;
console.log("[RepeatScreenModal] 채번 설정 확인:", {
tableCrud: contentRow.tableCrud,
rowNumbering,
enabled: rowNumbering?.enabled,
targetColumn: rowNumbering?.targetColumn,
numberingRuleId: rowNumbering?.numberingRuleId,
});
if (rowNumbering?.enabled && rowNumbering.targetColumn && rowNumbering.numberingRuleId) {
try {
console.log("[RepeatScreenModal] 자동 채번 시작:", {
targetColumn: rowNumbering.targetColumn,
numberingRuleId: rowNumbering.numberingRuleId,
});
// 채번 API 호출 (allocate: 실제 시퀀스 증가)
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(rowNumbering.numberingRuleId);
if (response.success && response.data) {
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
console.log("[RepeatScreenModal] 자동 채번 완료:", {
column: rowNumbering.targetColumn,
generatedCode: response.data.generatedCode,
});
} else {
console.warn("[RepeatScreenModal] 채번 실패:", response);
}
} catch (error) {
console.error("[RepeatScreenModal] 채번 API 호출 실패:", error);
}
}
console.log("[RepeatScreenModal] 새 행 추가:", { console.log("[RepeatScreenModal] 새 행 추가:", {
cardId, cardId,
contentRowId, contentRowId,
@ -1329,8 +1468,13 @@ export function RepeatScreenModalComponent({
for (const fn of extAggFunctions) { for (const fn of extAggFunctions) {
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g"); const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
expression = expression.replace(regex, (match, fieldName) => { expression = expression.replace(regex, (match, fieldName) => {
if (!externalRows || externalRows.length === 0) return "0"; if (!externalRows || externalRows.length === 0) {
console.log(`[SUM_EXT] ${fieldName}: 외부 데이터 없음`);
return "0";
}
const values = externalRows.map((row) => Number(row[fieldName]) || 0); const values = externalRows.map((row) => Number(row[fieldName]) || 0);
const sum = values.reduce((a, b) => a + b, 0);
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
const baseFn = fn.replace("_EXT", ""); const baseFn = fn.replace("_EXT", "");
switch (baseFn) { switch (baseFn) {
case "SUM": case "SUM":
@ -1531,6 +1675,9 @@ export function RepeatScreenModalComponent({
// 🆕 v3.1: 외부 테이블 데이터 저장 // 🆕 v3.1: 외부 테이블 데이터 저장
await saveExternalTableData(); await saveExternalTableData();
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
await processSyncSaves();
alert("저장되었습니다."); alert("저장되었습니다.");
} catch (error: any) { } catch (error: any) {
console.error("저장 실패:", error); console.error("저장 실패:", error);
@ -1588,6 +1735,102 @@ export function RepeatScreenModalComponent({
}); });
}; };
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
const processSyncSaves = async () => {
const syncPromises: Promise<void>[] = [];
// contentRows에서 syncSaves가 설정된 테이블 행 찾기
for (const contentRow of contentRows) {
if (contentRow.type !== "table") continue;
if (!contentRow.tableCrud?.syncSaves?.length) continue;
const sourceTable = contentRow.tableDataSource?.sourceTable;
if (!sourceTable) continue;
// 이 테이블 행의 모든 카드 데이터 수집
for (const card of groupedCardsData) {
const key = `${card._cardId}-${contentRow.id}`;
const rows = (externalTableData[key] || []).filter((row) => !row._isDeleted);
// 각 syncSave 설정 처리
for (const syncSave of contentRow.tableCrud.syncSaves) {
if (!syncSave.enabled) continue;
if (!syncSave.sourceColumn || !syncSave.targetTable || !syncSave.targetColumn) continue;
// 조인 키 값 수집 (중복 제거)
const joinKeyValues = new Set<string | number>();
for (const row of rows) {
const keyValue = row[syncSave.joinKey.sourceField];
if (keyValue !== undefined && keyValue !== null) {
joinKeyValues.add(keyValue);
}
}
// 각 조인 키별로 집계 계산 및 업데이트
for (const keyValue of joinKeyValues) {
// 해당 조인 키에 해당하는 행들만 필터링
const filteredRows = rows.filter(
(row) => row[syncSave.joinKey.sourceField] === keyValue
);
// 집계 계산
let aggregatedValue: number = 0;
const values = filteredRows.map((row) => Number(row[syncSave.sourceColumn]) || 0);
switch (syncSave.aggregationType) {
case "sum":
aggregatedValue = values.reduce((a, b) => a + b, 0);
break;
case "count":
aggregatedValue = values.length;
break;
case "avg":
aggregatedValue = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0;
break;
case "min":
aggregatedValue = values.length > 0 ? Math.min(...values) : 0;
break;
case "max":
aggregatedValue = values.length > 0 ? Math.max(...values) : 0;
break;
case "latest":
aggregatedValue = values.length > 0 ? values[values.length - 1] : 0;
break;
}
console.log(`[SyncSave] ${sourceTable}.${syncSave.sourceColumn}${syncSave.targetTable}.${syncSave.targetColumn}`, {
joinKey: keyValue,
aggregationType: syncSave.aggregationType,
values,
aggregatedValue,
});
// 대상 테이블 업데이트
syncPromises.push(
apiClient
.put(`/table-management/tables/${syncSave.targetTable}/data/${keyValue}`, {
[syncSave.targetColumn]: aggregatedValue,
})
.then(() => {
console.log(`[SyncSave] 업데이트 완료: ${syncSave.targetTable}.${syncSave.targetColumn} = ${aggregatedValue} (id=${keyValue})`);
})
.catch((err) => {
console.error(`[SyncSave] 업데이트 실패:`, err);
throw err;
})
);
}
}
}
}
if (syncPromises.length > 0) {
console.log(`[SyncSave] ${syncPromises.length}개 연동 저장 처리 중...`);
await Promise.all(syncPromises);
console.log(`[SyncSave] 연동 저장 완료`);
}
};
// 🆕 v3.1: Footer 버튼 클릭 핸들러 // 🆕 v3.1: Footer 버튼 클릭 핸들러
const handleFooterButtonClick = async (btn: FooterButtonConfig) => { const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
switch (btn.action) { switch (btn.action) {
@ -1934,27 +2177,10 @@ export function RepeatScreenModalComponent({
// 🆕 v3.1: 외부 테이블 데이터 소스 사용 // 🆕 v3.1: 외부 테이블 데이터 소스 사용
<div className="border rounded-lg overflow-hidden"> <div className="border rounded-lg overflow-hidden">
{/* 테이블 헤더 영역: 제목 + 버튼들 */} {/* 테이블 헤더 영역: 제목 + 버튼들 */}
{(contentRow.tableTitle || contentRow.tableCrud?.allowSave || contentRow.tableCrud?.allowCreate) && ( {(contentRow.tableTitle || contentRow.tableCrud?.allowCreate) && (
<div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between"> <div className="px-4 py-2 bg-muted/30 border-b text-sm font-medium flex items-center justify-between">
<span>{contentRow.tableTitle || ""}</span> <span>{contentRow.tableTitle || ""}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 저장 버튼 - allowSave가 true일 때만 표시 */}
{contentRow.tableCrud?.allowSave && (
<Button
variant="default"
size="sm"
onClick={() => handleTableAreaSave(card._cardId, contentRow.id, contentRow)}
disabled={isSaving || !(externalTableData[`${card._cardId}-${contentRow.id}`] || []).some((r: any) => r._isDirty)}
className="h-7 text-xs gap-1"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
{contentRow.tableCrud?.saveButtonLabel || "저장"}
</Button>
)}
{/* 추가 버튼 */} {/* 추가 버튼 */}
{contentRow.tableCrud?.allowCreate && ( {contentRow.tableCrud?.allowCreate && (
<Button <Button
@ -1974,7 +2200,8 @@ export function RepeatScreenModalComponent({
{contentRow.showTableHeader !== false && ( {contentRow.showTableHeader !== false && (
<TableHeader> <TableHeader>
<TableRow className="bg-muted/50"> <TableRow className="bg-muted/50">
{(contentRow.tableColumns || []).map((col) => ( {/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
<TableHead <TableHead
key={col.id} key={col.id}
style={{ width: col.width }} style={{ width: col.width }}
@ -1993,7 +2220,7 @@ export function RepeatScreenModalComponent({
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? ( {(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={(contentRow.tableColumns?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)} colSpan={(contentRow.tableColumns?.filter(col => !col.hidden)?.length || 0) + (contentRow.tableCrud?.allowDelete ? 1 : 0)}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
. .
@ -2009,7 +2236,8 @@ export function RepeatScreenModalComponent({
row._isDeleted && "bg-destructive/10 opacity-60" row._isDeleted && "bg-destructive/10 opacity-60"
)} )}
> >
{(contentRow.tableColumns || []).map((col) => ( {/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
<TableCell <TableCell
key={`${row._rowId}-${col.id}`} key={`${row._rowId}-${col.id}`}
className={cn( className={cn(

View File

@ -21,6 +21,8 @@ import {
TableColumnConfig, TableColumnConfig,
CardContentRowConfig, CardContentRowConfig,
AggregationDisplayConfig, AggregationDisplayConfig,
SyncSaveConfig,
RowNumberingConfig,
} from "./types"; } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -738,6 +740,7 @@ function AggregationSettingsModal({
aggregations, aggregations,
sourceTable, sourceTable,
allTables, allTables,
contentRows,
onSave, onSave,
}: { }: {
open: boolean; open: boolean;
@ -745,6 +748,7 @@ function AggregationSettingsModal({
aggregations: AggregationConfig[]; aggregations: AggregationConfig[];
sourceTable: string; sourceTable: string;
allTables: { tableName: string; displayName?: string }[]; allTables: { tableName: string; displayName?: string }[];
contentRows: CardContentRowConfig[];
onSave: (aggregations: AggregationConfig[]) => void; onSave: (aggregations: AggregationConfig[]) => void;
}) { }) {
// 로컬 상태로 집계 목록 관리 // 로컬 상태로 집계 목록 관리
@ -852,6 +856,7 @@ function AggregationSettingsModal({
sourceTable={sourceTable} sourceTable={sourceTable}
allTables={allTables} allTables={allTables}
existingAggregations={localAggregations} existingAggregations={localAggregations}
contentRows={contentRows}
onUpdate={(updates) => updateAggregation(index, updates)} onUpdate={(updates) => updateAggregation(index, updates)}
onRemove={() => removeAggregation(index)} onRemove={() => removeAggregation(index)}
onMove={(direction) => moveAggregation(index, direction)} onMove={(direction) => moveAggregation(index, direction)}
@ -884,6 +889,7 @@ function AggregationConfigItemModal({
sourceTable, sourceTable,
allTables, allTables,
existingAggregations, existingAggregations,
contentRows,
onUpdate, onUpdate,
onRemove, onRemove,
onMove, onMove,
@ -894,6 +900,7 @@ function AggregationConfigItemModal({
sourceTable: string; sourceTable: string;
allTables: { tableName: string; displayName?: string }[]; allTables: { tableName: string; displayName?: string }[];
existingAggregations: AggregationConfig[]; existingAggregations: AggregationConfig[];
contentRows: CardContentRowConfig[];
onUpdate: (updates: Partial<AggregationConfig>) => void; onUpdate: (updates: Partial<AggregationConfig>) => void;
onRemove: () => void; onRemove: () => void;
onMove: (direction: "up" | "down") => void; onMove: (direction: "up" | "down") => void;
@ -1120,6 +1127,15 @@ function AggregationConfigItemModal({
}} }}
/> />
</div> </div>
{/* 🆕 v3.11: SUM_EXT 참조 테이블 선택 */}
{localFormula.includes("_EXT") && (
<ExternalTableRefSelector
contentRows={contentRows}
selectedRefs={agg.externalTableRefs || []}
onUpdate={(refs) => onUpdate({ externalTableRefs: refs })}
/>
)}
</div> </div>
)} )}
@ -1280,6 +1296,504 @@ function FormulaColumnAggregator({
); );
} }
// 🆕 v3.11: SUM_EXT 참조 테이블 선택 컴포넌트
function ExternalTableRefSelector({
contentRows,
selectedRefs,
onUpdate,
}: {
contentRows: CardContentRowConfig[];
selectedRefs: string[];
onUpdate: (refs: string[]) => void;
}) {
// 외부 데이터 소스가 활성화된 테이블 행만 필터링
const tableRowsWithExternalSource = contentRows.filter(
(row) => row.type === "table" && row.tableDataSource?.enabled
);
if (tableRowsWithExternalSource.length === 0) {
return (
<div className="p-3 bg-gray-100 dark:bg-gray-800 rounded-lg">
<p className="text-xs text-muted-foreground">
.
</p>
</div>
);
}
const isAllSelected = selectedRefs.length === 0;
const handleToggleTable = (tableId: string) => {
if (selectedRefs.includes(tableId)) {
// 이미 선택된 경우 제거
const newRefs = selectedRefs.filter((id) => id !== tableId);
onUpdate(newRefs);
} else {
// 선택되지 않은 경우 추가
onUpdate([...selectedRefs, tableId]);
}
};
const handleSelectAll = () => {
onUpdate([]); // 빈 배열 = 모든 테이블 사용
};
return (
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded-lg border border-amber-200 space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-amber-700 dark:text-amber-300 font-medium">
SUM_EXT ( )
</Label>
<Button
size="sm"
variant={isAllSelected ? "default" : "outline"}
onClick={handleSelectAll}
className="h-6 text-[10px] px-2"
>
</Button>
</div>
<p className="text-[10px] text-amber-600">
SUM_EXT . .
</p>
<div className="space-y-1">
{tableRowsWithExternalSource.map((row) => {
const isSelected = selectedRefs.length === 0 || selectedRefs.includes(row.id);
const tableTitle = row.title || row.tableDataSource?.sourceTable || row.id;
const tableName = row.tableDataSource?.sourceTable || "";
return (
<div
key={row.id}
className={cn(
"flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors",
isSelected
? "bg-amber-100 border-amber-300"
: "bg-white border-gray-200 hover:bg-gray-50"
)}
onClick={() => handleToggleTable(row.id)}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}} // onClick에서 처리
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{tableTitle}</p>
<p className="text-[10px] text-muted-foreground truncate">
: {tableName} | ID: {row.id.slice(-10)}
</p>
</div>
</div>
);
})}
</div>
{selectedRefs.length > 0 && (
<p className="text-[10px] text-amber-700 bg-amber-100 px-2 py-1 rounded">
: {selectedRefs.length} ( )
</p>
)}
</div>
);
}
// 🆕 v3.12: 연동 저장 설정 섹션
function SyncSaveConfigSection({
row,
allTables,
onUpdateRow,
}: {
row: CardContentRowConfig;
allTables: { tableName: string; displayName?: string }[];
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
}) {
const syncSaves = row.tableCrud?.syncSaves || [];
const sourceTable = row.tableDataSource?.sourceTable || "";
// 연동 저장 추가
const addSyncSave = () => {
const newSyncSave: SyncSaveConfig = {
id: `sync-${Date.now()}`,
enabled: true,
sourceColumn: "",
aggregationType: "sum",
targetTable: "",
targetColumn: "",
joinKey: {
sourceField: "",
targetField: "id",
},
};
onUpdateRow({
tableCrud: {
...row.tableCrud,
allowCreate: row.tableCrud?.allowCreate || false,
allowUpdate: row.tableCrud?.allowUpdate || false,
allowDelete: row.tableCrud?.allowDelete || false,
syncSaves: [...syncSaves, newSyncSave],
},
});
};
// 연동 저장 삭제
const removeSyncSave = (index: number) => {
const newSyncSaves = [...syncSaves];
newSyncSaves.splice(index, 1);
onUpdateRow({
tableCrud: {
...row.tableCrud,
allowCreate: row.tableCrud?.allowCreate || false,
allowUpdate: row.tableCrud?.allowUpdate || false,
allowDelete: row.tableCrud?.allowDelete || false,
syncSaves: newSyncSaves,
},
});
};
// 연동 저장 업데이트
const updateSyncSave = (index: number, updates: Partial<SyncSaveConfig>) => {
const newSyncSaves = [...syncSaves];
newSyncSaves[index] = { ...newSyncSaves[index], ...updates };
onUpdateRow({
tableCrud: {
...row.tableCrud,
allowCreate: row.tableCrud?.allowCreate || false,
allowUpdate: row.tableCrud?.allowUpdate || false,
allowDelete: row.tableCrud?.allowDelete || false,
syncSaves: newSyncSaves,
},
});
};
return (
<div className="space-y-2 p-2 bg-orange-50 dark:bg-orange-950 rounded-lg border border-orange-200">
<div className="flex items-center justify-between">
<Label className="text-[10px] font-semibold text-orange-700 dark:text-orange-300">
( )
</Label>
<Button
size="sm"
variant="outline"
onClick={addSyncSave}
className="h-5 text-[9px] px-1 bg-orange-100 hover:bg-orange-200 border-orange-300"
>
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
{syncSaves.length === 0 ? (
<p className="text-[9px] text-muted-foreground text-center py-2">
. .
</p>
) : (
<div className="space-y-2">
{syncSaves.map((sync, index) => (
<SyncSaveConfigItem
key={sync.id}
sync={sync}
index={index}
sourceTable={sourceTable}
allTables={allTables}
onUpdate={(updates) => updateSyncSave(index, updates)}
onRemove={() => removeSyncSave(index)}
/>
))}
</div>
)}
</div>
);
}
// 🆕 v3.12: 개별 연동 저장 설정 아이템
function SyncSaveConfigItem({
sync,
index,
sourceTable,
allTables,
onUpdate,
onRemove,
}: {
sync: SyncSaveConfig;
index: number;
sourceTable: string;
allTables: { tableName: string; displayName?: string }[];
onUpdate: (updates: Partial<SyncSaveConfig>) => void;
onRemove: () => void;
}) {
return (
<div className={cn(
"p-2 rounded border space-y-2",
sync.enabled ? "bg-orange-100 border-orange-300" : "bg-gray-100 border-gray-300 opacity-60"
)}>
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
checked={sync.enabled}
onCheckedChange={(checked) => onUpdate({ enabled: checked })}
className="scale-[0.6]"
/>
<Badge className="text-[8px] px-1 py-0 bg-orange-200 text-orange-800">
{index + 1}
</Badge>
</div>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="h-4 w-4 p-0 hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 소스 설정 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-0.5">
<Label className="text-[8px] text-muted-foreground"> ({sourceTable})</Label>
<SourceColumnSelector
sourceTable={sourceTable}
value={sync.sourceColumn}
onChange={(value) => onUpdate({ sourceColumn: value })}
placeholder="컬럼 선택"
/>
</div>
<div className="space-y-0.5">
<Label className="text-[8px] text-muted-foreground"> </Label>
<Select
value={sync.aggregationType}
onValueChange={(value) => onUpdate({ aggregationType: value as SyncSaveConfig["aggregationType"] })}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
<SelectItem value="latest"> (LATEST)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 대상 설정 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-0.5">
<Label className="text-[8px] text-muted-foreground"> </Label>
<Select
value={sync.targetTable}
onValueChange={(value) => onUpdate({ targetTable: value })}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-0.5">
<Label className="text-[8px] text-muted-foreground"> </Label>
<SourceColumnSelector
sourceTable={sync.targetTable}
value={sync.targetColumn}
onChange={(value) => onUpdate({ targetColumn: value })}
placeholder="컬럼 선택"
/>
</div>
</div>
{/* 조인 키 설정 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-0.5">
<Label className="text-[8px] text-muted-foreground"> ()</Label>
<SourceColumnSelector
sourceTable={sourceTable}
value={sync.joinKey.sourceField}
onChange={(value) => onUpdate({ joinKey: { ...sync.joinKey, sourceField: value } })}
placeholder="예: sales_order_id"
/>
</div>
<div className="space-y-0.5">
<Label className="text-[8px] text-muted-foreground"> ()</Label>
<SourceColumnSelector
sourceTable={sync.targetTable}
value={sync.joinKey.targetField}
onChange={(value) => onUpdate({ joinKey: { ...sync.joinKey, targetField: value } })}
placeholder="예: id"
/>
</div>
</div>
{/* 설정 요약 */}
{sync.sourceColumn && sync.targetTable && sync.targetColumn && (
<p className="text-[8px] text-orange-700 bg-orange-200 px-2 py-1 rounded">
{sourceTable}.{sync.sourceColumn} {sync.aggregationType.toUpperCase()} {" "}
{sync.targetTable}.{sync.targetColumn}
</p>
)}
</div>
);
}
// 🆕 v3.13: 행 추가 시 자동 채번 설정 섹션
function RowNumberingConfigSection({
row,
onUpdateRow,
}: {
row: CardContentRowConfig;
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
}) {
const [numberingRules, setNumberingRules] = useState<{ id: string; name: string; code: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const rowNumbering = row.tableCrud?.rowNumbering;
const tableColumns = row.tableColumns || [];
// 채번 규칙 목록 로드 (옵션설정 > 코드설정에서 등록된 전체 목록)
useEffect(() => {
const loadNumberingRules = async () => {
setIsLoading(true);
try {
const { getNumberingRules } = await import("@/lib/api/numberingRule");
const response = await getNumberingRules();
if (response.success && response.data) {
setNumberingRules(response.data.map((rule: any, index: number) => ({
id: String(rule.ruleId || rule.id || `rule-${index}`),
name: rule.ruleName || rule.name || "이름 없음",
code: rule.ruleId || rule.code || "",
})));
}
} catch (error) {
console.error("채번 규칙 로드 실패:", error);
setNumberingRules([]);
} finally {
setIsLoading(false);
}
};
loadNumberingRules();
}, []);
// 채번 설정 업데이트
const updateRowNumbering = (updates: Partial<RowNumberingConfig>) => {
const currentNumbering = row.tableCrud?.rowNumbering || {
enabled: false,
targetColumn: "",
numberingRuleId: "",
};
onUpdateRow({
tableCrud: {
...row.tableCrud,
allowCreate: row.tableCrud?.allowCreate || false,
allowUpdate: row.tableCrud?.allowUpdate || false,
allowDelete: row.tableCrud?.allowDelete || false,
rowNumbering: {
...currentNumbering,
...updates,
},
},
});
};
return (
<div className="space-y-2 p-3 bg-purple-50 dark:bg-purple-950 rounded-lg border border-purple-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch
checked={rowNumbering?.enabled || false}
onCheckedChange={(checked) => updateRowNumbering({ enabled: checked })}
className="scale-90"
/>
<Label className="text-xs font-semibold text-purple-700 dark:text-purple-300">
</Label>
</div>
</div>
<p className="text-[10px] text-purple-600 leading-tight">
&quot;&quot; .
( &gt; )
</p>
{rowNumbering?.enabled && (
<div className="space-y-3 pt-2 border-t border-purple-200">
{/* 대상 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-purple-700"> *</Label>
<Select
value={rowNumbering.targetColumn || ""}
onValueChange={(value) => updateRowNumbering({ targetColumn: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col, index) => (
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
{col.label || col.field}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[9px] text-muted-foreground">
( )
</p>
</div>
{/* 채번 규칙 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-purple-700"> *</Label>
<Select
value={rowNumbering.numberingRuleId || ""}
onValueChange={(value) => updateRowNumbering({ numberingRuleId: value })}
disabled={isLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={isLoading ? "로딩 중..." : "채번 규칙 선택"} />
</SelectTrigger>
<SelectContent>
{numberingRules.map((rule) => (
<SelectItem key={rule.id} value={rule.id} className="text-xs">
{rule.name} ({rule.code})
</SelectItem>
))}
</SelectContent>
</Select>
{numberingRules.length === 0 && !isLoading && (
<p className="text-[9px] text-amber-600">
. &gt; .
</p>
)}
</div>
{/* 설정 요약 */}
{rowNumbering.targetColumn && rowNumbering.numberingRuleId && (
<div className="text-[10px] text-purple-700 bg-purple-100 px-2 py-1.5 rounded">
"추가" <strong>{rowNumbering.targetColumn}</strong>
</div>
)}
</div>
)}
</div>
);
}
// 🆕 레이아웃 설정 전용 모달 // 🆕 레이아웃 설정 전용 모달
function LayoutSettingsModal({ function LayoutSettingsModal({
open, open,
@ -2040,6 +2554,78 @@ function LayoutRowConfigModal({
)} )}
</div> </div>
{/* CRUD 설정 */}
<div className="space-y-2 p-3 bg-green-100/50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
<Label className="text-xs font-semibold">CRUD </Label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Switch
checked={row.tableCrud?.allowCreate || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false },
})
}
className="scale-90"
/>
<Label className="text-xs"></Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={row.tableCrud?.allowUpdate || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false },
})
}
className="scale-90"
/>
<Label className="text-xs"></Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={row.tableCrud?.allowDelete || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked },
})
}
className="scale-90"
/>
<Label className="text-xs"></Label>
</div>
</div>
{row.tableCrud?.allowDelete && (
<div className="flex items-center gap-2 pl-2 pt-1 border-t border-green-200">
<Switch
checked={row.tableCrud?.deleteConfirm?.enabled !== false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } },
})
}
className="scale-75"
/>
<Label className="text-[10px]"> </Label>
</div>
)}
</div>
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */}
{row.tableCrud?.allowCreate && (
<RowNumberingConfigSection
row={row}
onUpdateRow={onUpdateRow}
/>
)}
{/* 🆕 v3.12: 연동 저장 설정 */}
<SyncSaveConfigSection
row={row}
allTables={allTables}
onUpdateRow={onUpdateRow}
/>
{/* 테이블 컬럼 목록 */} {/* 테이블 컬럼 목록 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -2077,7 +2663,7 @@ function LayoutRowConfigModal({
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-5 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px]"></Label> <Label className="text-[10px]"></Label>
<SourceColumnSelector <SourceColumnSelector
@ -2124,6 +2710,17 @@ function LayoutRowConfigModal({
<span className="ml-1 text-[10px]">{col.editable ? "예" : "아니오"}</span> <span className="ml-1 text-[10px]">{col.editable ? "예" : "아니오"}</span>
</div> </div>
</div> </div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<div className="flex items-center h-6 px-2 border rounded-md bg-background">
<Switch
checked={col.hidden || false}
onCheckedChange={(checked) => onUpdateTableColumn(colIndex, { hidden: checked })}
className="scale-75"
/>
<span className="ml-1 text-[10px]">{col.hidden ? "예" : "아니오"}</span>
</div>
</div>
</div> </div>
</div> </div>
))} ))}
@ -3188,21 +3785,21 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
<div className="space-y-2 pt-2 border-t overflow-hidden"> <div className="space-y-2 pt-2 border-t overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-[10px] font-semibold"> </Label> <Label className="text-[10px] font-semibold"> </Label>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setAggregationModalOpen(true)} onClick={() => setAggregationModalOpen(true)}
className="h-6 text-[9px] px-2" className="h-6 text-[9px] px-2"
> >
<Layers className="h-3 w-3 mr-1" /> <Layers className="h-3 w-3 mr-1" />
({(localConfig.grouping?.aggregations || []).length}) ({(localConfig.grouping?.aggregations || []).length})
</Button> </Button>
</div> </div>
{/* 현재 집계 목록 요약 */} {/* 현재 집계 목록 요약 */}
{(localConfig.grouping?.aggregations || []).length > 0 ? ( {(localConfig.grouping?.aggregations || []).length > 0 ? (
<div className="space-y-1 max-h-[120px] overflow-y-auto pr-1"> <div className="space-y-1 max-h-[120px] overflow-y-auto pr-1">
{(localConfig.grouping?.aggregations || []).map((agg, index) => ( {(localConfig.grouping?.aggregations || []).map((agg, index) => (
<div <div
key={`agg-summary-${index}`} key={`agg-summary-${index}`}
className={cn( className={cn(
@ -3239,15 +3836,15 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
<div className="space-y-3 border rounded-lg p-3 bg-card"> <div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-semibold"> </h3> <h3 className="text-xs font-semibold"> </h3>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => setLayoutModalOpen(true)} onClick={() => setLayoutModalOpen(true)}
className="h-6 text-[9px] px-2" className="h-6 text-[9px] px-2"
> >
<Layers className="h-3 w-3 mr-1" /> <Layers className="h-3 w-3 mr-1" />
({(localConfig.contentRows || []).length}) ({(localConfig.contentRows || []).length})
</Button> </Button>
</div> </div>
{/* 현재 레이아웃 요약 */} {/* 현재 레이아웃 요약 */}
@ -3324,6 +3921,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
aggregations={localConfig.grouping?.aggregations || []} aggregations={localConfig.grouping?.aggregations || []}
sourceTable={localConfig.dataSource?.sourceTable || ""} sourceTable={localConfig.dataSource?.sourceTable || ""}
allTables={allTables} allTables={allTables}
contentRows={localConfig.contentRows || []}
onSave={(newAggregations) => { onSave={(newAggregations) => {
updateGrouping({ aggregations: newAggregations }); updateGrouping({ aggregations: newAggregations });
}} }}
@ -4192,7 +4790,7 @@ function ContentRowConfigSection({
checked={row.tableCrud?.allowCreate || false} checked={row.tableCrud?.allowCreate || false}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onUpdateRow({ onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false }, tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false },
}) })
} }
className="scale-[0.5]" className="scale-[0.5]"
@ -4204,7 +4802,7 @@ function ContentRowConfigSection({
checked={row.tableCrud?.allowUpdate || false} checked={row.tableCrud?.allowUpdate || false}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onUpdateRow({ onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false }, tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false },
}) })
} }
className="scale-[0.5]" className="scale-[0.5]"
@ -4216,25 +4814,13 @@ function ContentRowConfigSection({
checked={row.tableCrud?.allowDelete || false} checked={row.tableCrud?.allowDelete || false}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onUpdateRow({ onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked, allowSave: row.tableCrud?.allowSave || false }, tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked },
}) })
} }
className="scale-[0.5]" className="scale-[0.5]"
/> />
<Label className="text-[9px]"></Label> <Label className="text-[9px]"></Label>
</div> </div>
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowSave || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: checked },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
</div> </div>
{row.tableCrud?.allowDelete && ( {row.tableCrud?.allowDelete && (
<div className="flex items-center gap-1 pl-2"> <div className="flex items-center gap-1 pl-2">
@ -4252,6 +4838,21 @@ function ContentRowConfigSection({
)} )}
</div> </div>
{/* 🆕 v3.12: 연동 저장 설정 */}
<SyncSaveConfigSection
row={row}
allTables={allTables}
onUpdateRow={onUpdateRow}
/>
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */}
{row.tableCrud?.allowCreate && (
<RowNumberingConfigSection
row={row}
onUpdateRow={onUpdateRow}
/>
)}
{/* 테이블 컬럼 목록 */} {/* 테이블 컬럼 목록 */}
<div className="space-y-2 pl-2 border-l-2 border-blue-300"> <div className="space-y-2 pl-2 border-l-2 border-blue-300">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -188,10 +188,6 @@ export interface TableCrudConfig {
allowUpdate: boolean; // 행 수정 허용 allowUpdate: boolean; // 행 수정 허용
allowDelete: boolean; // 행 삭제 허용 allowDelete: boolean; // 행 삭제 허용
// 🆕 v3.5: 테이블 영역 저장 버튼
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
// 신규 행 기본값 // 신규 행 기본값
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" }) newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
@ -203,6 +199,54 @@ export interface TableCrudConfig {
// 저장 대상 테이블 (외부 데이터 소스 사용 시) // 저장 대상 테이블 (외부 데이터 소스 사용 시)
targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable) targetTable?: string; // 저장할 테이블 (기본: tableDataSource.sourceTable)
// 🆕 v3.12: 연동 저장 설정 (모달 전체 저장 시 다른 테이블에도 동기화)
syncSaves?: SyncSaveConfig[];
// 🆕 v3.13: 행 추가 시 자동 채번 설정
rowNumbering?: RowNumberingConfig;
}
/**
* 🆕 v3.13: 테이블
* "추가"
*
* :
* - (shipment_plan_no)
* - (invoice_no)
* - (work_order_no)
*
* 참고: 채번 "수정 가능"
*/
export interface RowNumberingConfig {
enabled: boolean; // 채번 사용 여부
targetColumn: string; // 채번 결과를 저장할 컬럼 (예: "shipment_plan_no")
// 채번 규칙 설정 (옵션설정 > 코드설정에서 등록된 채번 규칙)
numberingRuleId: string; // 채번 규칙 ID (numbering_rule 테이블)
}
/**
* 🆕 v3.12: 연동
*
*/
export interface SyncSaveConfig {
id: string; // 고유 ID
enabled: boolean; // 활성화 여부
// 소스 설정 (이 테이블에서)
sourceColumn: string; // 집계할 컬럼 (예: "plan_qty")
aggregationType: "sum" | "count" | "avg" | "min" | "max" | "latest"; // 집계 방식
// 대상 설정 (저장할 테이블)
targetTable: string; // 대상 테이블 (예: "sales_order_mng")
targetColumn: string; // 대상 컬럼 (예: "plan_ship_qty")
// 조인 키 (어떤 레코드를 업데이트할지)
joinKey: {
sourceField: string; // 이 테이블의 조인 키 (예: "sales_order_id")
targetField: string; // 대상 테이블의 키 (예: "id")
};
} }
/** /**
@ -285,6 +329,12 @@ export interface AggregationConfig {
// - 산술 연산: +, -, *, /, () // - 산술 연산: +, -, *, /, ()
formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})") formula?: string; // 연산식 (예: "{total_balance} - SUM_EXT({plan_qty})")
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
// === 공통 === // === 공통 ===
resultField: string; // 결과 필드명 (예: "total_balance_qty") resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량") label: string; // 표시 라벨 (예: "총수주잔량")
@ -340,6 +390,9 @@ export interface TableColumnConfig {
editable: boolean; // 편집 가능 여부 editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부 required?: boolean; // 필수 입력 여부
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
hidden?: boolean; // 숨김 여부
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시) // 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블) fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때) fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)

View File

@ -2,7 +2,7 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { ComponentRendererProps } from "@/types/component"; import { ComponentRendererProps } from "@/types/component";
import { AutoGenerationConfig } from "@/types/screen"; import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen";
import { TextInputConfig } from "./types"; import { TextInputConfig } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";

View File

@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { TextInputConfig } from "./types"; import { TextInputConfig } from "./types";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen"; import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";