반복입력 컴포넌트 통합
This commit is contained in:
parent
9af7fe5b98
commit
2513b89ca2
|
|
@ -0,0 +1,628 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedRepeater 컴포넌트
|
||||
*
|
||||
* 기존 컴포넌트 통합:
|
||||
* - simple-repeater-table: 인라인 모드
|
||||
* - modal-repeater-table: 모달 모드
|
||||
* - repeat-screen-modal: 화면 기반 모달 모드
|
||||
* - related-data-buttons: 버튼 모드
|
||||
*
|
||||
* 모든 하드코딩을 제거하고 설정 기반으로 동작합니다.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
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, Edit, Eye, GripVertical } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
UnifiedRepeaterConfig,
|
||||
UnifiedRepeaterProps,
|
||||
RepeaterButtonConfig,
|
||||
ButtonActionType,
|
||||
DEFAULT_REPEATER_CONFIG,
|
||||
} from "@/types/unified-repeater";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
|
||||
// 모달 크기 매핑
|
||||
const MODAL_SIZE_MAP = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-lg",
|
||||
lg: "max-w-2xl",
|
||||
xl: "max-w-4xl",
|
||||
full: "max-w-[95vw]",
|
||||
};
|
||||
|
||||
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||
config: propConfig,
|
||||
parentId,
|
||||
data: initialData,
|
||||
onDataChange,
|
||||
onRowClick,
|
||||
onButtonClick,
|
||||
className,
|
||||
}) => {
|
||||
// 설정 병합
|
||||
const config: UnifiedRepeaterConfig = useMemo(
|
||||
() => ({
|
||||
...DEFAULT_REPEATER_CONFIG,
|
||||
...propConfig,
|
||||
dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource },
|
||||
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features },
|
||||
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal },
|
||||
button: { ...DEFAULT_REPEATER_CONFIG.button, ...propConfig.button },
|
||||
}),
|
||||
[propConfig],
|
||||
);
|
||||
|
||||
// 상태
|
||||
const [data, setData] = useState<any[]>(initialData || []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [editingRow, setEditingRow] = useState<number | null>(null);
|
||||
const [editedData, setEditedData] = useState<Record<string, any>>({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalRow, setModalRow] = useState<any>(null);
|
||||
const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
if (!config.dataSource?.tableName || !parentId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/dynamic-form/${config.dataSource.tableName}`, {
|
||||
params: {
|
||||
[config.dataSource.foreignKey]: parentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const items = Array.isArray(response.data.data) ? response.data.data : [response.data.data];
|
||||
setData(items);
|
||||
onDataChange?.(items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("UnifiedRepeater 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config.dataSource?.tableName, config.dataSource?.foreignKey, parentId, onDataChange]);
|
||||
|
||||
// 공통코드 버튼 로드
|
||||
const loadCodeButtons = useCallback(async () => {
|
||||
if (config.button?.sourceType !== "commonCode" || !config.button?.commonCode?.categoryCode) return;
|
||||
|
||||
try {
|
||||
const response = await commonCodeApi.codes.getList(config.button.commonCode.categoryCode);
|
||||
if (response.success && response.data) {
|
||||
const labelField = config.button.commonCode.labelField || "codeName";
|
||||
setCodeButtons(
|
||||
response.data.map((code) => ({
|
||||
label: labelField === "codeName" ? code.codeName : code.codeValue,
|
||||
value: code.codeValue,
|
||||
variant: config.button?.commonCode?.variantMapping?.[code.codeValue],
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("공통코드 버튼 로드 실패:", error);
|
||||
}
|
||||
}, [config.button?.sourceType, config.button?.commonCode]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
loadData();
|
||||
}
|
||||
}, [loadData, initialData]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCodeButtons();
|
||||
}, [loadCodeButtons]);
|
||||
|
||||
// 행 선택 토글
|
||||
const toggleRowSelection = (index: number) => {
|
||||
if (!config.features?.selectable) return;
|
||||
|
||||
setSelectedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
if (!config.features?.multiSelect) {
|
||||
newSet.clear();
|
||||
}
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 행 추가
|
||||
const handleAddRow = async () => {
|
||||
if (config.renderMode === "modal" || config.renderMode === "mixed") {
|
||||
setModalRow(null);
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
// 인라인 추가
|
||||
const newRow: any = {};
|
||||
config.columns.forEach((col) => {
|
||||
newRow[col.key] = "";
|
||||
});
|
||||
if (config.dataSource?.foreignKey && parentId) {
|
||||
newRow[config.dataSource.foreignKey] = parentId;
|
||||
}
|
||||
|
||||
const newData = [...data, newRow];
|
||||
setData(newData);
|
||||
onDataChange?.(newData);
|
||||
setEditingRow(newData.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 행 삭제
|
||||
const handleDeleteRow = async (index: number) => {
|
||||
const row = data[index];
|
||||
const rowId = row?.id || row?.objid;
|
||||
|
||||
if (rowId && config.dataSource?.tableName) {
|
||||
try {
|
||||
await apiClient.delete(`/dynamic-form/${config.dataSource.tableName}/${rowId}`);
|
||||
} catch (error) {
|
||||
console.error("행 삭제 실패:", error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newData = data.filter((_, i) => i !== index);
|
||||
setData(newData);
|
||||
onDataChange?.(newData);
|
||||
setSelectedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(index);
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// 선택된 행 일괄 삭제
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedRows.size === 0) return;
|
||||
|
||||
const indices = Array.from(selectedRows).sort((a, b) => b - a); // 역순 정렬
|
||||
for (const index of indices) {
|
||||
await handleDeleteRow(index);
|
||||
}
|
||||
setSelectedRows(new Set());
|
||||
};
|
||||
|
||||
// 인라인 편집 시작
|
||||
const handleEditRow = (index: number) => {
|
||||
if (!config.features?.inlineEdit) return;
|
||||
setEditingRow(index);
|
||||
setEditedData({ ...data[index] });
|
||||
};
|
||||
|
||||
// 인라인 편집 저장
|
||||
const handleSaveEdit = async () => {
|
||||
if (editingRow === null) return;
|
||||
|
||||
const rowId = editedData?.id || editedData?.objid;
|
||||
|
||||
try {
|
||||
if (rowId && config.dataSource?.tableName) {
|
||||
await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, editedData);
|
||||
} else if (config.dataSource?.tableName) {
|
||||
const response = await apiClient.post(`/dynamic-form/${config.dataSource.tableName}`, editedData);
|
||||
if (response.data?.data?.id) {
|
||||
editedData.id = response.data.data.id;
|
||||
}
|
||||
}
|
||||
|
||||
const newData = [...data];
|
||||
newData[editingRow] = editedData;
|
||||
setData(newData);
|
||||
onDataChange?.(newData);
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
}
|
||||
|
||||
setEditingRow(null);
|
||||
setEditedData({});
|
||||
};
|
||||
|
||||
// 인라인 편집 취소
|
||||
const handleCancelEdit = () => {
|
||||
setEditingRow(null);
|
||||
setEditedData({});
|
||||
};
|
||||
|
||||
// 행 클릭
|
||||
const handleRowClick = (row: any, index: number) => {
|
||||
if (config.features?.selectable) {
|
||||
toggleRowSelection(index);
|
||||
}
|
||||
onRowClick?.(row);
|
||||
|
||||
if (config.renderMode === "modal" || config.renderMode === "mixed") {
|
||||
setModalRow(row);
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 버튼 클릭 핸들러
|
||||
const handleButtonAction = (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => {
|
||||
onButtonClick?.(action, row, buttonConfig);
|
||||
|
||||
if (action === "view" && row) {
|
||||
setModalRow(row);
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 공통코드 버튼 클릭
|
||||
const handleCodeButtonClick = async (codeValue: string, row: any, index: number) => {
|
||||
const valueField = config.button?.commonCode?.valueField;
|
||||
if (!valueField) return;
|
||||
|
||||
const updatedRow = { ...row, [valueField]: codeValue };
|
||||
const rowId = row?.id || row?.objid;
|
||||
|
||||
try {
|
||||
if (rowId && config.dataSource?.tableName) {
|
||||
await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, { [valueField]: codeValue });
|
||||
}
|
||||
|
||||
const newData = [...data];
|
||||
newData[index] = updatedRow;
|
||||
setData(newData);
|
||||
onDataChange?.(newData);
|
||||
} catch (error) {
|
||||
console.error("상태 변경 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 모달 제목 생성
|
||||
const getModalTitle = (row?: any) => {
|
||||
const template = config.modal?.titleTemplate;
|
||||
if (!template) return row ? "상세 보기" : "새 항목";
|
||||
|
||||
let title = template.prefix || "";
|
||||
if (template.columnKey && row?.[template.columnKey]) {
|
||||
title += row[template.columnKey];
|
||||
}
|
||||
title += template.suffix || "";
|
||||
|
||||
return title || (row ? "상세 보기" : "새 항목");
|
||||
};
|
||||
|
||||
// 버튼 렌더링
|
||||
const renderButtons = (row: any, index: number) => {
|
||||
const isVertical = config.button?.layout === "vertical";
|
||||
const buttonStyle = config.button?.style || "outline";
|
||||
|
||||
if (config.button?.sourceType === "commonCode") {
|
||||
return (
|
||||
<div className={cn("flex gap-1", isVertical && "flex-col")}>
|
||||
{codeButtons.map((btn) => (
|
||||
<Button
|
||||
key={btn.value}
|
||||
variant={(btn.variant as any) || buttonStyle}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCodeButtonClick(btn.value, row, index);
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 수동 버튼
|
||||
return (
|
||||
<div className={cn("flex gap-1", isVertical && "flex-col")}>
|
||||
{(config.button?.manualButtons || []).map((btn) => (
|
||||
<Button
|
||||
key={btn.id}
|
||||
variant={btn.variant as any}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleButtonAction(btn.action, row, btn);
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 테이블 렌더링 (inline, mixed 모드)
|
||||
const renderTable = () => {
|
||||
if (config.renderMode === "button") return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
{config.features?.dragSort && <TableHead className="w-8" />}
|
||||
{config.features?.selectable && <TableHead className="w-8" />}
|
||||
{config.features?.showRowNumber && <TableHead className="w-12 text-center">#</TableHead>}
|
||||
{config.columns
|
||||
.filter((col) => col.visible !== false)
|
||||
.map((col) => (
|
||||
<TableHead key={col.key} style={{ width: col.width !== "auto" ? col.width : undefined }}>
|
||||
{col.title}
|
||||
</TableHead>
|
||||
))}
|
||||
{(config.features?.inlineEdit || config.features?.showDeleteButton || config.renderMode === "mixed") && (
|
||||
<TableHead className="w-24 text-center">액션</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={
|
||||
config.columns.length +
|
||||
(config.features?.dragSort ? 1 : 0) +
|
||||
(config.features?.selectable ? 1 : 0) +
|
||||
(config.features?.showRowNumber ? 1 : 0) +
|
||||
1
|
||||
}
|
||||
className="text-muted-foreground py-8 text-center"
|
||||
>
|
||||
데이터가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={row.id || row.objid || index}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50",
|
||||
selectedRows.has(index) && "bg-primary/10",
|
||||
editingRow === index && "bg-blue-50",
|
||||
)}
|
||||
onClick={() => handleRowClick(row, index)}
|
||||
>
|
||||
{config.features?.dragSort && (
|
||||
<TableCell className="w-8 p-1">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab" />
|
||||
</TableCell>
|
||||
)}
|
||||
{config.features?.selectable && (
|
||||
<TableCell className="w-8 p-1">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(index)}
|
||||
onCheckedChange={() => toggleRowSelection(index)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{config.features?.showRowNumber && (
|
||||
<TableCell className="text-muted-foreground w-12 text-center text-xs">{index + 1}</TableCell>
|
||||
)}
|
||||
{config.columns
|
||||
.filter((col) => col.visible !== false)
|
||||
.map((col) => (
|
||||
<TableCell key={col.key} className="py-2">
|
||||
{editingRow === index ? (
|
||||
<Input
|
||||
value={editedData[col.key] || ""}
|
||||
onChange={(e) =>
|
||||
setEditedData((prev) => ({
|
||||
...prev,
|
||||
[col.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm">{row[col.key]}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="w-24 p-1 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{editingRow === index ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSaveEdit();
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancelEdit();
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{config.features?.inlineEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditRow(index);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{config.renderMode === "mixed" && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setModalRow(row);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{config.features?.showDeleteButton && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteRow(index);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 버튼 모드 렌더링
|
||||
const renderButtonMode = () => {
|
||||
if (config.renderMode !== "button") return null;
|
||||
|
||||
const isVertical = config.button?.layout === "vertical";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-2", isVertical && "flex-col")}>
|
||||
{data.map((row, index) => (
|
||||
<div key={row.id || row.objid || index} className="flex items-center gap-2">
|
||||
{renderButtons(row, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{/* 헤더 (추가/삭제 버튼) */}
|
||||
{(config.features?.showAddButton || (config.features?.selectable && selectedRows.size > 0)) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{config.features?.showAddButton && (
|
||||
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
{config.features?.selectable && selectedRows.size > 0 && config.features?.showDeleteButton && (
|
||||
<Button size="sm" variant="destructive" onClick={handleDeleteSelected} className="h-7 text-xs">
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
선택 삭제 ({selectedRows.size})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">총 {data.length}건</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 */}
|
||||
{loading && <div className="text-muted-foreground py-4 text-center text-sm">로딩 중...</div>}
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{!loading && (
|
||||
<>
|
||||
{renderTable()}
|
||||
{renderButtonMode()}
|
||||
|
||||
{/* mixed 모드에서 버튼도 표시 */}
|
||||
{config.renderMode === "mixed" && data.length > 0 && (
|
||||
<div className="border-t pt-2">
|
||||
{data.map((row, index) => (
|
||||
<div key={row.id || row.objid || index} className="mb-1">
|
||||
{renderButtons(row, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 모달 */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className={cn(MODAL_SIZE_MAP[config.modal?.size || "md"])}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getModalTitle(modalRow)}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{config.modal?.screenId ? (
|
||||
// 화면 기반 모달 - 동적 화면 로드
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
화면 ID: {config.modal.screenId}
|
||||
{/* TODO: DynamicScreen 컴포넌트로 교체 */}
|
||||
</div>
|
||||
) : (
|
||||
// 기본 폼 표시
|
||||
<div className="space-y-3">
|
||||
{config.columns.map((col) => (
|
||||
<div key={col.key} className="space-y-1">
|
||||
<label className="text-sm font-medium">{col.title}</label>
|
||||
<Input
|
||||
value={modalRow?.[col.key] || ""}
|
||||
onChange={(e) =>
|
||||
setModalRow((prev: any) => ({
|
||||
...prev,
|
||||
[col.key]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnifiedRepeater.displayName = "UnifiedRepeater";
|
||||
|
||||
export default UnifiedRepeater;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
|||
// 🆕 연관 데이터 버튼 컴포넌트
|
||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||
|
||||
// 🆕 통합 반복 데이터 컴포넌트 (Unified)
|
||||
import "./unified-repeater/UnifiedRepeaterRenderer"; // 인라인/모달/버튼 모드 통합
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UnifiedRepeater 렌더러
|
||||
* 컴포넌트 레지스트리에 등록하기 위한 래퍼
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
|
||||
import { UnifiedRepeaterDefinition } from "./index";
|
||||
import { UnifiedRepeaterConfig, DEFAULT_REPEATER_CONFIG } from "@/types/unified-repeater";
|
||||
|
||||
interface UnifiedRepeaterRendererProps {
|
||||
component: any;
|
||||
data?: any;
|
||||
mode?: "view" | "edit";
|
||||
isPreview?: boolean;
|
||||
onDataChange?: (data: any[]) => void;
|
||||
onRowClick?: (row: any) => void;
|
||||
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||
parentId?: string | number;
|
||||
}
|
||||
|
||||
const UnifiedRepeaterRenderer: React.FC<UnifiedRepeaterRendererProps> = ({
|
||||
component,
|
||||
data,
|
||||
mode,
|
||||
isPreview,
|
||||
onDataChange,
|
||||
onRowClick,
|
||||
onButtonClick,
|
||||
parentId,
|
||||
}) => {
|
||||
// component.config에서 UnifiedRepeaterConfig 추출
|
||||
const config: UnifiedRepeaterConfig = React.useMemo(() => {
|
||||
const componentConfig = component?.config || component?.props?.config || {};
|
||||
return {
|
||||
...DEFAULT_REPEATER_CONFIG,
|
||||
...componentConfig,
|
||||
dataSource: {
|
||||
...DEFAULT_REPEATER_CONFIG.dataSource,
|
||||
...componentConfig.dataSource,
|
||||
},
|
||||
columns: componentConfig.columns || [],
|
||||
features: {
|
||||
...DEFAULT_REPEATER_CONFIG.features,
|
||||
...componentConfig.features,
|
||||
},
|
||||
modal: {
|
||||
...DEFAULT_REPEATER_CONFIG.modal,
|
||||
...componentConfig.modal,
|
||||
},
|
||||
button: {
|
||||
...DEFAULT_REPEATER_CONFIG.button,
|
||||
...componentConfig.button,
|
||||
},
|
||||
};
|
||||
}, [component]);
|
||||
|
||||
// parentId 결정: props에서 전달받거나 data에서 추출
|
||||
const resolvedParentId = React.useMemo(() => {
|
||||
if (parentId) return parentId;
|
||||
if (data && config.dataSource?.referenceKey) {
|
||||
return data[config.dataSource.referenceKey];
|
||||
}
|
||||
return undefined;
|
||||
}, [parentId, data, config.dataSource?.referenceKey]);
|
||||
|
||||
// 미리보기 모드에서는 샘플 데이터 표시
|
||||
if (isPreview) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
통합 반복 데이터
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
모드: {config.renderMode} | 테이블: {config.dataSource?.tableName || "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedRepeater
|
||||
config={config}
|
||||
parentId={resolvedParentId}
|
||||
data={Array.isArray(data) ? data : undefined}
|
||||
onDataChange={onDataChange}
|
||||
onRowClick={onRowClick}
|
||||
onButtonClick={onButtonClick}
|
||||
className={component?.className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 컴포넌트 레지스트리에 등록
|
||||
ComponentRegistry.registerComponent({
|
||||
...UnifiedRepeaterDefinition,
|
||||
render: (props: any) => <UnifiedRepeaterRenderer {...props} />,
|
||||
});
|
||||
|
||||
export default UnifiedRepeaterRenderer;
|
||||
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* UnifiedRepeater 컴포넌트 정의
|
||||
*
|
||||
* 반복 데이터 관리를 위한 통합 컴포넌트
|
||||
* 기존 simple-repeater-table, modal-repeater-table, repeat-screen-modal, related-data-buttons 통합
|
||||
*/
|
||||
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { UnifiedRepeaterConfigPanel } from "@/components/unified/config-panels/UnifiedRepeaterConfigPanel";
|
||||
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
|
||||
|
||||
export const UnifiedRepeaterDefinition = createComponentDefinition({
|
||||
id: "unified-repeater",
|
||||
name: "통합 반복 데이터",
|
||||
description: "반복 데이터 관리 (인라인/모달/버튼 모드)",
|
||||
category: ComponentCategory.UNIFIED,
|
||||
webType: "entity", // 반복 데이터는 엔티티 참조 타입
|
||||
version: "1.0.0",
|
||||
component: UnifiedRepeater, // React 컴포넌트 (필수)
|
||||
|
||||
// 기본 속성
|
||||
defaultProps: {
|
||||
config: {
|
||||
renderMode: "inline",
|
||||
dataSource: {
|
||||
tableName: "",
|
||||
foreignKey: "",
|
||||
referenceKey: "",
|
||||
},
|
||||
columns: [],
|
||||
modal: {
|
||||
size: "md",
|
||||
},
|
||||
button: {
|
||||
sourceType: "manual",
|
||||
manualButtons: [],
|
||||
layout: "horizontal",
|
||||
style: "outline",
|
||||
},
|
||||
features: {
|
||||
showAddButton: true,
|
||||
showDeleteButton: true,
|
||||
inlineEdit: false,
|
||||
dragSort: false,
|
||||
showRowNumber: false,
|
||||
selectable: false,
|
||||
multiSelect: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 설정 스키마
|
||||
configSchema: {
|
||||
renderMode: {
|
||||
type: "select",
|
||||
label: "렌더링 모드",
|
||||
options: [
|
||||
{ value: "inline", label: "인라인 (테이블)" },
|
||||
{ value: "modal", label: "모달" },
|
||||
{ value: "button", label: "버튼" },
|
||||
{ value: "mixed", label: "혼합 (테이블 + 버튼)" },
|
||||
],
|
||||
},
|
||||
"dataSource.tableName": {
|
||||
type: "tableSelect",
|
||||
label: "데이터 테이블",
|
||||
description: "반복 데이터가 저장된 테이블",
|
||||
},
|
||||
"dataSource.foreignKey": {
|
||||
type: "columnSelect",
|
||||
label: "연결 키 (FK)",
|
||||
description: "부모 레코드를 참조하는 컬럼",
|
||||
dependsOn: "dataSource.tableName",
|
||||
},
|
||||
"dataSource.referenceKey": {
|
||||
type: "columnSelect",
|
||||
label: "상위 키",
|
||||
description: "현재 화면 테이블의 PK 컬럼",
|
||||
useCurrentTable: true,
|
||||
},
|
||||
},
|
||||
|
||||
// 이벤트
|
||||
events: ["onDataChange", "onRowClick", "onButtonClick"],
|
||||
|
||||
// 아이콘
|
||||
icon: "Repeat",
|
||||
|
||||
// 태그
|
||||
tags: ["data", "repeater", "table", "modal", "button", "unified"],
|
||||
|
||||
// 설정 패널
|
||||
configPanel: UnifiedRepeaterConfigPanel,
|
||||
});
|
||||
|
||||
export default UnifiedRepeaterDefinition;
|
||||
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* UnifiedRepeater 컴포넌트 타입 정의
|
||||
*
|
||||
* 기존 컴포넌트 통합:
|
||||
* - simple-repeater-table: 인라인 모드
|
||||
* - modal-repeater-table: 모달 모드
|
||||
* - repeat-screen-modal: 화면 기반 모달 모드
|
||||
* - related-data-buttons: 버튼 모드
|
||||
*/
|
||||
|
||||
// 렌더링 모드
|
||||
export type RepeaterRenderMode = "inline" | "modal" | "button" | "mixed";
|
||||
|
||||
// 버튼 소스 타입
|
||||
export type ButtonSourceType = "commonCode" | "manual";
|
||||
|
||||
// 버튼 액션 타입
|
||||
export type ButtonActionType = "create" | "update" | "delete" | "view" | "navigate" | "custom";
|
||||
|
||||
// 버튼 색상/스타일
|
||||
export type ButtonVariant = "default" | "primary" | "secondary" | "destructive" | "outline" | "ghost";
|
||||
|
||||
// 버튼 레이아웃
|
||||
export type ButtonLayout = "horizontal" | "vertical";
|
||||
|
||||
// 모달 크기
|
||||
export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||
|
||||
// 컬럼 너비 옵션
|
||||
export type ColumnWidthOption = "auto" | "60px" | "80px" | "100px" | "120px" | "150px" | "200px" | "250px" | "300px";
|
||||
|
||||
// 컬럼 설정
|
||||
export interface RepeaterColumnConfig {
|
||||
key: string;
|
||||
title: string;
|
||||
width: ColumnWidthOption;
|
||||
visible: boolean;
|
||||
isJoinColumn?: boolean;
|
||||
sourceTable?: string;
|
||||
}
|
||||
|
||||
// 버튼 설정 (수동 모드)
|
||||
export interface RepeaterButtonConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
action: ButtonActionType;
|
||||
variant: ButtonVariant;
|
||||
icon?: string;
|
||||
confirmMessage?: string;
|
||||
// 네비게이트 액션용
|
||||
navigateScreen?: number;
|
||||
navigateParams?: Record<string, string>;
|
||||
// 커스텀 액션용
|
||||
customHandler?: string;
|
||||
}
|
||||
|
||||
// 공통코드 버튼 설정
|
||||
export interface CommonCodeButtonConfig {
|
||||
categoryCode: string;
|
||||
labelField: "codeValue" | "codeName";
|
||||
valueField: string; // 버튼 클릭 시 전달할 값의 컬럼
|
||||
variantMapping?: Record<string, ButtonVariant>; // 코드값별 색상 매핑
|
||||
}
|
||||
|
||||
// 모달 설정
|
||||
export interface RepeaterModalConfig {
|
||||
screenId?: number;
|
||||
size: ModalSize;
|
||||
titleTemplate?: {
|
||||
prefix?: string;
|
||||
columnKey?: string;
|
||||
suffix?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 기능 옵션
|
||||
export interface RepeaterFeatureOptions {
|
||||
showAddButton: boolean;
|
||||
showDeleteButton: boolean;
|
||||
inlineEdit: boolean;
|
||||
dragSort: boolean;
|
||||
showRowNumber: boolean;
|
||||
selectable: boolean;
|
||||
multiSelect: boolean;
|
||||
}
|
||||
|
||||
// 메인 설정 타입
|
||||
export interface UnifiedRepeaterConfig {
|
||||
// 렌더링 모드
|
||||
renderMode: RepeaterRenderMode;
|
||||
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
tableName: string; // 데이터 테이블
|
||||
foreignKey: string; // 연결 키 (FK) - 데이터 테이블의 컬럼
|
||||
referenceKey: string; // 상위 키 - 현재 화면 테이블의 컬럼 (부모 ID)
|
||||
filter?: { // 추가 필터 조건
|
||||
column: string;
|
||||
value: string;
|
||||
};
|
||||
};
|
||||
|
||||
// 컬럼 설정
|
||||
columns: RepeaterColumnConfig[];
|
||||
|
||||
// 모달 설정 (modal, mixed 모드)
|
||||
modal?: RepeaterModalConfig;
|
||||
|
||||
// 버튼 설정 (button, mixed 모드)
|
||||
button?: {
|
||||
sourceType: ButtonSourceType;
|
||||
commonCode?: CommonCodeButtonConfig;
|
||||
manualButtons?: RepeaterButtonConfig[];
|
||||
layout: ButtonLayout;
|
||||
style: ButtonVariant;
|
||||
};
|
||||
|
||||
// 기능 옵션
|
||||
features: RepeaterFeatureOptions;
|
||||
|
||||
// 스타일
|
||||
style?: {
|
||||
maxHeight?: string;
|
||||
minHeight?: string;
|
||||
borderless?: boolean;
|
||||
compact?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 컴포넌트 Props
|
||||
export interface UnifiedRepeaterProps {
|
||||
config: UnifiedRepeaterConfig;
|
||||
parentId?: string | number; // 부모 레코드 ID
|
||||
data?: any[]; // 초기 데이터 (없으면 API로 로드)
|
||||
onDataChange?: (data: any[]) => void;
|
||||
onRowClick?: (row: any) => void;
|
||||
onButtonClick?: (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 기본 설정값
|
||||
export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = {
|
||||
renderMode: "inline",
|
||||
dataSource: {
|
||||
tableName: "",
|
||||
foreignKey: "",
|
||||
referenceKey: "",
|
||||
},
|
||||
columns: [],
|
||||
modal: {
|
||||
size: "md",
|
||||
},
|
||||
button: {
|
||||
sourceType: "manual",
|
||||
manualButtons: [],
|
||||
layout: "horizontal",
|
||||
style: "outline",
|
||||
},
|
||||
features: {
|
||||
showAddButton: true,
|
||||
showDeleteButton: true,
|
||||
inlineEdit: false,
|
||||
dragSort: false,
|
||||
showRowNumber: false,
|
||||
selectable: false,
|
||||
multiSelect: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 고정 옵션들 (콤보박스용)
|
||||
export const RENDER_MODE_OPTIONS = [
|
||||
{ value: "inline", label: "인라인 (테이블)" },
|
||||
{ value: "modal", label: "모달" },
|
||||
{ value: "button", label: "버튼" },
|
||||
{ value: "mixed", label: "혼합 (테이블 + 버튼)" },
|
||||
] as const;
|
||||
|
||||
export const MODAL_SIZE_OPTIONS = [
|
||||
{ value: "sm", label: "작게 (sm)" },
|
||||
{ value: "md", label: "중간 (md)" },
|
||||
{ value: "lg", label: "크게 (lg)" },
|
||||
{ value: "xl", label: "매우 크게 (xl)" },
|
||||
{ value: "full", label: "전체 화면" },
|
||||
] as const;
|
||||
|
||||
export const COLUMN_WIDTH_OPTIONS = [
|
||||
{ value: "auto", label: "자동" },
|
||||
{ value: "60px", label: "60px" },
|
||||
{ value: "80px", label: "80px" },
|
||||
{ value: "100px", label: "100px" },
|
||||
{ value: "120px", label: "120px" },
|
||||
{ value: "150px", label: "150px" },
|
||||
{ value: "200px", label: "200px" },
|
||||
{ value: "250px", label: "250px" },
|
||||
{ value: "300px", label: "300px" },
|
||||
] as const;
|
||||
|
||||
export const BUTTON_ACTION_OPTIONS = [
|
||||
{ value: "create", label: "생성" },
|
||||
{ value: "update", label: "수정" },
|
||||
{ value: "delete", label: "삭제" },
|
||||
{ value: "view", label: "보기" },
|
||||
{ value: "navigate", label: "화면 이동" },
|
||||
{ value: "custom", label: "커스텀" },
|
||||
] as const;
|
||||
|
||||
export const BUTTON_VARIANT_OPTIONS = [
|
||||
{ value: "default", label: "기본" },
|
||||
{ value: "primary", label: "Primary" },
|
||||
{ value: "secondary", label: "Secondary" },
|
||||
{ value: "destructive", label: "삭제 (빨강)" },
|
||||
{ value: "outline", label: "Outline" },
|
||||
{ value: "ghost", label: "Ghost" },
|
||||
] as const;
|
||||
|
||||
export const BUTTON_LAYOUT_OPTIONS = [
|
||||
{ value: "horizontal", label: "가로 배치" },
|
||||
{ value: "vertical", label: "세로 배치" },
|
||||
] as const;
|
||||
|
||||
export const BUTTON_SOURCE_OPTIONS = [
|
||||
{ value: "commonCode", label: "공통코드 사용" },
|
||||
{ value: "manual", label: "수동 설정" },
|
||||
] as const;
|
||||
|
||||
export const LABEL_FIELD_OPTIONS = [
|
||||
{ value: "codeName", label: "코드명" },
|
||||
{ value: "codeValue", label: "코드값" },
|
||||
] as const;
|
||||
|
||||
Loading…
Reference in New Issue