리피터 입력폼 수정

This commit is contained in:
kjs 2025-12-24 09:58:22 +09:00
parent 97675458d7
commit 5948799a29
6 changed files with 445 additions and 36 deletions

View File

@ -541,6 +541,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const response = await dynamicFormApi.saveData(saveData);
if (response.success) {
// 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
window.dispatchEvent(new CustomEvent("repeaterSave", {
detail: { parentId: response.data?.id || formData.id }
}));
toast.success("데이터가 성공적으로 저장되었습니다.");
} else {
toast.error(response.message || "저장에 실패했습니다.");

View File

@ -16,17 +16,23 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, Trash2, GripVertical, Search, X, Check } from "lucide-react";
import { Plus, Trash2, GripVertical, Search, X, Check, Calendar } from "lucide-react";
import { cn } from "@/lib/utils";
import {
UnifiedRepeaterConfig,
UnifiedRepeaterProps,
RepeaterButtonConfig,
ButtonActionType,
RepeaterColumnConfig,
DEFAULT_REPEATER_CONFIG,
} from "@/types/unified-repeater";
import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
// 모달 크기 매핑
const MODAL_SIZE_MAP = {
@ -37,6 +43,14 @@ const MODAL_SIZE_MAP = {
full: "max-w-[95vw]",
};
// 🆕 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
// 리피터가 있으면 메인 폼은 리피터 데이터에 병합되어 저장되므로 별도 저장 불필요
declare global {
interface Window {
__unifiedRepeaterInstances?: Set<string>;
}
}
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
config: propConfig,
parentId,
@ -75,11 +89,38 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// 상태 - 버튼 모드
const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]);
// 상태 - 컬럼별 공통코드 옵션 (code 타입용)
const [columnCodeOptions, setColumnCodeOptions] = useState<Record<string, { label: string; value: string }[]>>({});
// 상태 - 엔티티 표시 정보 캐시 (FK값 → 표시 데이터)
const [entityDisplayCache, setEntityDisplayCache] = useState<Record<string | number, Record<string, any>>>({});
// 상태 - 소스 테이블 컬럼 정보 (라벨 매핑용)
const [sourceTableColumnMap, setSourceTableColumnMap] = useState<Record<string, string>>({});
// 상태 - 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, { inputType: string; detailSettings?: any }>>({});
// 🆕 전역 등록 - 리피터가 있으면 메인 폼 단독 저장 건너뛰기
useEffect(() => {
const tableName = config.dataSource?.tableName;
if (!tableName) return;
// 전역 Set 초기화
if (!window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances = new Set();
}
// 등록
window.__unifiedRepeaterInstances.add(tableName);
console.log("📦 UnifiedRepeater 등록:", tableName, Array.from(window.__unifiedRepeaterInstances));
return () => {
// 해제
window.__unifiedRepeaterInstances?.delete(tableName);
console.log("📦 UnifiedRepeater 해제:", tableName, Array.from(window.__unifiedRepeaterInstances || []));
};
}, [config.dataSource?.tableName]);
// 외부 데이터 변경 시 동기화
useEffect(() => {
@ -88,6 +129,138 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
}
}, [initialData]);
// 저장 이벤트 리스너 - 상위에서 저장 버튼 클릭 시 리피터 데이터도 저장
// 🔧 메인 폼 데이터 + 리피터 행 데이터를 병합해서 저장
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
const tableName = config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData || {}; // 🆕 메인 폼 데이터
if (!tableName || data.length === 0) {
console.log("📦 UnifiedRepeater 저장 스킵:", { tableName, dataCount: data.length, hasTable: !!tableName });
return;
}
console.log("📦 UnifiedRepeater 저장 이벤트 수신:", {
tableName,
dataCount: data.length,
eventParentId,
propsParentId: parentId,
mainFormDataKeys: Object.keys(mainFormData),
referenceKey: config.dataSource?.referenceKey
});
try {
// 새 데이터 삽입 (메인 폼 데이터 + 리피터 행 데이터 병합)
console.log("🔄 UnifiedRepeater 데이터 삽입 시작:", { dataCount: data.length, mainFormData });
// 🆕 테이블에 존재하는 컬럼 목록 조회 (존재하지 않는 컬럼 필터링용)
let validColumns: Set<string> = new Set();
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
console.log("📋 테이블 유효 컬럼:", Array.from(validColumns));
} catch {
console.warn("⚠️ 테이블 컬럼 정보 조회 실패 - 모든 필드 저장 시도");
}
for (let i = 0; i < data.length; i++) {
const row = data[i];
console.log(`🔄 [${i + 1}/${data.length}] 리피터 행 데이터:`, row);
// _display_ 필드와 임시 필드 제거
const cleanRow = Object.fromEntries(
Object.entries(row).filter(([key]) => !key.startsWith("_"))
);
// 🆕 메인 폼 데이터 + 리피터 행 데이터 병합
// 리피터 행 데이터가 우선 (같은 키가 있으면 덮어씀)
// 메인 폼에서 제외할 필드: id (각 행은 새 레코드)
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData;
const mergedData = {
...mainFormDataWithoutId, // 메인 폼 데이터 (id 제외)
...cleanRow, // 리피터 행 데이터 (우선)
};
// 🆕 테이블에 존재하지 않는 컬럼 제거
const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) {
// validColumns가 비어있으면 (조회 실패) 모든 필드 포함
// validColumns가 있으면 해당 컬럼만 포함
if (validColumns.size === 0 || validColumns.has(key)) {
filteredData[key] = value;
} else {
console.log(`🗑️ [${i + 1}/${data.length}] 필터링된 컬럼 (테이블에 없음): ${key}`);
}
}
console.log(`📝 [${i + 1}/${data.length}] 최종 저장 데이터:`, JSON.stringify(filteredData, null, 2));
try {
// /add 엔드포인트 사용 (INSERT)
const saveResponse = await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
console.log(`✅ [${i + 1}/${data.length}] 저장 성공:`, saveResponse.data);
} catch (saveError) {
console.error(`❌ [${i + 1}/${data.length}] 저장 실패:`, saveError);
throw saveError;
}
}
console.log("✅ UnifiedRepeater 전체 데이터 저장 완료:", data.length, "건");
} catch (error) {
console.error("❌ UnifiedRepeater 저장 실패:", error);
throw error; // 상위에서 에러 처리
}
};
window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => {
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [data, config.dataSource?.tableName, config.dataSource?.referenceKey, parentId]);
// 현재 테이블 컬럼 정보 로드 (inputType 매핑용)
useEffect(() => {
const loadCurrentTableColumnInfo = async () => {
const tableName = config.dataSource?.tableName;
if (!tableName) return;
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const colInfo: Record<string, { inputType: string; detailSettings?: any }> = {};
if (Array.isArray(columns)) {
columns.forEach((col: any) => {
const colName = col.columnName || col.column_name;
let detailSettings = col.detailSettings || col.detail_settings;
if (typeof detailSettings === "string") {
try {
detailSettings = JSON.parse(detailSettings);
} catch (e) {
detailSettings = null;
}
}
if (colName) {
colInfo[colName] = {
inputType: col.inputType || col.input_type || "text",
detailSettings,
};
}
});
}
setCurrentTableColumnInfo(colInfo);
console.log("현재 테이블 컬럼 정보 로드:", tableName, colInfo);
} catch (error) {
console.error("현재 테이블 컬럼 정보 로드 실패:", error);
}
};
loadCurrentTableColumnInfo();
}, [config.dataSource?.tableName]);
// 소스 테이블 컬럼 정보 로드 (라벨 매핑용)
useEffect(() => {
const loadSourceTableColumns = async () => {
@ -157,6 +330,59 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
loadCodeButtons();
}, [config.button?.sourceType, config.button?.commonCode]);
// 컬럼별 공통코드 옵션 로드 (code 타입 컬럼용)
useEffect(() => {
const loadColumnCodeOptions = async () => {
// config.columns와 currentTableColumnInfo를 함께 확인하여 code 타입 컬럼 찾기
const codeColumnsToLoad: { key: string; codeGroup: string }[] = [];
config.columns.forEach((col) => {
// 저장된 설정에서 codeGroup 확인
let codeGroup = col.detailSettings?.codeGroup;
let inputType = col.inputType;
// 저장된 정보가 없으면 현재 테이블 정보에서 확인
if (!inputType || inputType === "text") {
const tableColInfo = currentTableColumnInfo[col.key];
if (tableColInfo) {
inputType = tableColInfo.inputType;
if (!codeGroup && tableColInfo.detailSettings?.codeGroup) {
codeGroup = tableColInfo.detailSettings.codeGroup;
}
}
}
if (inputType === "code" && codeGroup) {
codeColumnsToLoad.push({ key: col.key, codeGroup });
}
});
if (codeColumnsToLoad.length === 0) return;
const newOptions: Record<string, { label: string; value: string }[]> = {};
await Promise.all(
codeColumnsToLoad.map(async ({ key, codeGroup }) => {
try {
const response = await commonCodeApi.codes.getList(codeGroup);
if (response.success && response.data) {
newOptions[key] = response.data.map((code) => ({
label: code.codeName,
value: code.codeValue,
}));
}
} catch (error) {
console.error(`공통코드 로드 실패 (${codeGroup}):`, error);
}
})
);
setColumnCodeOptions((prev) => ({ ...prev, ...newOptions }));
};
loadColumnCodeOptions();
}, [config.columns, currentTableColumnInfo]);
// 소스 테이블 검색 (modal 모드)
const searchSourceTable = useCallback(async () => {
const sourceTable = config.dataSource?.sourceTable;
@ -491,6 +717,105 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
return entityDisplayCache[fkValue] || null;
};
// inputType별 입력 컴포넌트 렌더링
const renderColumnInput = (col: RepeaterColumnConfig, value: any, onChange: (value: any) => void) => {
// 저장된 inputType이 없거나 "text"이면 현재 테이블 정보에서 조회
let inputType = col.inputType;
let detailSettings = col.detailSettings;
if (!inputType || inputType === "text") {
const tableColInfo = currentTableColumnInfo[col.key];
if (tableColInfo) {
inputType = tableColInfo.inputType;
detailSettings = tableColInfo.detailSettings || detailSettings;
}
}
inputType = inputType || "text";
const commonClasses = "h-8 text-sm min-w-[80px] w-full";
console.log("renderColumnInput:", { key: col.key, inputType, value, detailSettings });
switch (inputType) {
case "number":
case "decimal":
return (
<Input
type="number"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className={commonClasses}
onClick={(e) => e.stopPropagation()}
step={inputType === "decimal" ? "0.01" : "1"}
/>
);
case "date":
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(commonClasses, "justify-start text-left font-normal", !value && "text-muted-foreground")}
onClick={(e) => e.stopPropagation()}
>
<Calendar className="mr-2 h-4 w-4" />
{value ? format(new Date(value), "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={value ? new Date(value) : undefined}
onSelect={(date) => onChange(date ? format(date, "yyyy-MM-dd") : "")}
initialFocus
/>
</PopoverContent>
</Popover>
);
case "code":
const codeOptions = columnCodeOptions[col.key] || [];
return (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className={commonClasses} onClick={(e) => e.stopPropagation()}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{codeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "boolean":
case "checkbox":
return (
<div className="flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={value === true || value === "Y" || value === "1"}
onCheckedChange={(checked) => onChange(checked ? "Y" : "N")}
/>
</div>
);
case "text":
default:
return (
<Input
type="text"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className={commonClasses}
onClick={(e) => e.stopPropagation()}
/>
);
}
};
// 테이블 렌더링
const renderTable = () => {
if (config.renderMode === "button") return null;
@ -598,19 +923,17 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
.map((col) => (
<TableCell key={col.key} className="py-2 min-w-[100px]">
{editingRow === index && col.editable !== false ? (
<Input
value={editedData[col.key] || ""}
onChange={(e) =>
setEditedData((prev) => ({
...prev,
[col.key]: e.target.value,
}))
}
className="h-7 text-xs min-w-[80px] w-full"
onClick={(e) => e.stopPropagation()}
/>
renderColumnInput(
col,
editedData[col.key],
(value) => setEditedData((prev) => ({ ...prev, [col.key]: value }))
)
) : (
<span className="text-sm">{row[col.key] || "-"}</span>
<span className="text-sm">
{col.inputType === "code" && columnCodeOptions[col.key]
? columnCodeOptions[col.key].find((opt) => opt.value === row[col.key])?.label || row[col.key] || "-"
: row[col.key] || "-"}
</span>
)}
</TableCell>
))}

View File

@ -61,6 +61,13 @@ interface ColumnOption {
columnName: string;
displayName: string;
inputType?: string;
detailSettings?: {
codeGroup?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
format?: string;
};
}
interface EntityColumnOption {
@ -194,27 +201,34 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
const entityCols: EntityColumnOption[] = [];
for (const c of columnData) {
// detailSettings 파싱
let detailSettings: any = null;
if (c.detailSettings) {
try {
detailSettings = typeof c.detailSettings === "string"
? JSON.parse(c.detailSettings)
: c.detailSettings;
} catch (e) {
console.warn("detailSettings 파싱 실패:", c.detailSettings);
}
}
const col: ColumnOption = {
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
inputType: c.inputType || c.input_type,
detailSettings: detailSettings ? {
codeGroup: detailSettings.codeGroup,
referenceTable: detailSettings.referenceTable,
referenceColumn: detailSettings.referenceColumn,
displayColumn: detailSettings.displayColumn,
format: detailSettings.format,
} : undefined,
};
cols.push(col);
// 엔티티 타입 컬럼 감지
if (col.inputType === "entity") {
let detailSettings: any = null;
if (c.detailSettings) {
try {
detailSettings = typeof c.detailSettings === "string"
? JSON.parse(c.detailSettings)
: c.detailSettings;
} catch (e) {
console.warn("detailSettings 파싱 실패:", c.detailSettings);
}
}
const referenceTable = detailSettings?.referenceTable || c.referenceTable;
const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id";
const displayColumn = detailSettings?.displayColumn || c.displayColumn;
@ -335,12 +349,21 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
const newColumns = config.columns.filter((c) => c.key !== column.columnName);
updateConfig({ columns: newColumns });
} else {
// 컬럼의 inputType과 detailSettings 정보 포함
const newColumn: RepeaterColumnConfig = {
key: column.columnName,
title: column.displayName,
width: "auto",
visible: true,
editable: true,
inputType: column.inputType || "text",
detailSettings: column.detailSettings ? {
codeGroup: column.detailSettings.codeGroup,
referenceTable: column.detailSettings.referenceTable,
referenceColumn: column.detailSettings.referenceColumn,
displayColumn: column.detailSettings.displayColumn,
format: column.detailSettings.format,
} : undefined,
};
updateConfig({ columns: [...config.columns, newColumn] });
}

View File

@ -398,10 +398,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
<UnifiedRepeater
config={{
renderMode: config.renderMode || "inline",
dataSource: config.dataSource || {
tableName: "",
foreignKey: "",
referenceKey: "",
dataSource: {
tableName: config.dataSource?.tableName || props.tableName || "",
foreignKey: config.dataSource?.foreignKey || "",
referenceKey: config.dataSource?.referenceKey || "",
sourceTable: config.dataSource?.sourceTable,
displayColumn: config.dataSource?.displayColumn,
},
columns: config.columns || [],
modal: config.modal,

View File

@ -706,7 +706,12 @@ export class ButtonActionExecutor {
if (!response.ok) {
throw new Error(`저장 실패: ${response.statusText}`);
}
} else if (tableName && screenId) {
}
// saveResult를 상위 스코프에서 정의 (repeaterSave 이벤트에서 사용)
let saveResult: { success: boolean; data?: any; message?: string } | undefined;
if (tableName && screenId) {
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
@ -732,8 +737,6 @@ export class ButtonActionExecutor {
primaryKeys,
});
let saveResult;
if (isUpdate) {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
console.log("🔄 UPDATE 모드로 저장:", {
@ -1088,19 +1091,28 @@ export class ButtonActionExecutor {
}
}
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
// 🆕 UnifiedRepeater 전역 등록 확인
// @ts-ignore - window에 동적 속성 사용
const unifiedRepeaterTables = Array.from(window.__unifiedRepeaterInstances || []);
// 메인 저장 건너뛰기 조건:
// 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
// 2. UnifiedRepeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨)
const shouldSkipMainSave =
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
repeatScreenModalTables.includes(tableName) ||
repeaterFieldGroupTables.includes(tableName) ||
unifiedRepeaterTables.includes(tableName);
if (shouldSkipMainSave) {
console.log(
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀`,
{
repeatScreenModalTables,
repeaterFieldGroupTables,
unifiedRepeaterTables,
},
);
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/UnifiedRepeater에서 처리" };
} else {
saveResult = await DynamicFormApi.saveFormData({
screenId,
@ -1229,6 +1241,40 @@ export class ButtonActionExecutor {
// 저장 성공 후 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
// UnifiedRepeater 저장 이벤트 발생 (메인 폼 데이터 + 리피터 데이터 병합 저장)
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
// 메인 폼 데이터 구성 (사용자 정보 포함)
const mainFormData = {
...formData,
writer: formData.writer || context.userId,
created_by: context.userId,
updated_by: context.userId,
company_code: formData.company_code || context.companyCode,
};
// _numberingRuleId 등 메타 필드 제거
for (const key of Object.keys(mainFormData)) {
if (key.endsWith("_numberingRuleId") || key.startsWith("_")) {
delete mainFormData[key];
}
}
console.log("🔗 [handleSave] repeaterSave 이벤트 발생:", {
savedId,
tableName: context.tableName,
mainFormDataKeys: Object.keys(mainFormData),
saveResultData: saveResult?.data
});
window.dispatchEvent(new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData, // 🆕 메인 폼 데이터 전달
}
}));
return true;
} catch (error) {

View File

@ -38,6 +38,16 @@ export interface RepeaterColumnConfig {
editable?: boolean; // 편집 가능 여부 (inline 모드)
isJoinColumn?: boolean;
sourceTable?: string;
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
inputType?: string; // text, number, date, code, entity 등
// 입력 타입별 상세 설정
detailSettings?: {
codeGroup?: string; // code 타입용
referenceTable?: string; // entity 타입용
referenceColumn?: string;
displayColumn?: string;
format?: string; // date, number 타입용
};
}
// 버튼 설정 (수동 모드)