feat(repeat-screen-modal): 연동 저장, 자동 채번, SUM_EXT 참조 제한 기능 추가
- SyncSaveConfig: 모달 저장 시 다른 테이블에 집계 값 동기화 기능 - RowNumberingConfig: 행 추가 시 채번 규칙 적용하여 자동 번호 생성 - externalTableRefs: SUM_EXT 함수가 참조할 외부 테이블 제한 기능 - triggerRepeatScreenModalSave: 외부에서 저장 트리거 가능한 이벤트 리스너 - TableColumnConfig.hidden: 테이블 컬럼 숨김 기능 (데이터는 유지, 화면만 숨김) - beforeFormSave: FK 자동 채우기 및 _isNew 행 포함 로직 개선
This commit is contained in:
parent
ae6f022f88
commit
512e1e30d1
|
|
@ -2141,3 +2141,4 @@ export async function multiTableSave(
|
|||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,25 +99,99 @@ export function RepeatScreenModalComponent({
|
|||
contentRowId: string;
|
||||
} | 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에 병합
|
||||
useEffect(() => {
|
||||
const handleBeforeFormSave = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||
|
||||
console.log("[RepeatScreenModal] beforeFormSave 이벤트 수신");
|
||||
console.log("[RepeatScreenModal] beforeFormSave - externalTableData:", externalTableData);
|
||||
console.log("[RepeatScreenModal] beforeFormSave - groupedCardsData:", groupedCardsData.length, "개 카드");
|
||||
|
||||
// 외부 테이블 데이터에서 dirty 행만 추출하여 저장 데이터 준비
|
||||
const saveDataByTable: Record<string, any[]> = {};
|
||||
|
||||
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 찾기
|
||||
const contentRow = contentRows.find((r) => key.includes(r.id));
|
||||
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;
|
||||
|
||||
// dirty 행만 필터링 (삭제된 행 제외)
|
||||
const dirtyRows = rows.filter((row) => row._isDirty && !row._isDeleted);
|
||||
// dirty 행 또는 새로운 행 필터링 (삭제된 행 제외)
|
||||
// 🆕 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;
|
||||
|
||||
|
|
@ -126,8 +200,9 @@ export function RepeatScreenModalComponent({
|
|||
.filter((col) => col.editable)
|
||||
.map((col) => col.field);
|
||||
|
||||
const joinKeys = (contentRow.tableDataSource.joinConditions || [])
|
||||
.map((cond) => cond.sourceKey);
|
||||
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
|
||||
const joinConditions = contentRow.tableDataSource.joinConditions || [];
|
||||
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
|
||||
|
||||
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 플래그 유지
|
||||
saveData._isNew = row._isNew;
|
||||
saveData._targetTable = targetTable;
|
||||
|
|
@ -599,15 +685,17 @@ export function RepeatScreenModalComponent({
|
|||
|
||||
// 각 카드의 집계 재계산
|
||||
const updatedCards = groupedCardsData.map((card) => {
|
||||
// 🆕 v3.11: 모든 외부 테이블 행의 데이터를 합침
|
||||
// 🆕 v3.11: 테이블 행 ID별로 외부 데이터를 구분하여 저장
|
||||
const externalRowsByTableId: Record<string, any[]> = {};
|
||||
const allExternalRows: any[] = [];
|
||||
|
||||
for (const tableRow of tableRowsWithExternalSource) {
|
||||
const key = `${card._cardId}-${tableRow.id}`;
|
||||
// 🆕 v3.7: 삭제된 행은 집계에서 제외
|
||||
const rows = (extData[key] || []).filter((row) => !row._isDeleted);
|
||||
externalRowsByTableId[tableRow.id] = rows;
|
||||
allExternalRows.push(...rows);
|
||||
}
|
||||
const externalRows = allExternalRows;
|
||||
|
||||
// 집계 재계산
|
||||
const newAggregations: Record<string, number> = {};
|
||||
|
|
@ -622,7 +710,7 @@ export function RepeatScreenModalComponent({
|
|||
if (isExternalTable) {
|
||||
// 외부 테이블 집계
|
||||
newAggregations[agg.resultField] = calculateColumnAggregation(
|
||||
externalRows,
|
||||
allExternalRows,
|
||||
agg.sourceField || "",
|
||||
agg.type || "sum"
|
||||
);
|
||||
|
|
@ -632,12 +720,28 @@ export function RepeatScreenModalComponent({
|
|||
calculateColumnAggregation(card._rows, agg.sourceField || "", agg.type || "sum");
|
||||
}
|
||||
} 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(
|
||||
agg.formula,
|
||||
card._representativeData,
|
||||
card._rows,
|
||||
externalRows,
|
||||
filteredExternalRows,
|
||||
newAggregations // 이전 집계 결과 참조
|
||||
);
|
||||
}
|
||||
|
|
@ -660,8 +764,8 @@ export function RepeatScreenModalComponent({
|
|||
});
|
||||
};
|
||||
|
||||
// 🆕 v3.1: 외부 테이블 행 추가
|
||||
const handleAddExternalRow = (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
||||
// 🆕 v3.1: 외부 테이블 행 추가 (v3.13: 자동 채번 기능 추가)
|
||||
const handleAddExternalRow = async (cardId: string, contentRowId: string, contentRow: CardContentRowConfig) => {
|
||||
const key = `${cardId}-${contentRowId}`;
|
||||
const card = groupedCardsData.find((c) => c._cardId === cardId) || cardsData.find((c) => c._cardId === cardId);
|
||||
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] 새 행 추가:", {
|
||||
cardId,
|
||||
contentRowId,
|
||||
|
|
@ -1329,8 +1468,13 @@ export function RepeatScreenModalComponent({
|
|||
for (const fn of extAggFunctions) {
|
||||
const regex = new RegExp(`${fn}\\(\\{(\\w+)\\}\\)`, "g");
|
||||
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 sum = values.reduce((a, b) => a + b, 0);
|
||||
console.log(`[SUM_EXT] ${fieldName}: ${externalRows.length}개 행, 값들:`, values, `합계: ${sum}`);
|
||||
const baseFn = fn.replace("_EXT", "");
|
||||
switch (baseFn) {
|
||||
case "SUM":
|
||||
|
|
@ -1531,6 +1675,9 @@ export function RepeatScreenModalComponent({
|
|||
// 🆕 v3.1: 외부 테이블 데이터 저장
|
||||
await saveExternalTableData();
|
||||
|
||||
// 🆕 v3.12: 연동 저장 처리 (syncSaves)
|
||||
await processSyncSaves();
|
||||
|
||||
alert("저장되었습니다.");
|
||||
} catch (error: any) {
|
||||
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 버튼 클릭 핸들러
|
||||
const handleFooterButtonClick = async (btn: FooterButtonConfig) => {
|
||||
switch (btn.action) {
|
||||
|
|
@ -1934,27 +2177,10 @@ export function RepeatScreenModalComponent({
|
|||
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
|
||||
<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">
|
||||
<span>{contentRow.tableTitle || ""}</span>
|
||||
<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 && (
|
||||
<Button
|
||||
|
|
@ -1974,7 +2200,8 @@ export function RepeatScreenModalComponent({
|
|||
{contentRow.showTableHeader !== false && (
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{(contentRow.tableColumns || []).map((col) => (
|
||||
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||
<TableHead
|
||||
key={col.id}
|
||||
style={{ width: col.width }}
|
||||
|
|
@ -1993,7 +2220,7 @@ export function RepeatScreenModalComponent({
|
|||
{(externalTableData[`${card._cardId}-${contentRow.id}`] || []).length === 0 ? (
|
||||
<TableRow>
|
||||
<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"
|
||||
>
|
||||
데이터가 없습니다.
|
||||
|
|
@ -2009,7 +2236,8 @@ export function RepeatScreenModalComponent({
|
|||
row._isDeleted && "bg-destructive/10 opacity-60"
|
||||
)}
|
||||
>
|
||||
{(contentRow.tableColumns || []).map((col) => (
|
||||
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
|
||||
{(contentRow.tableColumns || []).filter(col => !col.hidden).map((col) => (
|
||||
<TableCell
|
||||
key={`${row._rowId}-${col.id}`}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import {
|
|||
TableColumnConfig,
|
||||
CardContentRowConfig,
|
||||
AggregationDisplayConfig,
|
||||
SyncSaveConfig,
|
||||
RowNumberingConfig,
|
||||
} from "./types";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -738,6 +740,7 @@ function AggregationSettingsModal({
|
|||
aggregations,
|
||||
sourceTable,
|
||||
allTables,
|
||||
contentRows,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
|
|
@ -745,6 +748,7 @@ function AggregationSettingsModal({
|
|||
aggregations: AggregationConfig[];
|
||||
sourceTable: string;
|
||||
allTables: { tableName: string; displayName?: string }[];
|
||||
contentRows: CardContentRowConfig[];
|
||||
onSave: (aggregations: AggregationConfig[]) => void;
|
||||
}) {
|
||||
// 로컬 상태로 집계 목록 관리
|
||||
|
|
@ -852,6 +856,7 @@ function AggregationSettingsModal({
|
|||
sourceTable={sourceTable}
|
||||
allTables={allTables}
|
||||
existingAggregations={localAggregations}
|
||||
contentRows={contentRows}
|
||||
onUpdate={(updates) => updateAggregation(index, updates)}
|
||||
onRemove={() => removeAggregation(index)}
|
||||
onMove={(direction) => moveAggregation(index, direction)}
|
||||
|
|
@ -884,6 +889,7 @@ function AggregationConfigItemModal({
|
|||
sourceTable,
|
||||
allTables,
|
||||
existingAggregations,
|
||||
contentRows,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onMove,
|
||||
|
|
@ -894,6 +900,7 @@ function AggregationConfigItemModal({
|
|||
sourceTable: string;
|
||||
allTables: { tableName: string; displayName?: string }[];
|
||||
existingAggregations: AggregationConfig[];
|
||||
contentRows: CardContentRowConfig[];
|
||||
onUpdate: (updates: Partial<AggregationConfig>) => void;
|
||||
onRemove: () => void;
|
||||
onMove: (direction: "up" | "down") => void;
|
||||
|
|
@ -1120,6 +1127,15 @@ function AggregationConfigItemModal({
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🆕 v3.11: SUM_EXT 참조 테이블 선택 */}
|
||||
{localFormula.includes("_EXT") && (
|
||||
<ExternalTableRefSelector
|
||||
contentRows={contentRows}
|
||||
selectedRefs={agg.externalTableRefs || []}
|
||||
onUpdate={(refs) => onUpdate({ externalTableRefs: refs })}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
"추가" 버튼 클릭 시 지정한 컬럼에 자동으로 번호를 생성합니다.
|
||||
(옵션설정 > 코드설정에서 등록한 채번 규칙 사용)
|
||||
</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">
|
||||
등록된 채번 규칙이 없습니다. 옵션설정 > 코드설정에서 추가하세요.
|
||||
</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({
|
||||
open,
|
||||
|
|
@ -2040,6 +2554,78 @@ function LayoutRowConfigModal({
|
|||
)}
|
||||
</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="flex items-center justify-between">
|
||||
|
|
@ -2077,7 +2663,7 @@ function LayoutRowConfigModal({
|
|||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">필드</Label>
|
||||
<SourceColumnSelector
|
||||
|
|
@ -2124,6 +2710,17 @@ function LayoutRowConfigModal({
|
|||
<span className="ml-1 text-[10px]">{col.editable ? "예" : "아니오"}</span>
|
||||
</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>
|
||||
))}
|
||||
|
|
@ -3188,21 +3785,21 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
|
|||
<div className="space-y-2 pt-2 border-t overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] font-semibold">집계 설정</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setAggregationModalOpen(true)}
|
||||
className="h-6 text-[9px] px-2"
|
||||
>
|
||||
>
|
||||
<Layers className="h-3 w-3 mr-1" />
|
||||
설정 열기 ({(localConfig.grouping?.aggregations || []).length}개)
|
||||
</Button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 현재 집계 목록 요약 */}
|
||||
{(localConfig.grouping?.aggregations || []).length > 0 ? (
|
||||
<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
|
||||
key={`agg-summary-${index}`}
|
||||
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="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold">레이아웃 행</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setLayoutModalOpen(true)}
|
||||
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}개)
|
||||
</Button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 현재 레이아웃 요약 */}
|
||||
|
|
@ -3324,6 +3921,7 @@ export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenM
|
|||
aggregations={localConfig.grouping?.aggregations || []}
|
||||
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
||||
allTables={allTables}
|
||||
contentRows={localConfig.contentRows || []}
|
||||
onSave={(newAggregations) => {
|
||||
updateGrouping({ aggregations: newAggregations });
|
||||
}}
|
||||
|
|
@ -4192,7 +4790,7 @@ function ContentRowConfigSection({
|
|||
checked={row.tableCrud?.allowCreate || false}
|
||||
onCheckedChange={(checked) =>
|
||||
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]"
|
||||
|
|
@ -4204,7 +4802,7 @@ function ContentRowConfigSection({
|
|||
checked={row.tableCrud?.allowUpdate || false}
|
||||
onCheckedChange={(checked) =>
|
||||
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]"
|
||||
|
|
@ -4216,25 +4814,13 @@ function ContentRowConfigSection({
|
|||
checked={row.tableCrud?.allowDelete || false}
|
||||
onCheckedChange={(checked) =>
|
||||
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]"
|
||||
/>
|
||||
<Label className="text-[9px]">삭제</Label>
|
||||
</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>
|
||||
{row.tableCrud?.allowDelete && (
|
||||
<div className="flex items-center gap-1 pl-2">
|
||||
|
|
@ -4252,6 +4838,21 @@ function ContentRowConfigSection({
|
|||
)}
|
||||
</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="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -188,10 +188,6 @@ export interface TableCrudConfig {
|
|||
allowUpdate: boolean; // 행 수정 허용
|
||||
allowDelete: boolean; // 행 삭제 허용
|
||||
|
||||
// 🆕 v3.5: 테이블 영역 저장 버튼
|
||||
allowSave?: boolean; // 테이블 영역에 저장 버튼 표시
|
||||
saveButtonLabel?: string; // 저장 버튼 라벨 (기본: "저장")
|
||||
|
||||
// 신규 행 기본값
|
||||
newRowDefaults?: Record<string, string>; // 기본값 (예: { status: "READY", sales_order_id: "{id}" })
|
||||
|
||||
|
|
@ -203,6 +199,54 @@ export interface TableCrudConfig {
|
|||
|
||||
// 저장 대상 테이블 (외부 데이터 소스 사용 시)
|
||||
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})")
|
||||
|
||||
// === 🆕 v3.11: SUM_EXT 참조 테이블 제한 ===
|
||||
// SUM_EXT 함수가 참조할 외부 테이블 행 ID 목록
|
||||
// 비어있거나 undefined면 모든 외부 테이블 데이터 사용 (기존 동작)
|
||||
// 특정 테이블만 참조하려면 contentRow의 id를 배열로 지정
|
||||
externalTableRefs?: string[]; // 참조할 테이블 행 ID 목록 (예: ["crow-1764571929625"])
|
||||
|
||||
// === 공통 ===
|
||||
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||
|
|
@ -340,6 +390,9 @@ export interface TableColumnConfig {
|
|||
editable: boolean; // 편집 가능 여부
|
||||
required?: boolean; // 필수 입력 여부
|
||||
|
||||
// 🆕 v3.13: 숨김 설정 (화면에는 안 보이지만 데이터는 존재)
|
||||
hidden?: boolean; // 숨김 여부
|
||||
|
||||
// 🆕 v3.3: 컬럼 소스 테이블 지정 (조인 테이블 컬럼 사용 시)
|
||||
fromTable?: string; // 컬럼이 속한 테이블 (비어있으면 소스 테이블)
|
||||
fromJoinId?: string; // additionalJoins의 id 참조 (조인 테이블 컬럼일 때)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationConfig, AutoGenerationType } from "@/types/screen";
|
||||
import { TextInputConfig } from "./types";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||
|
|
|
|||
Loading…
Reference in New Issue