feature/v2-unified-renewal #379
|
|
@ -541,6 +541,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
const response = await dynamicFormApi.saveData(saveData);
|
const response = await dynamicFormApi.saveData(saveData);
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
// 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
||||||
|
window.dispatchEvent(new CustomEvent("repeaterSave", {
|
||||||
|
detail: { parentId: response.data?.id || formData.id }
|
||||||
|
}));
|
||||||
|
|
||||||
toast.success("데이터가 성공적으로 저장되었습니다.");
|
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.message || "저장에 실패했습니다.");
|
toast.error(response.message || "저장에 실패했습니다.");
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,23 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { 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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
UnifiedRepeaterConfig,
|
UnifiedRepeaterConfig,
|
||||||
UnifiedRepeaterProps,
|
UnifiedRepeaterProps,
|
||||||
RepeaterButtonConfig,
|
RepeaterButtonConfig,
|
||||||
ButtonActionType,
|
ButtonActionType,
|
||||||
|
RepeaterColumnConfig,
|
||||||
DEFAULT_REPEATER_CONFIG,
|
DEFAULT_REPEATER_CONFIG,
|
||||||
} from "@/types/unified-repeater";
|
} from "@/types/unified-repeater";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
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 = {
|
const MODAL_SIZE_MAP = {
|
||||||
|
|
@ -37,6 +43,14 @@ const MODAL_SIZE_MAP = {
|
||||||
full: "max-w-[95vw]",
|
full: "max-w-[95vw]",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
|
||||||
|
// 리피터가 있으면 메인 폼은 리피터 데이터에 병합되어 저장되므로 별도 저장 불필요
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__unifiedRepeaterInstances?: Set<string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
config: propConfig,
|
config: propConfig,
|
||||||
parentId,
|
parentId,
|
||||||
|
|
@ -75,12 +89,39 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
// 상태 - 버튼 모드
|
// 상태 - 버튼 모드
|
||||||
const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]);
|
const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]);
|
||||||
|
|
||||||
|
// 상태 - 컬럼별 공통코드 옵션 (code 타입용)
|
||||||
|
const [columnCodeOptions, setColumnCodeOptions] = useState<Record<string, { label: string; value: string }[]>>({});
|
||||||
|
|
||||||
// 상태 - 엔티티 표시 정보 캐시 (FK값 → 표시 데이터)
|
// 상태 - 엔티티 표시 정보 캐시 (FK값 → 표시 데이터)
|
||||||
const [entityDisplayCache, setEntityDisplayCache] = useState<Record<string | number, Record<string, any>>>({});
|
const [entityDisplayCache, setEntityDisplayCache] = useState<Record<string | number, Record<string, any>>>({});
|
||||||
|
|
||||||
// 상태 - 소스 테이블 컬럼 정보 (라벨 매핑용)
|
// 상태 - 소스 테이블 컬럼 정보 (라벨 매핑용)
|
||||||
const [sourceTableColumnMap, setSourceTableColumnMap] = useState<Record<string, string>>({});
|
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(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
|
|
@ -88,6 +129,138 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
}
|
}
|
||||||
}, [initialData]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const loadSourceTableColumns = async () => {
|
const loadSourceTableColumns = async () => {
|
||||||
|
|
@ -157,6 +330,59 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
loadCodeButtons();
|
loadCodeButtons();
|
||||||
}, [config.button?.sourceType, config.button?.commonCode]);
|
}, [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 모드)
|
// 소스 테이블 검색 (modal 모드)
|
||||||
const searchSourceTable = useCallback(async () => {
|
const searchSourceTable = useCallback(async () => {
|
||||||
const sourceTable = config.dataSource?.sourceTable;
|
const sourceTable = config.dataSource?.sourceTable;
|
||||||
|
|
@ -491,6 +717,105 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
return entityDisplayCache[fkValue] || null;
|
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 = () => {
|
const renderTable = () => {
|
||||||
if (config.renderMode === "button") return null;
|
if (config.renderMode === "button") return null;
|
||||||
|
|
@ -598,19 +923,17 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
<TableCell key={col.key} className="py-2 min-w-[100px]">
|
<TableCell key={col.key} className="py-2 min-w-[100px]">
|
||||||
{editingRow === index && col.editable !== false ? (
|
{editingRow === index && col.editable !== false ? (
|
||||||
<Input
|
renderColumnInput(
|
||||||
value={editedData[col.key] || ""}
|
col,
|
||||||
onChange={(e) =>
|
editedData[col.key],
|
||||||
setEditedData((prev) => ({
|
(value) => setEditedData((prev) => ({ ...prev, [col.key]: value }))
|
||||||
...prev,
|
)
|
||||||
[col.key]: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-7 text-xs min-w-[80px] w-full"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,13 @@ interface ColumnOption {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
inputType?: string;
|
inputType?: string;
|
||||||
|
detailSettings?: {
|
||||||
|
codeGroup?: string;
|
||||||
|
referenceTable?: string;
|
||||||
|
referenceColumn?: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
format?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityColumnOption {
|
interface EntityColumnOption {
|
||||||
|
|
@ -194,17 +201,8 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
const entityCols: EntityColumnOption[] = [];
|
const entityCols: EntityColumnOption[] = [];
|
||||||
|
|
||||||
for (const c of columnData) {
|
for (const c of columnData) {
|
||||||
const col: ColumnOption = {
|
// detailSettings 파싱
|
||||||
columnName: c.columnName || c.column_name,
|
|
||||||
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
|
|
||||||
inputType: c.inputType || c.input_type,
|
|
||||||
};
|
|
||||||
cols.push(col);
|
|
||||||
|
|
||||||
// 엔티티 타입 컬럼 감지
|
|
||||||
if (col.inputType === "entity") {
|
|
||||||
let detailSettings: any = null;
|
let detailSettings: any = null;
|
||||||
|
|
||||||
if (c.detailSettings) {
|
if (c.detailSettings) {
|
||||||
try {
|
try {
|
||||||
detailSettings = typeof c.detailSettings === "string"
|
detailSettings = typeof c.detailSettings === "string"
|
||||||
|
|
@ -215,6 +213,22 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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") {
|
||||||
const referenceTable = detailSettings?.referenceTable || c.referenceTable;
|
const referenceTable = detailSettings?.referenceTable || c.referenceTable;
|
||||||
const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id";
|
const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id";
|
||||||
const displayColumn = detailSettings?.displayColumn || c.displayColumn;
|
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);
|
const newColumns = config.columns.filter((c) => c.key !== column.columnName);
|
||||||
updateConfig({ columns: newColumns });
|
updateConfig({ columns: newColumns });
|
||||||
} else {
|
} else {
|
||||||
|
// 컬럼의 inputType과 detailSettings 정보 포함
|
||||||
const newColumn: RepeaterColumnConfig = {
|
const newColumn: RepeaterColumnConfig = {
|
||||||
key: column.columnName,
|
key: column.columnName,
|
||||||
title: column.displayName,
|
title: column.displayName,
|
||||||
width: "auto",
|
width: "auto",
|
||||||
visible: true,
|
visible: true,
|
||||||
editable: 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] });
|
updateConfig({ columns: [...config.columns, newColumn] });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,10 +398,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
<UnifiedRepeater
|
<UnifiedRepeater
|
||||||
config={{
|
config={{
|
||||||
renderMode: config.renderMode || "inline",
|
renderMode: config.renderMode || "inline",
|
||||||
dataSource: config.dataSource || {
|
dataSource: {
|
||||||
tableName: "",
|
tableName: config.dataSource?.tableName || props.tableName || "",
|
||||||
foreignKey: "",
|
foreignKey: config.dataSource?.foreignKey || "",
|
||||||
referenceKey: "",
|
referenceKey: config.dataSource?.referenceKey || "",
|
||||||
|
sourceTable: config.dataSource?.sourceTable,
|
||||||
|
displayColumn: config.dataSource?.displayColumn,
|
||||||
},
|
},
|
||||||
columns: config.columns || [],
|
columns: config.columns || [],
|
||||||
modal: config.modal,
|
modal: config.modal,
|
||||||
|
|
|
||||||
|
|
@ -706,7 +706,12 @@ export class ButtonActionExecutor {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`저장 실패: ${response.statusText}`);
|
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 자동 판단
|
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
|
||||||
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
||||||
|
|
||||||
|
|
@ -732,8 +737,6 @@ export class ButtonActionExecutor {
|
||||||
primaryKeys,
|
primaryKeys,
|
||||||
});
|
});
|
||||||
|
|
||||||
let saveResult;
|
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
|
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
|
||||||
console.log("🔄 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 =
|
const shouldSkipMainSave =
|
||||||
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
|
repeatScreenModalTables.includes(tableName) ||
|
||||||
|
repeaterFieldGroupTables.includes(tableName) ||
|
||||||
|
unifiedRepeaterTables.includes(tableName);
|
||||||
|
|
||||||
if (shouldSkipMainSave) {
|
if (shouldSkipMainSave) {
|
||||||
console.log(
|
console.log(
|
||||||
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀`,
|
||||||
{
|
{
|
||||||
repeatScreenModalTables,
|
repeatScreenModalTables,
|
||||||
repeaterFieldGroupTables,
|
repeaterFieldGroupTables,
|
||||||
|
unifiedRepeaterTables,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/UnifiedRepeater에서 처리" };
|
||||||
} else {
|
} else {
|
||||||
saveResult = await DynamicFormApi.saveFormData({
|
saveResult = await DynamicFormApi.saveFormData({
|
||||||
screenId,
|
screenId,
|
||||||
|
|
@ -1230,6 +1242,40 @@ export class ButtonActionExecutor {
|
||||||
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
|
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
|
||||||
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 오류:", error);
|
console.error("저장 오류:", error);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,16 @@ export interface RepeaterColumnConfig {
|
||||||
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
||||||
isJoinColumn?: boolean;
|
isJoinColumn?: boolean;
|
||||||
sourceTable?: string;
|
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 타입용
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버튼 설정 (수동 모드)
|
// 버튼 설정 (수동 모드)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue