refactor: 코드 정리 및 가독성 향상

- numberingRuleController.ts에서 API 엔드포인트의 코드 스타일을 일관되게 정리하여 가독성을 높였습니다.
- 불필요한 줄바꿈을 제거하고, 코드 블록을 명확하게 정리하여 유지보수성을 개선했습니다.
- tableManagementService.ts와 ButtonConfigPanel.tsx에서 코드 정리를 통해 일관성을 유지하고, 가독성을 향상시켰습니다.
- 전반적으로 코드의 깔끔함을 유지하고, 향후 개발 시 이해하기 쉽게 개선했습니다.
This commit is contained in:
kjs
2026-02-05 17:38:06 +09:00
parent 73d05b991c
commit e31bb970a2
11 changed files with 1570 additions and 1348 deletions

View File

@@ -51,13 +51,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}) => {
// 🔧 component가 없는 경우 방어 처리
if (!component) {
return (
<div className="p-4 text-sm text-muted-foreground">
.
</div>
);
return <div className="text-muted-foreground p-4 text-sm"> .</div>;
}
// 🔧 component에서 직접 읽기 (useMemo 제거)
const config = component.componentConfig || {};
const currentAction = component.componentConfig?.action || {};
@@ -122,7 +118,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [modalActionTargetTable, setModalActionTargetTable] = useState<string | null>(null);
const [modalActionSourceColumns, setModalActionSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionTargetColumns, setModalActionTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<Array<{ sourceField: string; targetField: string }>>([]);
const [modalActionFieldMappings, setModalActionFieldMappings] = useState<
Array<{ sourceField: string; targetField: string }>
>([]);
const [modalFieldMappingSourceOpen, setModalFieldMappingSourceOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingTargetOpen, setModalFieldMappingTargetOpen] = useState<Record<number, boolean>>({});
const [modalFieldMappingSourceSearch, setModalFieldMappingSourceSearch] = useState<Record<number, string>>({});
@@ -353,7 +351,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
useEffect(() => {
const actionType = config.action?.type;
if (actionType !== "modal") return;
const autoDetect = config.action?.autoDetectDataSource;
if (!autoDetect) {
// 데이터 전달이 비활성화되면 상태 초기화
@@ -363,19 +361,19 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
setModalActionTargetColumns([]);
return;
}
const targetScreenId = config.action?.targetScreenId;
if (!targetScreenId) return;
const loadModalActionMappingData = async () => {
// 1. 소스 테이블 감지 (현재 화면)
let sourceTableName: string | null = currentTableName || null;
// allComponents에서 분할패널/테이블리스트/통합목록 감지
for (const comp of allComponents) {
const compType = comp.componentType || (comp as any).componentConfig?.type;
const compConfig = (comp as any).componentConfig || {};
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
sourceTableName = compConfig.leftPanel?.tableName || compConfig.tableName || null;
if (sourceTableName) break;
@@ -389,9 +387,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
if (sourceTableName) break;
}
}
setModalActionSourceTable(sourceTableName);
// 2. 대상 화면의 테이블 조회
let targetTableName: string | null = null;
try {
@@ -405,9 +403,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
} catch (error) {
console.error("대상 화면 정보 로드 실패:", error);
}
setModalActionTargetTable(targetTableName);
// 3. 소스 테이블 컬럼 로드
if (sourceTableName) {
try {
@@ -416,7 +414,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
@@ -429,7 +427,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
console.error("소스 테이블 컬럼 로드 실패:", error);
}
}
// 4. 대상 테이블 컬럼 로드
if (targetTableName) {
try {
@@ -438,7 +436,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
@@ -451,7 +449,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
console.error("대상 테이블 컬럼 로드 실패:", error);
}
}
// 5. 기존 필드 매핑 로드 또는 자동 매핑 생성
const existingMappings = config.action?.fieldMappings || [];
if (existingMappings.length > 0) {
@@ -461,10 +459,16 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
setModalActionFieldMappings([]); // 빈 배열 = 자동 매핑
}
};
loadModalActionMappingData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.action?.type, config.action?.autoDetectDataSource, config.action?.targetScreenId, currentTableName, allComponents]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
config.action?.type,
config.action?.autoDetectDataSource,
config.action?.targetScreenId,
currentTableName,
allComponents,
]);
// 🆕 현재 테이블 컬럼 로드 (그룹화 컬럼 선택용)
useEffect(() => {
@@ -818,25 +822,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
{/* 엑셀 관련 */}
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
{/* 특수 기능 (필요 시 사용) */}
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="operation_control"> </SelectItem>
{/* 이벤트 버스 */}
<SelectItem value="event"> </SelectItem>
{/* 복사 */}
<SelectItem value="copy"> ( )</SelectItem>
{/* 🔒 숨김 처리 - 기존 시스템 호환성 유지, UI에서만 숨김
<SelectItem value="openRelatedModal">연관 데이터 버튼 모달 열기</SelectItem>
<SelectItem value="openModalWithData">(deprecated) 데이터 전달 + 모달 열기</SelectItem>
@@ -985,10 +989,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}}
/>
<div className="flex flex-col">
<Label htmlFor="auto-detect-data-source" className="text-sm cursor-pointer">
<Label htmlFor="auto-detect-data-source" className="cursor-pointer text-sm">
</Label>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
TableList/SplitPanel에서
</p>
</div>
@@ -996,11 +1000,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 🆕 필드 매핑 UI (데이터 전달 활성화 + 테이블이 다른 경우) */}
{component.componentConfig?.action?.autoDetectDataSource === true && (
<div className="mt-4 space-y-3 rounded-lg border bg-background p-3">
<div className="bg-background mt-4 space-y-3 rounded-lg border p-3">
{/* 테이블 정보 표시 */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<Database className="h-3 w-3 text-muted-foreground" />
<Database className="text-muted-foreground h-3 w-3" />
<span className="text-muted-foreground">:</span>
<span className="font-medium">{modalActionSourceTable || "감지 중..."}</span>
</div>
@@ -1012,171 +1016,210 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
{/* 테이블이 같으면 자동 매핑 안내 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable === modalActionTargetTable && (
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
. .
</div>
)}
{modalActionSourceTable &&
modalActionTargetTable &&
modalActionSourceTable === modalActionTargetTable && (
<div className="rounded-md bg-green-50 p-2 text-xs text-green-700 dark:bg-green-950/30 dark:text-green-400">
. .
</div>
)}
{/* 테이블이 다르면 필드 매핑 UI 표시 */}
{modalActionSourceTable && modalActionTargetTable && modalActionSourceTable !== modalActionTargetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || []), { sourceField: "", targetField: "" }];
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
<p className="text-xs text-muted-foreground">
. .
</p>
)}
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.sourceField
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionSourceColumns
.filter((col) =>
col.name.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
col.label.toLowerCase().includes((modalFieldMappingSourceSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
newMappings[index] = { ...newMappings[index], sourceField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.sourceField === col.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) => setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.targetField
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) => setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionTargetColumns
.filter((col) =>
col.name.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
col.label.toLowerCase().includes((modalFieldMappingTargetSearch[index] || "").toLowerCase())
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const newMappings = [...(component.componentConfig?.action?.fieldMappings || [])];
newMappings[index] = { ...newMappings[index], targetField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
}}
>
<Check
className={cn("mr-2 h-4 w-4", mapping.targetField === col.name ? "opacity-100" : "opacity-0")}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-[10px] text-muted-foreground">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 버튼 */}
{modalActionSourceTable &&
modalActionTargetTable &&
modalActionSourceTable !== modalActionTargetTable && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:bg-destructive/10"
className="h-6 text-xs"
onClick={() => {
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter((_: any, i: number) => i !== index);
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
{ sourceField: "", targetField: "" },
];
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<X className="h-3 w-3" />
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{(component.componentConfig?.action?.fieldMappings || []).length === 0 && (
<p className="text-muted-foreground text-xs">
. .
</p>
)}
{(component.componentConfig?.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2">
{/* 소스 필드 선택 */}
<Popover
open={modalFieldMappingSourceOpen[index] || false}
onOpenChange={(open) =>
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.sourceField
? modalActionSourceColumns.find((c) => c.name === mapping.sourceField)?.label ||
mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingSourceSearch[index] || ""}
onValueChange={(val) =>
setModalFieldMappingSourceSearch((prev) => ({ ...prev, [index]: val }))
}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionSourceColumns
.filter(
(col) =>
col.name
.toLowerCase()
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()) ||
col.label
.toLowerCase()
.includes((modalFieldMappingSourceSearch[index] || "").toLowerCase()),
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
];
newMappings[index] = { ...newMappings[index], sourceField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
setModalFieldMappingSourceOpen((prev) => ({ ...prev, [index]: false }));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<span className="text-muted-foreground text-xs"></span>
{/* 대상 필드 선택 */}
<Popover
open={modalFieldMappingTargetOpen[index] || false}
onOpenChange={(open) =>
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-7 flex-1 justify-between text-xs">
{mapping.targetField
? modalActionTargetColumns.find((c) => c.name === mapping.targetField)?.label ||
mapping.targetField
: "대상 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={modalFieldMappingTargetSearch[index] || ""}
onValueChange={(val) =>
setModalFieldMappingTargetSearch((prev) => ({ ...prev, [index]: val }))
}
/>
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{modalActionTargetColumns
.filter(
(col) =>
col.name
.toLowerCase()
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()) ||
col.label
.toLowerCase()
.includes((modalFieldMappingTargetSearch[index] || "").toLowerCase()),
)
.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
const newMappings = [
...(component.componentConfig?.action?.fieldMappings || []),
];
newMappings[index] = { ...newMappings[index], targetField: col.name };
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
setModalFieldMappingTargetOpen((prev) => ({ ...prev, [index]: false }));
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="text-xs font-medium">{col.label}</span>
<span className="text-muted-foreground text-[10px]">{col.name}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:bg-destructive/10 h-7 w-7 p-0"
onClick={() => {
const newMappings = (component.componentConfig?.action?.fieldMappings || []).filter(
(_: any, i: number) => i !== index,
);
setModalActionFieldMappings(newMappings);
onUpdateProperty("componentConfig.action.fieldMappings", newMappings);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
)}
</div>
@@ -1185,9 +1228,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 (deprecated - 하위 호환성 유지) */}
{component.componentConfig?.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-amber-50 p-4 dark:bg-amber-950/20">
<h4 className="text-sm font-medium text-foreground"> + </h4>
<h4 className="text-foreground text-sm font-medium"> + </h4>
<p className="text-xs text-amber-600 dark:text-amber-400">
"모달 열기" . "모달 열기" + "선택된 데이터 전달" .
"모달 열기" . "모달 열기" + "선택된 데이터 전달"
.
</p>
{/* 🆕 블록 기반 제목 빌더 */}
@@ -3546,8 +3590,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
V2 .
.
V2 .
.
</p>
<div>
@@ -3597,11 +3641,13 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
type="number"
className="h-8 text-xs"
placeholder="3"
value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3}
value={
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.leadTimeDays || 3
}
onChange={(e) => {
onUpdateProperty(
"componentConfig.action.eventConfig.eventPayload.config.scheduling.leadTimeDays",
parseInt(e.target.value) || 3
parseInt(e.target.value) || 3,
);
}}
/>
@@ -3613,11 +3659,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
type="number"
className="h-8 text-xs"
placeholder="100"
value={component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling?.maxDailyCapacity || 100}
value={
component.componentConfig?.action?.eventConfig?.eventPayload?.config?.scheduling
?.maxDailyCapacity || 100
}
onChange={(e) => {
onUpdateProperty(
"componentConfig.action.eventConfig.eventPayload.config.scheduling.maxDailyCapacity",
parseInt(e.target.value) || 100
parseInt(e.target.value) || 100,
);
}}
/>
@@ -3625,8 +3674,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/20">
<p className="text-xs text-blue-800 dark:text-blue-200">
<strong> :</strong> .
.
<strong> :</strong> .
.
</p>
</div>
</div>

View File

@@ -65,7 +65,7 @@ export function TabsWidget({
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]);
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
// 🆕 화면 진입 시 첫 번째 탭 자동 선택 및 마운트
useEffect(() => {
// 현재 선택된 탭이 유효하지 않거나 비어있으면 첫 번째 탭 선택
@@ -92,7 +92,7 @@ export function TabsWidget({
});
}
}, [tabs]); // tabs가 변경될 때마다 실행
// screenId 기반 화면 로드 상태
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
@@ -109,23 +109,28 @@ export function TabsWidget({
for (const tab of visibleTabs) {
const extTab = tab as ExtendedTabItem;
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) {
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true }));
if (
extTab.screenId &&
!screenLayouts[tab.id] &&
!screenLoadingStates[tab.id] &&
(!extTab.components || extTab.components.length === 0)
) {
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
try {
const layoutData = await screenApi.getLayout(extTab.screenId);
if (layoutData && layoutData.components) {
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components }));
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
}
} catch (error) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
} finally {
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false }));
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: false }));
}
}
}
};
loadScreenLayouts();
}, [visibleTabs, screenLayouts, screenLoadingStates]);
@@ -180,11 +185,7 @@ export function TabsWidget({
const getTabsListClass = () => {
const baseClass = orientation === "vertical" ? "flex-col" : "";
const variantClass =
variant === "pills"
? "bg-muted p-1 rounded-lg"
: variant === "underline"
? "border-b"
: "bg-muted p-1";
variant === "pills" ? "bg-muted p-1 rounded-lg" : variant === "underline" ? "border-b" : "bg-muted p-1";
return `${baseClass} ${variantClass}`;
};
@@ -192,47 +193,47 @@ export function TabsWidget({
const renderTabContent = (tab: ExtendedTabItem) => {
const extTab = tab as ExtendedTabItem;
const inlineComponents = tab.components || [];
// 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식
if (extTab.screenId && inlineComponents.length === 0) {
// 로딩 중
if (screenLoadingStates[tab.id]) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground"> ...</span>
<Loader2 className="text-primary h-8 w-8 animate-spin" />
<span className="text-muted-foreground ml-2"> ...</span>
</div>
);
}
// 에러 발생
if (screenErrors[tab.id]) {
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5">
<div className="border-destructive/50 bg-destructive/5 flex h-full w-full items-center justify-center rounded border-2 border-dashed">
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
</div>
);
}
// 화면 레이아웃이 로드된 경우
const loadedComponents = screenLayouts[tab.id];
if (loadedComponents && loadedComponents.length > 0) {
return renderScreenComponents(loadedComponents);
}
// 아직 로드되지 않은 경우
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
);
}
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
if (inlineComponents.length > 0) {
return renderInlineComponents(tab, inlineComponents);
}
// 3. 둘 다 없는 경우
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
@@ -246,22 +247,17 @@ export function TabsWidget({
// screenId로 로드한 화면 컴포넌트 렌더링
const renderScreenComponents = (components: ComponentData[]) => {
// InteractiveScreenViewerDynamic 동적 로드
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
const InteractiveScreenViewerDynamic =
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
300
);
const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400
);
const maxBottom = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), 300);
const maxRight = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 400);
return (
<div
<div
className="relative h-full w-full overflow-auto"
style={{
style={{
minHeight: maxBottom + 20,
minWidth: maxRight + 20,
}}
@@ -295,17 +291,17 @@ export function TabsWidget({
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
300 // 최소 높이
300, // 최소 높이
);
const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400 // 최소 너비
400, // 최소 너비
);
return (
<div
className="relative"
style={{
<div
className="relative"
style={{
minHeight: maxBottom + 20,
minWidth: maxRight + 20,
}}
@@ -319,7 +315,7 @@ export function TabsWidget({
className={cn(
"absolute",
isDesignMode && "cursor-move",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2"
isDesignMode && isSelected && "ring-primary ring-2 ring-offset-2",
)}
style={{
left: comp.position?.x || 0,
@@ -380,9 +376,7 @@ export function TabsWidget({
<TabsTrigger value={tab.id} disabled={tab.disabled} className="relative pr-8">
{tab.label}
{tab.components && tab.components.length > 0 && (
<span className="ml-1 text-xs text-muted-foreground">
({tab.components.length})
</span>
<span className="text-muted-foreground ml-1 text-xs">({tab.components.length})</span>
)}
</TabsTrigger>
{allowCloseable && (
@@ -390,7 +384,7 @@ export function TabsWidget({
onClick={(e) => handleCloseTab(tab.id, e)}
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-destructive/10"
className="hover:bg-destructive/10 absolute top-1/2 right-1 h-5 w-5 -translate-y-1/2 p-0"
>
<X className="h-3 w-3" />
</Button>

View File

@@ -8,7 +8,7 @@
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
*
* RepeaterTable 및 ItemSelectionModal 재사용
*
*
* 데이터 전달 인터페이스:
* - DataProvidable: 선택된 데이터 제공
* - DataReceivable: 외부에서 데이터 수신
@@ -124,83 +124,91 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// DataProvidable 인터페이스 구현
// 다른 컴포넌트에서 이 리피터의 데이터를 가져갈 수 있게 함
// ============================================================
const dataProvider: DataProvidable = useMemo(() => ({
componentId: parentId || config.fieldName || "unified-repeater",
componentType: "unified-repeater",
// 선택된 행 데이터 반환
getSelectedData: () => {
return Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean);
},
// 전체 데이터 반환
getAllData: () => {
return [...data];
},
// 선택 초기화
clearSelection: () => {
setSelectedRows(new Set());
},
}), [parentId, config.fieldName, data, selectedRows]);
const dataProvider: DataProvidable = useMemo(
() => ({
componentId: parentId || config.fieldName || "unified-repeater",
componentType: "unified-repeater",
// 선택된 행 데이터 반환
getSelectedData: () => {
return Array.from(selectedRows)
.map((idx) => data[idx])
.filter(Boolean);
},
// 전체 데이터 반환
getAllData: () => {
return [...data];
},
// 선택 초기화
clearSelection: () => {
setSelectedRows(new Set());
},
}),
[parentId, config.fieldName, data, selectedRows],
);
// ============================================================
// DataReceivable 인터페이스 구현
// 외부에서 이 리피터로 데이터를 전달받을 수 있게 함
// ============================================================
const dataReceiver: DataReceivable = useMemo(() => ({
componentId: parentId || config.fieldName || "unified-repeater",
componentType: "repeater",
// 데이터 수신 (append, replace, merge 모드 지원)
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
if (!incomingData || incomingData.length === 0) return;
const dataReceiver: DataReceivable = useMemo(
() => ({
componentId: parentId || config.fieldName || "unified-repeater",
componentType: "repeater",
// 매핑 규칙 적용
const mappedData = incomingData.map((item, index) => {
const newRow: any = { _id: `received_${Date.now()}_${index}` };
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
receiverConfig.mappingRules.forEach((rule) => {
const sourceValue = item[rule.sourceField];
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
// 데이터 수신 (append, replace, merge 모드 지원)
receiveData: async (incomingData: any[], receiverConfig: DataReceiverConfig) => {
if (!incomingData || incomingData.length === 0) return;
// 매핑 규칙 적용
const mappedData = incomingData.map((item, index) => {
const newRow: any = { _id: `received_${Date.now()}_${index}` };
if (receiverConfig.mappingRules && receiverConfig.mappingRules.length > 0) {
receiverConfig.mappingRules.forEach((rule) => {
const sourceValue = item[rule.sourceField];
newRow[rule.targetField] = sourceValue !== undefined ? sourceValue : rule.defaultValue;
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// 모드에 따라 데이터 처리
switch (receiverConfig.mode) {
case "replace":
setData(mappedData);
onDataChange?.(mappedData);
break;
case "merge":
// 중복 제거 후 병합 (id 또는 _id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id));
const mergedData = [...data, ...newItems];
setData(mergedData);
onDataChange?.(mergedData);
break;
case "append":
default:
const appendedData = [...data, ...mappedData];
setData(appendedData);
onDataChange?.(appendedData);
break;
}
return newRow;
});
},
// 모드에 따라 데이터 처리
switch (receiverConfig.mode) {
case "replace":
setData(mappedData);
onDataChange?.(mappedData);
break;
case "merge":
// 중복 제거 후 병합 (id 또는 _id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row) => !existingIds.has(row.id || row._id));
const mergedData = [...data, ...newItems];
setData(mergedData);
onDataChange?.(mergedData);
break;
case "append":
default:
const appendedData = [...data, ...mappedData];
setData(appendedData);
onDataChange?.(appendedData);
break;
}
},
// 현재 데이터 반환
getData: () => {
return [...data];
},
}), [parentId, config.fieldName, data, onDataChange]);
// 현재 데이터 반환
getData: () => {
return [...data];
},
}),
[parentId, config.fieldName, data, onDataChange],
);
// ============================================================
// ScreenContext에 DataProvider/DataReceiver 등록
@@ -208,7 +216,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
useEffect(() => {
if (screenContext && (parentId || config.fieldName)) {
const componentId = parentId || config.fieldName || "unified-repeater";
screenContext.registerDataProvider(componentId, dataProvider);
screenContext.registerDataReceiver(componentId, dataReceiver);
@@ -231,7 +239,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
componentId: parentId || config.fieldName || "unified-repeater",
tableName: config.dataSource?.tableName || "",
data: data,
selectedData: Array.from(selectedRows).map((idx) => data[idx]).filter(Boolean),
selectedData: Array.from(selectedRows)
.map((idx) => data[idx])
.filter(Boolean),
});
prevDataLengthRef.current = data.length;
}
@@ -701,19 +711,22 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// 🆕 채번 API 호출 (비동기)
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
const generateNumberingCode = useCallback(
async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
}
}, []);
},
[],
);
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => {

View File

@@ -6,7 +6,7 @@
* 렌더링 모드:
* - inline: 현재 테이블 컬럼 직접 입력
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
*
*
* RepeaterTable 및 ItemSelectionModal 재사용
*/
@@ -63,7 +63,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
// 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
@@ -72,10 +72,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
// 동적 데이터 소스 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
@@ -88,10 +88,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 전역 리피터 등록
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
useEffect(() => {
const targetTableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
const targetTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTableName) {
if (!window.__v2RepeaterInstances) {
window.__v2RepeaterInstances = new Set();
@@ -110,22 +109,21 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
const tableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
const tableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData;
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
if (!tableName || data.length === 0) {
return;
}
// V2Repeater 저장 시작
const saveInfo = {
tableName,
const saveInfo = {
tableName,
useCustomTable: config.useCustomTable,
mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn,
@@ -145,10 +143,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} catch {
console.warn("테이블 컬럼 정보 조회 실패");
}
for (let i = 0; i < data.length; i++) {
const row = data[i];
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
@@ -157,14 +155,14 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (config.useCustomTable && config.mainTableName) {
// 커스텀 테이블: 리피터 데이터만 저장
mergedData = { ...cleanRow };
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
if (config.foreignKeyColumn) {
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
// 없으면 마스터 레코드 ID 사용 (기존 동작)
const sourceColumn = config.foreignKeySourceColumn;
let fkValue: any;
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
// mainFormData에서 참조 컬럼 값 가져오기
fkValue = mainFormData[sourceColumn];
@@ -172,18 +170,18 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 기본: 마스터 레코드 ID 사용
fkValue = masterRecordId;
}
if (fkValue !== undefined && fkValue !== null) {
mergedData[config.foreignKeyColumn] = fkValue;
}
}
} else {
// 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = {
...mainFormDataWithoutId,
...cleanRow,
};
...mainFormDataWithoutId,
...cleanRow,
};
}
// 유효하지 않은 컬럼 제거
@@ -193,10 +191,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
filteredData[key] = value;
}
}
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
} catch (error) {
console.error("❌ V2Repeater 저장 실패:", error);
throw error;
@@ -207,14 +204,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE,
async (payload) => {
const tableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
const tableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (payload.tableName === tableName) {
await handleSaveEvent({ detail: payload } as CustomEvent);
}
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
@@ -223,7 +219,14 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
unsubscribe();
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
}, [
data,
config.dataSource?.tableName,
config.useCustomTable,
config.mainTableName,
config.foreignKeyColumn,
parentId,
]);
// 현재 테이블 컬럼 정보 로드
useEffect(() => {
@@ -234,7 +237,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const columnMap: Record<string, any> = {};
columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name;
@@ -320,7 +323,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
try {
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
const labels: Record<string, string> = {};
const categoryCols: string[] = [];
@@ -364,13 +367,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
calculated: true,
width: col.width === "auto" ? undefined : col.width,
};
}
}
// 일반 입력 컬럼
let type: "text" | "number" | "date" | "select" | "category" = "text";
if (inputType === "number" || inputType === "decimal") type = "number";
else if (inputType === "date" || inputType === "datetime") type = "date";
else if (inputType === "code") type = "select";
if (inputType === "number" || inputType === "decimal") type = "number";
else if (inputType === "date" || inputType === "datetime") type = "date";
else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
@@ -383,19 +386,19 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
categoryRef = `${tableName}.${col.key}`;
}
}
return {
field: col.key,
label: col.title || colInfo?.displayName || col.key,
type,
editable: col.editable !== false,
width: col.width === "auto" ? undefined : col.width,
required: false,
return {
field: col.key,
label: col.title || colInfo?.displayName || col.key,
type,
editable: col.editable !== false,
width: col.width === "auto" ? undefined : col.width,
required: false,
categoryRef, // 🆕 카테고리 참조 ID 전달
hidden: col.hidden, // 🆕 히든 처리
autoFill: col.autoFill, // 🆕 자동 입력 설정
};
});
};
});
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
@@ -451,26 +454,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
setData(newData);
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
if (onDataChange) {
const targetTable = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
if (targetTable) {
// 각 행에 _targetTable 추가
const dataWithTarget = newData.map(row => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
setData(newData);
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
if (onDataChange) {
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
// 각 행에 _targetTable 추가
const dataWithTarget = newData.map((row) => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
}
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
setAutoWidthTrigger((prev) => prev + 1);
},
@@ -480,26 +482,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const newData = [...data];
newData[index] = newRow;
setData(newData);
// 🆕 _targetTable 메타데이터 포함
if (onDataChange) {
const targetTable = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
if (targetTable) {
const dataWithTarget = newData.map(row => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
const newData = [...data];
newData[index] = newRow;
setData(newData);
// 🆕 _targetTable 메타데이터 포함
if (onDataChange) {
const targetTable =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (targetTable) {
const dataWithTarget = newData.map((row) => ({
...row,
_targetTable: targetTable,
}));
onDataChange(dataWithTarget);
} else {
onDataChange(newData);
}
}
}
},
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
);
@@ -507,16 +508,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 행 삭제 핸들러
const handleRowDelete = useCallback(
(index: number) => {
const newData = data.filter((_, i) => i !== index);
const newData = data.filter((_, i) => i !== index);
handleDataChange(newData); // 🆕 handleDataChange 사용
// 선택 상태 업데이트
const newSelected = new Set<number>();
selectedRows.forEach((i) => {
if (i < index) newSelected.add(i);
else if (i > index) newSelected.add(i - 1);
});
setSelectedRows(newSelected);
// 선택 상태 업데이트
const newSelected = new Set<number>();
selectedRows.forEach((i) => {
if (i < index) newSelected.add(i);
else if (i > index) newSelected.add(i - 1);
});
setSelectedRows(newSelected);
},
[data, selectedRows, handleDataChange],
);
@@ -535,30 +536,30 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (!col.autoFill || col.autoFill.type === "none") return undefined;
const now = new Date();
switch (col.autoFill.type) {
case "currentDate":
return now.toISOString().split("T")[0]; // YYYY-MM-DD
case "currentDateTime":
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
case "sequence":
return rowIndex + 1; // 1부터 시작하는 순번
case "numbering":
// 채번은 별도 비동기 처리 필요
return null; // null 반환하여 비동기 처리 필요함을 표시
case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) {
return mainFormData[col.autoFill.sourceField];
}
return "";
case "fixed":
return col.autoFill.fixedValue ?? "";
default:
return undefined;
}
@@ -568,19 +569,22 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 채번 API 호출 (비동기)
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
const generateNumberingCode = useCallback(async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
const generateNumberingCode = useCallback(
async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
try {
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
if (result.success && result.data?.generatedCode) {
return result.data.generatedCode;
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
}
console.error("채번 실패:", result.error);
return "";
} catch (error) {
console.error("채번 API 호출 실패:", error);
return "";
}
}, []);
},
[],
);
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
const handleAddRow = useCallback(async () => {
@@ -589,7 +593,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} else {
const newRow: any = { _id: `new_${Date.now()}` };
const currentRowCount = data.length;
// 먼저 동기적 자동 입력 값 적용
for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount);
@@ -599,10 +603,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} else if (autoValue !== undefined) {
newRow[col.key] = autoValue;
} else {
newRow[col.key] = "";
newRow[col.key] = "";
}
}
const newData = [...data, newRow];
handleDataChange(newData);
}
@@ -611,23 +615,23 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback(
async (items: Record<string, unknown>[]) => {
const fkColumn = config.dataSource?.foreignKey;
const fkColumn = config.dataSource?.foreignKey;
const currentRowCount = data.length;
// 채번이 필요한 컬럼 찾기
const numberingColumns = config.columns.filter(
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId,
);
const newRows = await Promise.all(
items.map(async (item, index) => {
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
// FK 값 저장 (resolvedReferenceKey 사용)
if (fkColumn && item[resolvedReferenceKey]) {
row[fkColumn] = item[resolvedReferenceKey];
}
}
// 모든 컬럼 처리 (순서대로)
for (const col of config.columns) {
if (col.isSourceDisplay) {
@@ -643,20 +647,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
row[col.key] = autoValue;
} else if (row[col.key] === undefined) {
// 입력 컬럼: 빈 값으로 초기화
row[col.key] = "";
}
row[col.key] = "";
}
}
}
return row;
})
return row;
}),
);
const newData = [...data, ...newRows];
const newData = [...data, ...newRows];
handleDataChange(newData);
setModalOpen(false);
setModalOpen(false);
},
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode],
[
config.dataSource?.foreignKey,
resolvedReferenceKey,
config.columns,
data,
handleDataChange,
generateAutoFillValueSync,
generateNumberingCode,
],
);
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
@@ -670,19 +682,19 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => {
const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent;
const formData = customEvent.detail?.formData;
if (!formData || !dataRef.current.length) return;
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
const processedData = await Promise.all(
dataRef.current.map(async (row) => {
const newRow = { ...row };
for (const key of Object.keys(newRow)) {
const value = newRow[key];
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
@@ -706,16 +718,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}
}
}
return newRow;
}),
);
// 처리된 데이터를 formData에 추가
const fieldName = config.fieldName || "repeaterData";
formData[fieldName] = processedData;
};
// V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.FORM_SAVE_COLLECT,
@@ -726,12 +738,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} as CustomEvent;
await handleBeforeFormSave(fakeEvent);
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("beforeFormSave", handleBeforeFormSave);
return () => {
unsubscribe();
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
@@ -744,20 +756,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const handleComponentDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
// 이 컴포넌트가 대상인지 확인
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
return;
}
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
// 매핑 규칙이 있으면 적용
mappingRules.forEach((rule: any) => {
@@ -767,10 +779,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
@@ -784,20 +796,20 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
handleDataChange([...data, ...mappedData]);
}
};
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
const handleSplitPanelDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
@@ -805,10 +817,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} else {
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
@@ -816,7 +828,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
handleDataChange([...data, ...mappedData]);
}
};
// V2 EventBus 구독
const unsubscribeComponent = v2EventBus.subscribe(
V2_EVENTS.COMPONENT_DATA_TRANSFER,
@@ -831,7 +843,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} as CustomEvent;
handleComponentDataTransfer(fakeEvent);
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
const unsubscribeSplitPanel = v2EventBus.subscribe(
@@ -846,13 +858,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} as CustomEvent;
handleSplitPanelDataTransfer(fakeEvent);
},
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
);
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
unsubscribeComponent();
unsubscribeSplitPanel();
@@ -928,11 +940,7 @@ V2Repeater.displayName = "V2Repeater";
// V2ErrorBoundary로 래핑된 안전한 버전 export
export const SafeV2Repeater: React.FC<V2RepeaterProps> = (props) => {
return (
<V2ErrorBoundary
componentId={props.parentId || "v2-repeater"}
componentType="V2Repeater"
fallbackStyle="compact"
>
<V2ErrorBoundary componentId={props.parentId || "v2-repeater"} componentType="V2Repeater" fallbackStyle="compact">
<V2Repeater {...props} />
</V2ErrorBoundary>
);