[agent-pipeline] pipe-20260329052843-hdtq round-1

This commit is contained in:
DDD1542
2026-03-29 14:49:52 +09:00
parent 3bcb0d2c01
commit f01fdfc57c
28 changed files with 659 additions and 1231 deletions
@@ -22,7 +22,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.external_connection_id || "");
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.externalConnectionId || "");
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
@@ -37,12 +37,12 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
loadApiConnections();
}, []);
// dataSource.external_connection_id가 변경되면 selectedConnectionId 업데이트
// dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
useEffect(() => {
if (dataSource.external_connection_id) {
setSelectedConnectionId(dataSource.external_connection_id);
if (dataSource.externalConnectionId) {
setSelectedConnectionId(dataSource.externalConnectionId);
}
}, [dataSource.external_connection_id]);
}, [dataSource.externalConnectionId]);
// 외부 커넥션 선택 핸들러
const handleConnectionSelect = async (connectionId: string) => {
@@ -67,7 +67,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const updates: Partial<ChartDataSource> = {
endpoint: fullEndpoint,
external_connection_id: connectionId, // 외부 연결 ID 저장
externalConnectionId: connectionId, // 외부 연결 ID 저장
};
const headers: KeyValuePair[] = [];
@@ -153,7 +153,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
updates.headers = headers;
}
if (queryParams.length > 0) {
updates.query_params = queryParams;
updates.queryParams = queryParams;
}
onChange(updates);
@@ -183,24 +183,24 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
// 쿼리 파라미터 추가
const handleAddQueryParam = () => {
const queryParams = dataSource.query_params || [];
const queryParams = dataSource.queryParams || [];
onChange({
query_params: [...queryParams, { id: Date.now().toString(), key: "", value: "" }],
queryParams: [...queryParams, { id: Date.now().toString(), key: "", value: "" }],
});
};
// 쿼리 파라미터 삭제
const handleDeleteQueryParam = (id: string) => {
const queryParams = (dataSource.query_params || []).filter((q) => q.id !== id);
onChange({ query_params: queryParams });
const queryParams = (dataSource.queryParams || []).filter((q) => q.id !== id);
onChange({ queryParams });
};
// 쿼리 파라미터 업데이트
const handleUpdateQueryParam = (id: string, field: "key" | "value", value: string) => {
const queryParams = (dataSource.query_params || []).map((q) =>
const queryParams = (dataSource.queryParams || []).map((q) =>
q.id === id ? { ...q, [field]: value } : q
);
onChange({ query_params: queryParams });
onChange({ queryParams });
};
// API 테스트
@@ -215,7 +215,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
try {
const queryParams: Record<string, string> = {};
(dataSource.query_params || []).forEach((param) => {
(dataSource.queryParams || []).forEach((param) => {
if (param.key && param.value) {
queryParams[param.key] = param.value;
}
@@ -243,7 +243,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
headers,
query_params: queryParams,
body: bodyPayload,
external_connection_id: dataSource.external_connection_id, // 외부 연결 ID 전달
external_connection_id: dataSource.externalConnectionId, // 외부 연결 ID 전달
}),
});
@@ -257,20 +257,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
try {
const lines = text.split('\n').filter(line => {
const trimmed = line.trim();
return trimmed &&
!trimmed.startsWith('#') &&
return trimmed &&
!trimmed.startsWith('#') &&
!trimmed.startsWith('=') &&
!trimmed.startsWith('---');
});
if (lines.length === 0) return [];
const result: any[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const values = line.split(',').map(v => v.trim().replace(/,=$/g, ''));
// 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명
if (values.length >= 4) {
const obj: any = {
@@ -285,11 +285,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
description: values.slice(8).join(', ').trim() || '',
name: values[3] || values[1] || values[0], // 하위 지역명 우선
};
result.push(obj);
}
}
return result;
} catch (error) {
console.error("❌ 텍스트 파싱 오류:", error);
@@ -299,29 +299,29 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
// JSON Path로 데이터 추출
let data = result.data;
// 텍스트 데이터 체크 (기상청 API 등)
if (data && typeof data === 'object' && data.text && typeof data.text === 'string') {
const parsedData = parseTextData(data.text);
if (parsedData.length > 0) {
data = parsedData;
}
} else if (dataSource.json_path) {
const pathParts = dataSource.json_path.split(".");
} else if (dataSource.jsonPath) {
const pathParts = dataSource.jsonPath.split(".");
for (const part of pathParts) {
data = data?.[part];
}
}
const rows = Array.isArray(data) ? data : [data];
console.log("📊 [최종 파싱된 데이터]", rows);
// 컬럼 목록 및 타입 추출
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
setAvailableColumns(columns);
// 컬럼 타입 분석 (첫 번째 행 기준)
const types: Record<string, string> = {};
columns.forEach(col => {
@@ -344,11 +344,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
}
});
setColumnTypes(types);
// 샘플 데이터 저장 (최대 3개)
setSampleData(rows.slice(0, 3));
}
// 위도/경도 또는 coordinates 필드 또는 지역 코드 체크
const hasLocationData = rows.some((row) => {
const hasLatLng = (row.lat || row.latitude) && (row.lng || row.longitude);
@@ -358,25 +358,25 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
});
if (hasLocationData) {
const markerCount = rows.filter(r =>
((r.lat || r.latitude) && (r.lng || r.longitude)) ||
const markerCount = rows.filter(r =>
((r.lat || r.latitude) && (r.lng || r.longitude)) ||
r.code || r.areaCode || r.regionCode
).length;
const polygonCount = rows.filter(r => r.coordinates && Array.isArray(r.coordinates)).length;
setTestResult({
success: true,
message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견`
setTestResult({
success: true,
message: `API 연결 성공 - 마커 ${markerCount}개, 영역 ${polygonCount}개 발견`
});
// 부모에게 테스트 결과 전달 (지도 미리보기용)
if (onTestResult) {
onTestResult(rows);
}
} else {
setTestResult({
success: true,
message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)`
setTestResult({
success: true,
message: `API 연결 성공 - ${rows.length}개 데이터 (위치 정보 없음)`
});
}
} else {
@@ -494,13 +494,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
{/* JSON Path */}
<div className="space-y-2">
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">
<Label htmlFor={`jsonPath-${dataSource.id}`} className="text-xs">
JSON Path ()
</Label>
<Input
id={`jsonPath-\${dataSource.id}`}
value={dataSource.json_path || ""}
onChange={(e) => onChange({ json_path: e.target.value })}
id={`jsonPath-${dataSource.id}`}
value={dataSource.jsonPath || ""}
onChange={(e) => onChange({ jsonPath: e.target.value })}
placeholder="예: data.results"
className="h-8 text-xs"
/>
@@ -523,7 +523,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</Button>
</div>
{(dataSource.query_params || []).map((param) => (
{(dataSource.queryParams || []).map((param) => (
<div key={param.id} className="flex gap-2">
<Input
value={param.key}
@@ -596,8 +596,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</Label>
<Select
value={dataSource.marker_type || "circle"}
onValueChange={(value) => onChange({ marker_type: value })}
value={dataSource.markerType || "circle"}
onValueChange={(value) => onChange({ markerType: value })}
>
<SelectTrigger id="marker-type" className="h-9 text-xs">
<SelectValue placeholder="마커 선택" />
@@ -616,7 +616,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
{/* 지도 색상 설정 (MapTestWidgetV2 전용) */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<h5 className="text-xs font-semibold">🎨 </h5>
{/* 색상 팔레트 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
@@ -631,19 +631,19 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
].map((color) => {
const isSelected = dataSource.marker_color === color.marker;
const isSelected = dataSource.markerColor === color.marker;
return (
<button
key={color.name}
type="button"
onClick={() => onChange({
marker_color: color.marker,
polygon_color: color.polygon,
polygon_opacity: 0.5,
markerColor: color.marker,
polygonColor: color.polygon,
polygonOpacity: 0.5,
})}
className={`flex h-16 flex-col items-center justify-center gap-1 rounded-md border-2 transition-all hover:scale-105 ${
isSelected
? "border-primary bg-primary/10 shadow-md"
isSelected
? "border-primary bg-primary/10 shadow-md"
: "border-border bg-background hover:border-primary/50"
}`}
>
@@ -706,8 +706,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<div>
<Label className="text-sm font-semibold"> </Label>
<p className="text-xs text-muted-foreground mt-0.5">
{dataSource.selected_columns && dataSource.selected_columns.length > 0
? `${dataSource.selected_columns.length}개 컬럼 선택됨`
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
? `${dataSource.selectedColumns.length}개 컬럼 선택됨`
: "모든 컬럼 표시"}
</p>
</div>
@@ -715,7 +715,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Button
variant="outline"
size="sm"
onClick={() => onChange({ selected_columns: availableColumns })}
onClick={() => onChange({ selectedColumns: availableColumns })}
className="h-7 text-xs"
>
@@ -723,7 +723,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Button
variant="outline"
size="sm"
onClick={() => onChange({ selected_columns: [] })}
onClick={() => onChange({ selectedColumns: [] })}
className="h-7 text-xs"
>
@@ -744,16 +744,16 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
{/* 컬럼 카드 그리드 */}
<div className="grid grid-cols-1 gap-2 max-h-80 overflow-y-auto">
{availableColumns
.filter(col =>
!columnSearchTerm ||
.filter(col =>
!columnSearchTerm ||
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
)
.map((col) => {
const isSelected =
!dataSource.selected_columns ||
dataSource.selected_columns.length === 0 ||
dataSource.selected_columns.includes(col);
const isSelected =
!dataSource.selectedColumns ||
dataSource.selectedColumns.length === 0 ||
dataSource.selectedColumns.includes(col);
const type = columnTypes[col] || "unknown";
const typeIcon = {
number: "🔢",
@@ -763,7 +763,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
object: "📦",
unknown: "❓"
}[type];
const typeColor = {
number: "text-primary bg-primary/10",
string: "text-muted-foreground bg-muted",
@@ -777,20 +777,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<div
key={col}
onClick={() => {
const currentSelected = dataSource.selected_columns && dataSource.selected_columns.length > 0
? dataSource.selected_columns
const currentSelected = dataSource.selectedColumns && dataSource.selectedColumns.length > 0
? dataSource.selectedColumns
: availableColumns;
const newSelected = isSelected
? currentSelected.filter(c => c !== col)
: [...currentSelected, col];
onChange({ selected_columns: newSelected });
onChange({ selectedColumns: newSelected });
}}
className={`
relative flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-all
${isSelected
? "border-primary bg-primary/5 shadow-sm"
${isSelected
? "border-primary bg-primary/5 shadow-sm"
: "border-border bg-card hover:border-primary/50 hover:bg-muted/50"
}
`}
@@ -799,8 +799,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<div className="flex-shrink-0 mt-0.5">
<div className={`
h-4 w-4 rounded border-2 flex items-center justify-center transition-colors
${isSelected
? "border-primary bg-primary"
${isSelected
? "border-primary bg-primary"
: "border-border bg-background"
}
`}>
@@ -820,7 +820,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
{typeIcon} {type}
</span>
</div>
{/* 샘플 데이터 */}
{sampleData.length > 0 && (
<div className="mt-1.5 text-xs text-muted-foreground">
@@ -841,7 +841,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</div>
{/* 검색 결과 없음 */}
{columnSearchTerm && availableColumns.filter(col =>
{columnSearchTerm && availableColumns.filter(col =>
col.toLowerCase().includes(columnSearchTerm.toLowerCase())
).length === 0 && (
<div className="text-center py-8 text-sm text-muted-foreground">
@@ -863,8 +863,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</div>
<Switch
id="save-to-history"
checked={dataSource.save_to_history || false}
onCheckedChange={(checked) => onChange({ save_to_history: checked })}
checked={dataSource.saveToHistory || false}
onCheckedChange={(checked) => onChange({ saveToHistory: checked })}
/>
</div>
@@ -878,11 +878,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</p>
</div>
{dataSource.column_mapping && Object.keys(dataSource.column_mapping).length > 0 && (
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ column_mapping: {} })}
onClick={() => onChange({ columnMapping: {} })}
className="h-7 text-xs"
>
@@ -891,9 +891,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</div>
{/* 매핑 목록 */}
{dataSource.column_mapping && Object.keys(dataSource.column_mapping).length > 0 && (
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<div className="space-y-2">
{Object.entries(dataSource.column_mapping).map(([original, mapped]) => (
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
<div key={original} className="flex items-center gap-2">
{/* 원본 컬럼 (읽기 전용) */}
<Input
@@ -909,9 +909,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Input
value={mapped}
onChange={(e) => {
const newMapping = { ...dataSource.column_mapping };
const newMapping = { ...dataSource.columnMapping };
newMapping[original] = e.target.value;
onChange({ column_mapping: newMapping });
onChange({ columnMapping: newMapping });
}}
placeholder="표시 이름"
className="h-8 flex-1 text-xs"
@@ -922,9 +922,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
variant="ghost"
size="sm"
onClick={() => {
const newMapping = { ...dataSource.column_mapping };
const newMapping = { ...dataSource.columnMapping };
delete newMapping[original];
onChange({ column_mapping: newMapping });
onChange({ columnMapping: newMapping });
}}
className="h-8 w-8 p-0"
>
@@ -939,9 +939,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Select
value=""
onValueChange={(col) => {
const newMapping = { ...dataSource.column_mapping } || {};
const newMapping = { ...dataSource.columnMapping } || {};
newMapping[col] = col; // 기본값은 원본과 동일
onChange({ column_mapping: newMapping });
onChange({ columnMapping: newMapping });
}}
>
<SelectTrigger className="h-8 text-xs">
@@ -949,7 +949,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</SelectTrigger>
<SelectContent>
{availableColumns
.filter(col => !dataSource.column_mapping || !dataSource.column_mapping[col])
.filter(col => !dataSource.columnMapping || !dataSource.columnMapping[col])
.map(col => (
<SelectItem key={col} value={col} className="text-xs">
{col}
@@ -973,9 +973,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</Label>
{/* 기존 팝업 필드 목록 */}
{dataSource.popup_fields && dataSource.popup_fields.length > 0 && (
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
<div className="space-y-3">
{dataSource.popup_fields.map((field, index) => (
{dataSource.popupFields.map((field, index) => (
<div key={index} className="space-y-2 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
@@ -983,9 +983,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
variant="ghost"
size="sm"
onClick={() => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields.splice(index, 1);
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
className="h-6 w-6 p-0"
>
@@ -999,9 +999,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Select
value={field.fieldName}
onValueChange={(value) => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields[index].fieldName = value;
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
>
<SelectTrigger className="h-8 w-full text-xs">
@@ -1023,9 +1023,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Input
value={field.label || ""}
onChange={(e) => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields[index].label = e.target.value;
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
placeholder="예: 차량 번호"
className="h-8 w-full text-xs"
@@ -1039,9 +1039,9 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<Select
value={field.format || "text"}
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields[index].format = value;
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
>
<SelectTrigger className="h-8 w-full text-xs">
@@ -1066,13 +1066,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
variant="outline"
size="sm"
onClick={() => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields.push({
fieldName: availableColumns[0] || "",
label: "",
format: "text",
});
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
className="h-8 w-full gap-2 text-xs"
disabled={availableColumns.length === 0}
@@ -34,10 +34,10 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
// 외부 DB 커넥션 목록 로드
useEffect(() => {
if (dataSource.connection_type === "external") {
if (dataSource.connectionType === "external") {
loadExternalConnections();
}
}, [dataSource.connection_type]);
}, [dataSource.connectionType]);
const loadExternalConnections = async () => {
setLoadingConnections(true);
@@ -76,16 +76,16 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
// dashboardApi 사용 (인증 토큰 자동 포함)
const { dashboardApi } = await import("@/lib/api/dashboard");
if (dataSource.connection_type === "external" && dataSource.external_connection_id) {
if (dataSource.connectionType === "external" && dataSource.externalConnectionId) {
// 외부 DB
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
const result = await ExternalDbConnectionAPI.executeQuery(
parseInt(dataSource.external_connection_id),
parseInt(dataSource.externalConnectionId),
dataSource.query,
);
if (result.success && result.data) {
const rows = Array.isArray(result.data.rows) ? result.data.rows : [];
const rows = Array.isArray(result.data) ? result.data : [];
const rowCount = rows.length;
// 컬럼 목록 및 타입 추출
@@ -195,8 +195,8 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
<div className="space-y-1">
<Label className="text-xs"> </Label>
<RadioGroup
value={dataSource.connection_type || "current"}
onValueChange={(value: "current" | "external") => onChange({ connection_type: value })}
value={dataSource.connectionType || "current"}
onValueChange={(value: "current" | "external") => onChange({ connectionType: value })}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="current" id={`current-${dataSource.id}`} />
@@ -214,7 +214,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
</div>
{/* 외부 DB 선택 */}
{dataSource.connection_type === "external" && (
{dataSource.connectionType === "external" && (
<div className="space-y-2">
<Label htmlFor={`external-conn-${dataSource.id}`} className="text-xs">
*
@@ -225,8 +225,8 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult
</div>
) : (
<Select
value={dataSource.external_connection_id || ""}
onValueChange={(value) => onChange({ external_connection_id: value })}
value={dataSource.externalConnectionId || ""}
onValueChange={(value) => onChange({ externalConnectionId: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="외부 DB 선택" />
@@ -327,8 +327,8 @@ ORDER BY 하위부서수 DESC`,
</Label>
<Select
value={dataSource.marker_type || "circle"}
onValueChange={(value) => onChange({ marker_type: value })}
value={dataSource.markerType || "circle"}
onValueChange={(value) => onChange({ markerType: value })}
>
<SelectTrigger id="marker-type" className="h-8 text-xs">
<SelectValue placeholder="마커 선택" />
@@ -364,16 +364,16 @@ ORDER BY 하위부서수 DESC`,
{ name: "청록", marker: "#06b6d4", polygon: "#06b6d4" },
{ name: "분홍", marker: "#ec4899", polygon: "#ec4899" },
].map((color) => {
const isSelected = dataSource.marker_color === color.marker;
const isSelected = dataSource.markerColor === color.marker;
return (
<button
key={color.name}
type="button"
onClick={() =>
onChange({
marker_color: color.marker,
polygon_color: color.polygon,
polygon_opacity: 0.5,
markerColor: color.marker,
polygonColor: color.polygon,
polygonOpacity: 0.5,
})
}
className={`flex h-12 flex-col items-center justify-center gap-0.5 rounded-md border-2 transition-all hover:scale-105 ${
@@ -434,8 +434,8 @@ ORDER BY 하위부서수 DESC`,
<div>
<Label className="text-xs font-semibold"> </Label>
<p className="text-muted-foreground mt-0.5 text-[10px]">
{dataSource.selected_columns && dataSource.selected_columns.length > 0
? `${dataSource.selected_columns.length}개 선택됨`
{dataSource.selectedColumns && dataSource.selectedColumns.length > 0
? `${dataSource.selectedColumns.length}개 선택됨`
: "모든 컬럼 표시"}
</p>
</div>
@@ -443,7 +443,7 @@ ORDER BY 하위부서수 DESC`,
<Button
variant="outline"
size="sm"
onClick={() => onChange({ selected_columns: availableColumns })}
onClick={() => onChange({ selectedColumns: availableColumns })}
className="h-6 px-2 text-xs"
>
@@ -451,7 +451,7 @@ ORDER BY 하위부서수 DESC`,
<Button
variant="outline"
size="sm"
onClick={() => onChange({ selected_columns: [] })}
onClick={() => onChange({ selectedColumns: [] })}
className="h-6 px-2 text-xs"
>
@@ -475,9 +475,9 @@ ORDER BY 하위부서수 DESC`,
.filter((col) => !columnSearchTerm || col.toLowerCase().includes(columnSearchTerm.toLowerCase()))
.map((col) => {
const isSelected =
!dataSource.selected_columns ||
dataSource.selected_columns.length === 0 ||
dataSource.selected_columns.includes(col);
!dataSource.selectedColumns ||
dataSource.selectedColumns.length === 0 ||
dataSource.selectedColumns.includes(col);
const type = columnTypes[col] || "unknown";
const typeIcon = {
@@ -503,15 +503,15 @@ ORDER BY 하위부서수 DESC`,
key={col}
onClick={() => {
const currentSelected =
dataSource.selected_columns && dataSource.selected_columns.length > 0
? dataSource.selected_columns
dataSource.selectedColumns && dataSource.selectedColumns.length > 0
? dataSource.selectedColumns
: availableColumns;
const newSelected = isSelected
? currentSelected.filter((c) => c !== col)
: [...currentSelected, col];
onChange({ selected_columns: newSelected });
onChange({ selectedColumns: newSelected });
}}
className={`relative flex cursor-pointer items-start gap-2 rounded-lg border p-2 transition-all ${
isSelected
@@ -588,17 +588,17 @@ ORDER BY 하위부서수 DESC`,
</p>
</div>
{dataSource.column_mapping && Object.keys(dataSource.column_mapping).length > 0 && (
<Button variant="ghost" size="sm" onClick={() => onChange({ column_mapping: {} })} className="h-7 text-xs">
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<Button variant="ghost" size="sm" onClick={() => onChange({ columnMapping: {} })} className="h-7 text-xs">
</Button>
)}
</div>
{/* 매핑 목록 */}
{dataSource.column_mapping && Object.keys(dataSource.column_mapping).length > 0 && (
{dataSource.columnMapping && Object.keys(dataSource.columnMapping).length > 0 && (
<div className="space-y-2">
{Object.entries(dataSource.column_mapping).map(([original, mapped]) => (
{Object.entries(dataSource.columnMapping).map(([original, mapped]) => (
<div key={original} className="flex items-center gap-2">
{/* 원본 컬럼 (읽기 전용) */}
<Input value={original} disabled className="bg-muted h-8 flex-1 text-xs" />
@@ -610,9 +610,9 @@ ORDER BY 하위부서수 DESC`,
<Input
value={mapped}
onChange={(e) => {
const newMapping = { ...dataSource.column_mapping };
const newMapping = { ...dataSource.columnMapping };
newMapping[original] = e.target.value;
onChange({ column_mapping: newMapping });
onChange({ columnMapping: newMapping });
}}
placeholder="표시 이름"
className="h-8 flex-1 text-xs"
@@ -623,9 +623,9 @@ ORDER BY 하위부서수 DESC`,
variant="ghost"
size="sm"
onClick={() => {
const newMapping = { ...dataSource.column_mapping };
const newMapping = { ...dataSource.columnMapping };
delete newMapping[original];
onChange({ column_mapping: newMapping });
onChange({ columnMapping: newMapping });
}}
className="h-8 w-8 p-0"
>
@@ -640,9 +640,9 @@ ORDER BY 하위부서수 DESC`,
<Select
value=""
onValueChange={(col) => {
const newMapping = { ...dataSource.column_mapping } || {};
const newMapping = { ...(dataSource.columnMapping || {}) };
newMapping[col] = col; // 기본값은 원본과 동일
onChange({ column_mapping: newMapping });
onChange({ columnMapping: newMapping });
}}
>
<SelectTrigger className="h-8 text-xs">
@@ -650,7 +650,7 @@ ORDER BY 하위부서수 DESC`,
</SelectTrigger>
<SelectContent>
{availableColumns
.filter((col) => !dataSource.column_mapping || !dataSource.column_mapping[col])
.filter((col) => !dataSource.columnMapping || !dataSource.columnMapping[col])
.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
@@ -671,9 +671,9 @@ ORDER BY 하위부서수 DESC`,
</Label>
{/* 기존 팝업 필드 목록 */}
{dataSource.popup_fields && dataSource.popup_fields.length > 0 && (
{dataSource.popupFields && dataSource.popupFields.length > 0 && (
<div className="space-y-3">
{dataSource.popup_fields.map((field, index) => (
{dataSource.popupFields.map((field, index) => (
<div key={index} className="bg-muted/30 space-y-2 rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
@@ -681,9 +681,9 @@ ORDER BY 하위부서수 DESC`,
variant="ghost"
size="sm"
onClick={() => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields.splice(index, 1);
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
className="h-6 w-6 p-0"
>
@@ -697,9 +697,9 @@ ORDER BY 하위부서수 DESC`,
<Select
value={field.fieldName}
onValueChange={(value) => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields[index].fieldName = value;
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
>
<SelectTrigger className="h-8 w-full text-xs">
@@ -721,9 +721,9 @@ ORDER BY 하위부서수 DESC`,
<Input
value={field.label || ""}
onChange={(e) => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields[index].label = e.target.value;
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
placeholder="예: 차량 번호"
className="h-8 w-full text-xs"
@@ -737,9 +737,9 @@ ORDER BY 하위부서수 DESC`,
<Select
value={field.format || "text"}
onValueChange={(value: "text" | "date" | "datetime" | "number" | "url") => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields[index].format = value;
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
>
<SelectTrigger className="h-8 w-full text-xs">
@@ -774,13 +774,13 @@ ORDER BY 하위부서수 DESC`,
variant="outline"
size="sm"
onClick={() => {
const newFields = [...(dataSource.popup_fields || [])];
const newFields = [...(dataSource.popupFields || [])];
newFields.push({
fieldName: availableColumns[0] || "",
label: "",
format: "text",
});
onChange({ popup_fields: newFields });
onChange({ popupFields: newFields });
}}
className="h-8 w-full gap-2 text-xs"
disabled={availableColumns.length === 0}
+10 -10
View File
@@ -775,12 +775,12 @@ export default function CopyScreenModal({
if (!screenData && screenId && screenName) {
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
screenData = {
screenId: screenId,
screenName: screenName,
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
tableName: tableName || '',
screen_id: screenId,
screen_name: screenName,
screen_code: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
table_name: tableName || '',
description: '',
companyCode: sourceGroupData.company_code || '',
company_code: sourceGroupData.company_code || '',
} as any;
}
return { screenId, displayOrder, screenRole, screenData };
@@ -958,12 +958,12 @@ export default function CopyScreenModal({
// allScreens에 없는 경우 (다른 회사 화면) - 그룹의 screens 정보로 최소한의 데이터 구성
console.log(` ⚠️ allScreens에서 못 찾음, 그룹 정보 사용: ${screenId} - ${screenName}`);
screenData = {
screenId: screenId,
screenName: screenName,
screenCode: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
tableName: tableName || '',
screen_id: screenId,
screen_name: screenName,
screen_code: `SCREEN_${screenId}`, // 임시 코드 (실제 복사 시 새 코드 생성)
table_name: tableName || '',
description: '',
companyCode: sourceGroup.company_code || '',
company_code: sourceGroup.company_code || '',
} as any;
} else if (screenData) {
console.log(` ✅ allScreens에서 찾음: ${screenId} - ${screenData.screen_name}`);
@@ -236,8 +236,8 @@ export const FileAttachmentDetailModal: React.FC<FileAttachmentDetailModalProps>
// 서버 파일인 경우
await downloadFile({
fileId: file.objid,
serverFilename: file.savedFileName,
originalName: file.realFileName,
server_filename: file.savedFileName,
original_name: file.realFileName,
});
toast.dismiss();
@@ -448,7 +448,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
if (newCodes.length > 0) {
try {
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
const response = await apiClient.post("/table-categories/labels-by-codes", { value_codes: newCodes });
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
if (response.data.success && response.data.data) {
setCategoryCodeLabels((prev) => {
@@ -2195,8 +2195,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
await downloadFile({
fileId: fileInfo.objid || fileInfo.id,
serverFilename: serverFilename,
originalName: fileInfo.name,
server_filename: serverFilename,
original_name: fileInfo.name,
});
toast.success(`${fileInfo.name} 다운로드가 완료되었습니다.`);
@@ -27,14 +27,11 @@ import {
NumberTypeConfig,
DateTypeConfig,
SelectTypeConfig,
RadioTypeConfig,
CheckboxTypeConfig,
TextareaTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
ButtonTypeConfig,
} from "@/types";
import { ExtendedButtonTypeConfig } from "@/types/control-management";
import { InteractiveDataTable } from "./InteractiveDataTable";
import { FileUpload } from "./widgets/FileUpload";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
@@ -130,8 +127,8 @@ const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
? config?.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config?.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
@@ -336,14 +333,14 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
finalFormData,
allComponents.filter(c => c.type === 'widget') as WidgetComponent[],
tableColumns,
{
id: screenInfo.id,
{
id: screenInfo.id,
screenName: screenInfo.tableName || "unknown",
tableName: screenInfo.tableName,
tableName: screenInfo.tableName || "",
screenResolution: { width: 800, height: 600 },
gridSettings: { size: 20, color: "#e0e0e0", opacity: 0.5 },
description: "동적 화면"
},
} as any,
{
enableRealTimeValidation: true,
validationDelay: 300,
@@ -381,7 +378,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await generateNumberingCode(ruleId);
if (response.success && response.data) {
return response.data.generated_code;
return response.data.generatedCode;
}
} catch (error) {
console.error("채번 규칙 코드 생성 실패:", error);
@@ -417,7 +414,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
setPopupLayout(layout.components || []);
setPopupScreenResolution(layout.screen_resolution || null);
setPopupScreenResolution(layout.screenResolution || null);
setPopupScreenInfo({
id: popupScreen.screenId,
tableName: screen.table_name
@@ -489,7 +486,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
// 사용자 정보에서 필터 값 가져오기
const userValue = user?.[userField];
const userValue = (user as any)?.[userField];
if (userValue && sourceTable && filterColumn && displayColumn) {
try {
@@ -576,7 +573,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
component={comp as any}
className="h-full w-full"
style={{
width: "100%",
@@ -666,7 +663,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<RackStructureComponent
config={rackConfig}
formData={formData}
tableName={tableName}
tableName={screenInfo?.tableName}
onChange={(locations: any[]) => {
// 컴포넌트의 columnName을 키로 사용
const fieldKey = (comp as any).columnName || "_rackStructureLocations";
@@ -678,8 +675,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const { widgetType, label: originalLabel, placeholder, required, readonly, columnName } = comp as any;
const fieldName = (columnName as string | undefined) || comp.id;
const currentValue = formData[fieldName] || "";
// 🆕 엔티티 조인 컬럼은 읽기 전용으로 처리
@@ -688,6 +685,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
const compLangKey = (comp as any).langKey;
const translations: Record<string, string> = (typeof window !== "undefined" && (window as any).__TRANSLATIONS) || {};
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
const applyStyles = (element: React.ReactElement) => {
@@ -697,9 +695,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// size.width, size.height가 부모 컨테이너에서 적용되므로
const { width, height, ...styleWithoutSize } = comp.style;
return React.cloneElement(element, {
return React.cloneElement(element as React.ReactElement<any>, {
style: {
...element.props.style, // 기존 스타일 유지
...(element.props as any).style, // 기존 스타일 유지
...styleWithoutSize, // width/height 제외한 스타일만 적용
boxSizing: "border-box",
},
@@ -827,8 +825,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
step: config?.step,
decimalPlaces: config?.decimalPlaces,
thousandSeparator: config?.thousandSeparator,
prefix: config?.prefix,
suffix: config?.suffix,
prefix: (config as any)?.prefix,
suffix: (config as any)?.suffix,
},
});
@@ -855,7 +853,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "textarea":
case "text_area": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
const config = widget.webTypeConfig as any;
console.log("📄 InteractiveScreenViewer - Textarea 위젯:", {
componentId: widget.id,
@@ -987,7 +985,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "checkbox":
case "boolean": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
const config = widget.webTypeConfig as any;
console.log("☑️ InteractiveScreenViewer - Checkbox 위젯:", {
componentId: widget.id,
@@ -1026,7 +1024,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "radio": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
const config = widget.webTypeConfig as any;
console.log("🔘 InteractiveScreenViewer - Radio 위젯:", {
componentId: widget.id,
@@ -1069,7 +1067,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</label>
</div>
)}
{options.map((option, index) => (
{(options as Array<{label: string; value: string; disabled?: boolean}>).map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="radio"
@@ -1216,7 +1214,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
accept: config?.accept,
multiple: config?.multiple,
maxSize: config?.maxSize,
preview: config?.preview,
preview: config?.showPreview,
},
});
@@ -1306,7 +1304,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
};
const renderFilePreview = () => {
if (!currentValue || !config?.preview) return null;
if (!currentValue || !config?.showPreview) return null;
// 새로운 JSON 구조에서 파일 정보 추출
const fileData = currentValue.files || [];
@@ -1366,7 +1364,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
disabled={isReadonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
accept={config?.accept?.join(",")}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
style={{ zIndex: 1 }}
/>
@@ -1402,7 +1400,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<>
<Upload className="mx-auto h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{config?.dragDrop ? '파일을 드래그하여 놓거나 클릭하여 선택' : '클릭하여 파일 선택'}
{(config as any)?.dragDrop ? '파일을 드래그하여 놓거나 클릭하여 선택' : '클릭하여 파일 선택'}
</p>
{(config?.accept || config?.maxSize) && (
<div className="text-xs text-muted-foreground space-y-1">
@@ -1425,7 +1423,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "code": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
const config = widget.webTypeConfig as any;
console.log(`🔍 [InteractiveScreenViewer] Code 위젯 렌더링:`, {
componentId: widget.id,
@@ -1487,7 +1485,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "entity": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
const config = widget.webTypeConfig as any;
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
componentId: widget.id,
@@ -1542,16 +1540,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
case "button": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as ButtonTypeConfig | undefined;
const config = widget.webTypeConfig as ExtendedButtonTypeConfig | undefined;
const handleButtonClick = async () => {
// 프리뷰 모드에서는 버튼 동작 차단
if (isPreviewMode) {
return;
}
const actionType = config?.actionType || "save";
const actionType: string = config?.actionType || "save";
try {
switch (actionType) {
case "save":
@@ -1592,7 +1590,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
} catch (error) {
// console.error(`버튼 액션 실행 오류 (${actionType}):`, error);
alert(`작업 중 오류가 발생했습니다: ${error.message}`);
alert(`작업 중 오류가 발생했습니다: ${(error as any).message}`);
}
};
@@ -1629,17 +1627,17 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 필수 항목 검증 (테이블 타입관리 NOT NULL 기반 + 기존 required 속성 폴백)
const requiredFields = allComponents.filter(c => {
const colName = c.columnName || c.id;
const colName = (c as any).columnName || c.id;
return (c.required || isColumnRequired(colName)) && colName;
});
const missingFields = requiredFields.filter(field => {
const fieldName = field.columnName || field.id;
const fieldName = (field as any).columnName || field.id;
const value = currentFormData[fieldName];
return !value || value.toString().trim() === "";
});
if (missingFields.length > 0) {
const fieldNames = missingFields.map(f => f.label || f.columnName || f.id).join(", ");
const fieldNames = missingFields.map(f => f.label || (f as any).columnName || f.id).join(", ");
alert(`다음 필수 항목을 입력해주세요: ${fieldNames}`);
return;
}
@@ -1741,8 +1739,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
// 테이블명 결정 (화면 정보에서 가져오거나 첫 번째 컴포넌트의 테이블명 사용)
const tableName = screenInfo.tableName ||
allComponents.find(c => c.columnName)?.tableName ||
const tableName = screenInfo.tableName ||
(allComponents.find(c => (c as any).columnName) as any)?.tableName ||
"dynamic_form_data"; // 기본값
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
@@ -1808,11 +1806,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 저장 후 데이터 초기화 (선택사항)
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
resetData[key] = "";
onFormDataChange!(key, "");
});
onFormDataChange(resetData);
}
} else {
throw new Error(result.message || "저장에 실패했습니다.");
@@ -1841,8 +1837,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
// 테이블명 결정
const tableName = screenInfo?.tableName ||
allComponents.find(c => c.columnName)?.tableName ||
const tableName = screenInfo?.tableName ||
(allComponents.find(c => (c as any).columnName) as any)?.tableName ||
"unknown_table";
if (!tableName || tableName === "unknown_table") {
@@ -1861,11 +1857,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 삭제 후 폼 초기화
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
resetData[key] = "";
onFormDataChange!(key, "");
});
onFormDataChange(resetData);
}
} else {
throw new Error(result.message || "삭제에 실패했습니다.");
@@ -1881,8 +1875,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
console.log("✏️ 수정 액션 실행");
// 버튼 컴포넌트의 수정 모달 설정 가져오기
const editModalTitle = config?.editModalTitle || "";
const editModalDescription = config?.editModalDescription || "";
const editModalTitle = (config as any)?.editModalTitle || "";
const editModalDescription = (config as any)?.editModalDescription || "";
console.log("📝 버튼 수정 모달 설정:", { editModalTitle, editModalDescription });
@@ -1926,11 +1920,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const handleResetAction = () => {
if (confirm("모든 입력을 초기화하시겠습니까?")) {
if (onFormDataChange) {
const resetData: Record<string, any> = {};
Object.keys(formData).forEach(key => {
resetData[key] = "";
onFormDataChange!(key, "");
});
onFormDataChange(resetData);
}
// console.log("🔄 폼 초기화 완료");
alert("입력이 초기화되었습니다.");
@@ -2046,7 +2038,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
// console.log("⚡ 커스텀 액션 실행 완료");
} catch (error) {
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
throw new Error(`커스텀 액션 실행 실패: ${(error as any).message}`);
}
} else {
// console.log("⚡ 커스텀 액션이 설정되지 않았습니다.");
@@ -2165,8 +2157,8 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return (
<div className="h-full w-full">
<FileUpload
component={fileComponent}
onUpdateComponent={handleFileUpdate}
component={fileComponent as any}
onUpdateComponent={handleFileUpdate as any}
userInfo={user} // 사용자 정보를 프롭으로 전달
/>
</div>
@@ -2212,7 +2204,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const shouldShowLabel =
!hideLabel &&
(component.style?.labelDisplay ?? true) !== false &&
component.style?.labelDisplay !== "false" &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type);
@@ -2237,9 +2228,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "hsl(var(--foreground))",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
backgroundColor: (component.style as any)?.labelBackgroundColor || "transparent",
padding: (component.style as any)?.labelPadding || "0",
borderRadius: (component.style as any)?.labelBorderRadius || "0",
...(isHorizontalLabel
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
@@ -2504,7 +2495,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
style={labelStyle}
>
{labelText}
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
{(component.required || component.componentConfig?.required || isColumnRequired((component as any).columnName || (component.style as any)?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
@@ -2523,7 +2514,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}}
>
{labelText}
{(component.required || component.componentConfig?.required || isColumnRequired(component.columnName || component.style?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
{(component.required || component.componentConfig?.required || isColumnRequired((component as any).columnName || (component.style as any)?.columnName || "")) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
</div>
@@ -15,7 +15,7 @@ import { DynamicComponentRenderer, isColumnRequiredByMeta } from "@/lib/registry
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { isFileComponent, isDataTableComponent, isButtonComponent } from "@/lib/utils/componentTypeUtils";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import { FlowVisibilityConfig } from "@/types/screen-management";
import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
@@ -283,7 +283,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig;
// 사용자 정보에서 필터 값 가져오기
const userValue = user?.[userField];
const userValue = (user as any)?.[userField];
if (userValue && sourceTable && filterColumn && displayColumn) {
try {
@@ -314,18 +314,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const loadPopupScreen = async (screenId: number) => {
try {
setPopupLoading(true);
const response = await screenApi.getScreenLayout(screenId);
const layoutData = await screenApi.getLayout(screenId) as any;
if (response.success && response.data) {
const screenData = response.data;
setPopupLayout(screenData.components || []);
if (layoutData) {
setPopupLayout(layoutData.components || []);
setPopupScreenResolution({
width: screenData.screen_resolution?.width || 1200,
height: screenData.screen_resolution?.height || 800,
width: layoutData.screen_resolution?.width || 1200,
height: layoutData.screen_resolution?.height || 800,
});
setPopupScreenInfo({
id: screenData.id,
tableName: screenData.table_name,
id: layoutData.id || screenId,
tableName: layoutData.table_name,
});
} else {
toast.error("팝업 화면을 불러올 수 없습니다.");
@@ -353,7 +352,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 동적 대화형 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 조건부 표시 평가 (기존 conditional 시스템)
const conditionalResult = evaluateConditional(comp.conditional, formData, allComponents);
const conditionalResult = evaluateConditional(comp.conditional as any, formData, allComponents);
// 조건에 따라 숨김 처리
if (!conditionalResult.visible) {
@@ -393,7 +392,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
if (isDataTableComponent(comp)) {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
component={comp as any as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
@@ -476,7 +475,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return React.cloneElement(element, {
style: {
...element.props.style,
...(element.props as any).style,
...styleWithoutSize, // width/height 제외한 스타일만 적용
width: "100%",
height: "100%",
@@ -556,7 +555,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 버튼 렌더링
const renderButton = (comp: ComponentData) => {
const config = (comp as any).webTypeConfig as ButtonTypeConfig | undefined;
const config = (comp as any).webTypeConfig as any;
const { label } = comp;
// 버튼 액션 핸들러들
@@ -726,7 +725,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
// console.log("⚡ 커스텀 액션 실행 완료");
} catch (error) {
throw new Error(`커스텀 액션 실행 실패: ${error.message}`);
throw new Error(`커스텀 액션 실행 실패: ${(error as any).message}`);
}
}
};
@@ -744,7 +743,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 1. 대상 테이블의 컬럼 목록 조회 (자동 매핑용)
let targetTableColumns: string[] = [];
try {
const { default: apiClient } = await import("@/lib/api/client");
const { apiClient } = await import("@/lib/api/client");
const columnsResponse = await apiClient.get(
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
);
@@ -865,7 +864,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 5. 중복 체크 (설정된 경우)
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
try {
const { default: apiClient } = await import("@/lib/api/client");
const { apiClient } = await import("@/lib/api/client");
// 중복 체크를 위한 검색 조건 구성
const searchConditions: Record<string, any> = {};
@@ -880,7 +879,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 기존 데이터 조회
const checkResponse = await apiClient.post(`/table-management/tables/${quickInsertConfig.targetTable}/data`, {
page: 1,
pageSize: 1,
size: 1,
search: searchConditions,
});
@@ -900,7 +899,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 6. API 호출
try {
const { default: apiClient } = await import("@/lib/api/client");
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
@@ -969,7 +968,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}
} catch (error) {
// console.error("버튼 액션 오류:", error);
toast.error(error.message || "액션 실행 중 오류가 발생했습니다.");
toast.error((error as any)?.message || "액션 실행 중 오류가 발생했습니다.");
}
};
@@ -1008,7 +1007,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 화면 ID 추출 (URL에서)
const screenId =
screenInfo?.screenId ||
screenInfo?.id ||
(typeof window !== "undefined" && window.location.pathname.includes("/screens/")
? parseInt(window.location.pathname.split("/screens/")[1])
: null);
@@ -1021,7 +1020,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
componentConfig={{
...comp.fileConfig,
multiple: comp.fileConfig?.multiple !== false,
accept: comp.fileConfig?.accept || "*/*",
accept: (comp.fileConfig?.accept as string) || "*/*",
maxSize: (comp.fileConfig?.maxSize || 10) * 1024 * 1024, // MB to bytes
disabled: readonly,
}}
@@ -1124,11 +1123,10 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
const compType = (component as any).componentType || "";
const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
const isV2InputComponent =
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false &&
(style?.labelText || (component as any).label);
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
@@ -1435,7 +1433,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
onFormDataChange={(fieldName, value) => {
setPopupFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
screenInfo={popupScreenInfo}
screenInfo={popupScreenInfo ?? undefined}
/>
))}
</div>
+13 -9
View File
@@ -10,13 +10,12 @@ import {
ComponentData,
LayoutData,
GroupState,
TableInfo,
Position,
ColumnInfo,
GridSettings,
ScreenResolution,
SCREEN_RESOLUTIONS,
} from "@/types/screen";
import type { TableInfo, ColumnInfo } from "@/types/screen-legacy-backup";
import { SCREEN_RESOLUTIONS } from "@/types/screen-management";
import { generateComponentId } from "@/lib/utils/generateId";
import {
getComponentIdFromWebType,
@@ -108,7 +107,7 @@ import DetailSettingsPanel from "./panels/DetailSettingsPanel";
import ResolutionPanel from "./panels/ResolutionPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
import { FlowButtonGroup } from "./widgets/FlowButtonGroup";
import { FlowVisibilityConfig } from "@/types/control-management";
import { FlowVisibilityConfig } from "@/types/screen-management";
import {
areAllButtons,
generateGroupId,
@@ -172,6 +171,7 @@ export default function ScreenDesigner({
// POP 모드 여부에 따른 API 분기
const USE_POP_API = isPop;
const [layout, setLayout] = useState<LayoutData>({
screenId: 0,
components: [],
gridSettings: {
columns: 12,
@@ -181,6 +181,10 @@ export default function ScreenDesigner({
showGrid: false, // 기본값 false로 변경
gridColor: "#d1d5db",
gridOpacity: 0.5,
enabled: false,
size: 16,
color: "#d1d5db",
opacity: 0.5,
},
});
const [isSaving, setIsSaving] = useState(false);
@@ -567,7 +571,7 @@ export default function ScreenDesigner({
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
const findZone = async () => {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screen_id, activeLayerId);
const layerData = await screenApi.getLayerLayout(selectedScreen.screen_id!, activeLayerId);
const zoneId = layerData?.conditionConfig?.zone_id;
if (zoneId) {
const zone = zones.find(z => z.zone_id === zoneId);
@@ -587,14 +591,14 @@ export default function ScreenDesigner({
if (!selectedScreen?.screen_id) return;
const loadOtherLayerComponents = async () => {
try {
const allLayers = await screenApi.getScreenLayers(selectedScreen.screen_id);
const allLayers = await screenApi.getScreenLayers(selectedScreen.screen_id!);
const currentLayerId = activeLayerIdRef.current || 1;
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
const components: ComponentData[] = [];
for (const layerInfo of otherLayers) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screen_id, layerInfo.layer_id);
const layerData = await screenApi.getLayerLayout(selectedScreen.screen_id!, layerInfo.layer_id);
const rawComps = layerData?.components;
if (rawComps && Array.isArray(rawComps)) {
for (const comp of rawComps) {
@@ -1549,7 +1553,7 @@ export default function ScreenDesigner({
const loadLayout = async () => {
try {
// 🆕 화면에 할당된 메뉴 조회
const menuInfo = await screenApi.getScreenMenu(selectedScreen.screen_id);
const menuInfo = await screenApi.getScreenMenu(selectedScreen.screen_id!);
if (menuInfo) {
setMenuObjid(menuInfo.menuObjid);
console.log("🔗 화면에 할당된 메뉴:", menuInfo);
@@ -1561,7 +1565,7 @@ export default function ScreenDesigner({
let response: any;
if (USE_POP_API) {
// POP 모드: screen_layouts_pop 테이블 사용
const popResponse = await screenApi.getLayoutPop(selectedScreen.screen_id);
const popResponse = await screenApi.getLayoutPop(selectedScreen.screen_id!);
response = popResponse ? convertV2ToLegacy(popResponse) : null;
console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트");
} else if (USE_V2_API) {
@@ -306,22 +306,22 @@ export function TableSettingModal({
// 초기 편집 상태 설정
const initialEdits: Record<string, Partial<ColumnTypeInfo>> = {};
columnsData.forEach((col) => {
// referenceTable이 설정되어 있으면 inputType은 entity여야 함
let effectiveInputType = col.inputType || "direct";
if (col.referenceTable && effectiveInputType !== "entity") {
// reference_table이 설정되어 있으면 input_type은 entity여야 함
let effectiveInputType = col.input_type || "direct";
if (col.reference_table && effectiveInputType !== "entity") {
effectiveInputType = "entity";
}
// codeCategory/codeValue가 설정되어 있으면 inputType은 code여야 함
if (col.codeCategory && effectiveInputType !== "code") {
// code_category/code_value가 설정되어 있으면 input_type은 code여야 함
if (col.code_category && effectiveInputType !== "code") {
effectiveInputType = "code";
}
initialEdits[col.columnName] = {
displayName: col.displayName,
inputType: effectiveInputType,
referenceTable: col.referenceTable,
referenceColumn: col.referenceColumn,
displayColumn: col.displayColumn,
initialEdits[col.column_name] = {
display_name: col.display_name,
input_type: effectiveInputType,
reference_table: col.reference_table,
reference_column: col.reference_column,
display_column: col.display_column,
};
});
setEditedColumns(initialEdits);
@@ -408,8 +408,8 @@ export function TableSettingModal({
// 참조 테이블 변경 시 컬럼 로드
useEffect(() => {
Object.values(editedColumns).forEach((col) => {
if (col.referenceTable && col.referenceTable !== "none") {
loadRefTableColumns(col.referenceTable);
if (col.reference_table && col.reference_table !== "none") {
loadRefTableColumns(col.reference_table);
}
});
}, [editedColumns, loadRefTableColumns]);
@@ -431,17 +431,17 @@ export function TableSettingModal({
}));
// 입력 타입 변경 시 관련 필드 초기화
if (field === "inputType") {
if (field === "input_type") {
// 엔티티가 아닌 다른 타입으로 변경하면 참조 설정 초기화
if (value !== "entity") {
setEditedColumns((prev) => ({
...prev,
[columnName]: {
...prev[columnName],
inputType: value,
referenceTable: "",
referenceColumn: "",
displayColumn: "",
input_type: value,
reference_table: "",
reference_column: "",
display_column: "",
},
}));
}
@@ -451,22 +451,22 @@ export function TableSettingModal({
...prev,
[columnName]: {
...prev[columnName],
inputType: value,
codeCategory: "",
codeValue: "",
input_type: value,
code_category: "",
code_value: "",
},
}));
}
}
// 참조 테이블 변경 시 참조 컬럼 초기화
if (field === "referenceTable") {
if (field === "reference_table") {
setEditedColumns((prev) => ({
...prev,
[columnName]: {
...prev[columnName],
referenceColumn: "",
displayColumn: "",
reference_column: "",
display_column: "",
},
}));
if (value && value !== "none") {
@@ -482,7 +482,7 @@ export function TableSettingModal({
// 변경된 컬럼들만 저장
for (const [columnName, editedSettings] of Object.entries(editedColumns)) {
// 기존 컬럼 정보 찾기
const originalColumn = tableColumns.find((c) => c.columnName === columnName);
const originalColumn = tableColumns.find((c) => c.column_name === columnName);
if (!originalColumn) continue;
// 기존 값과 편집된 값 병합
@@ -491,25 +491,25 @@ export function TableSettingModal({
...editedSettings,
};
// detailSettings 처리 (Entity 타입인 경우)
let finalDetailSettings = mergedColumn.detailSettings || "";
// referenceTable이 설정되어 있으면 inputType을 entity로 자동 설정
let currentInputType = (mergedColumn.inputType || "") as string;
if (mergedColumn.referenceTable && currentInputType !== "entity") {
// detail_settings 처리 (Entity 타입인 경우)
let finalDetailSettings = mergedColumn.detail_settings || "";
// reference_table이 설정되어 있으면 input_type을 entity로 자동 설정
let currentInputType = (mergedColumn.input_type || "") as string;
if (mergedColumn.reference_table && currentInputType !== "entity") {
currentInputType = "entity";
}
// codeCategory가 설정되어 있으면 inputType을 code로 자동 설정
if (mergedColumn.codeCategory && currentInputType !== "code") {
// code_category가 설정되어 있으면 input_type을 code로 자동 설정
if (mergedColumn.code_category && currentInputType !== "code") {
currentInputType = "code";
}
if (currentInputType === "entity" && mergedColumn.referenceTable) {
// 기존 detailSettings를 파싱하거나 새로 생성
if (currentInputType === "entity" && mergedColumn.reference_table) {
// 기존 detail_settings를 파싱하거나 새로 생성
let existingSettings: Record<string, unknown> = {};
if (typeof mergedColumn.detailSettings === "string" && mergedColumn.detailSettings.trim().startsWith("{")) {
if (typeof mergedColumn.detail_settings === "string" && mergedColumn.detail_settings.trim().startsWith("{")) {
try {
existingSettings = JSON.parse(mergedColumn.detailSettings);
existingSettings = JSON.parse(mergedColumn.detail_settings);
} catch {
existingSettings = {};
}
@@ -518,9 +518,9 @@ export function TableSettingModal({
// 엔티티 설정 추가
const entitySettings = {
...existingSettings,
entityTable: mergedColumn.referenceTable,
entityCodeColumn: mergedColumn.referenceColumn || "id",
entityLabelColumn: mergedColumn.displayColumn || "name",
entityTable: mergedColumn.reference_table,
entityCodeColumn: mergedColumn.reference_column || "id",
entityLabelColumn: mergedColumn.display_column || "name",
placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요",
searchable: existingSettings.searchable ?? true,
};
@@ -552,14 +552,14 @@ export function TableSettingModal({
// ColumnSettings 인터페이스에 맞게 데이터 구성
const columnSetting: ColumnSettings = {
columnName: columnName,
columnLabel: mergedColumn.displayName || originalColumn.displayName || "",
inputType: currentInputType || "text", // referenceTable/codeCategory가 설정된 경우 자동 보정된 값 사용
columnLabel: mergedColumn.display_name || originalColumn.display_name || "",
inputType: currentInputType || "text", // reference_table/code_category가 설정된 경우 자동 보정된 값 사용
detailSettings: finalDetailSettings,
codeCategory: mergedColumn.codeCategory || originalColumn.codeCategory || "",
codeValue: mergedColumn.codeValue || originalColumn.codeValue || "",
referenceTable: mergedColumn.referenceTable || "",
referenceColumn: mergedColumn.referenceColumn || "",
displayColumn: mergedColumn.displayColumn || "",
codeCategory: mergedColumn.code_category || originalColumn.code_category || "",
codeValue: mergedColumn.code_value || originalColumn.code_value || "",
referenceTable: mergedColumn.reference_table || "",
referenceColumn: mergedColumn.reference_column || "",
displayColumn: mergedColumn.display_column || "",
};
console.log("저장할 컬럼 설정:", columnSetting);
@@ -590,11 +590,11 @@ export function TableSettingModal({
const mergedColumns = useMemo(() => {
const columnsMap = new Map<string, ColumnTypeInfo & { isPK?: boolean; isFK?: boolean }>();
// API에서 가져온 컬럼 정보 (camelCase)
// API에서 가져온 컬럼 정보 (snake_case)
tableColumns.forEach((tcol) => {
columnsMap.set(tcol.columnName, {
columnsMap.set(tcol.column_name, {
...tcol,
isPK: tcol.isPrimaryKey,
isPK: tcol.is_primary_key,
isFK: false, // 백엔드에서 isForeignKey를 제공하지 않으므로 false 기본값
});
});
@@ -605,7 +605,7 @@ export function TableSettingModal({
// 선택된 컬럼 정보
const selectedColumnInfo = useMemo(() => {
if (!selectedColumn) return null;
return mergedColumns.find((c) => c.columnName === selectedColumn);
return mergedColumns.find((c) => c.column_name === selectedColumn);
}, [selectedColumn, mergedColumns]);
// 테이블 옵션
@@ -613,9 +613,9 @@ export function TableSettingModal({
() => [
{ value: "none", label: "-- 선택 안함 --" },
...tables.map((t) => ({
value: t.tableName,
label: t.displayName || t.tableName,
description: t.tableName,
value: t.table_name,
label: t.display_name || t.table_name,
description: t.table_name,
})),
],
[tables]
@@ -637,9 +637,9 @@ export function TableSettingModal({
return [
{ value: "", label: "-- 선택 안함 --" },
...cols.map((c) => ({
value: c.columnName,
label: c.displayName || c.columnName,
description: c.dataType,
value: c.column_name,
label: c.display_name || c.column_name,
description: c.data_type,
})),
];
};
@@ -719,8 +719,8 @@ export function TableSettingModal({
<ColumnListTab
columns={tableColumns.map((col) => ({
...col,
isPK: col.columnName === "id" || col.columnName.endsWith("_id"),
isFK: (col.inputType as string) === "entity",
isPK: col.column_name === "id" || col.column_name.endsWith("_id"),
isFK: (col.input_type as string) === "entity",
}))}
editedColumns={editedColumns}
selectedColumn={selectedColumn}
@@ -747,9 +747,9 @@ export function TableSettingModal({
{/* 우측: 상세 설정 (60%) */}
<div className="flex w-[60%] min-h-0 flex-col rounded-lg border bg-muted/30 p-3">
{selectedColumn && mergedColumns.find((c) => c.columnName === selectedColumn) ? (
{selectedColumn && mergedColumns.find((c) => c.column_name === selectedColumn) ? (
<ColumnDetailPanel
columnInfo={mergedColumns.find((c) => c.columnName === selectedColumn)!}
columnInfo={mergedColumns.find((c) => c.column_name === selectedColumn)!}
editedColumn={editedColumns[selectedColumn] || {}}
tableOptions={tableOptions}
inputTypeOptions={inputTypeOptions}
@@ -997,8 +997,8 @@ function ColumnListTab({
const term = searchTerm.toLowerCase();
return columns.filter(
(col) =>
col.columnName.toLowerCase().includes(term) ||
(col.displayName || "").toLowerCase().includes(term)
col.column_name.toLowerCase().includes(term) ||
(col.display_name || "").toLowerCase().includes(term)
);
}, [columns, searchTerm]);
@@ -1045,15 +1045,15 @@ function ColumnListTab({
) : (
<div className="space-y-1 px-3 pb-3">
{filteredColumns.map((col) => {
const edited = editedColumns[col.columnName] || {};
// editedColumns에서 inputType을 가져옴 (초기화 시 이미 보정됨)
const inputType = (edited.inputType || col.inputType || "text") as string;
const isSelected = selectedColumn === col.columnName;
const edited = editedColumns[col.column_name] || {};
// editedColumns에서 input_type을 가져옴 (초기화 시 이미 보정됨)
const inputType = (edited.input_type || col.input_type || "text") as string;
const isSelected = selectedColumn === col.column_name;
return (
<div
key={col.columnName}
onClick={() => onSelectColumn(col.columnName)}
key={col.column_name}
onClick={() => onSelectColumn(col.column_name)}
className={cn(
"cursor-pointer rounded-lg border p-3 transition-colors",
isSelected
@@ -1067,7 +1067,7 @@ function ColumnListTab({
{getInputTypeIcon(inputType)}
</span>
<span className="text-sm font-medium">
{edited.displayName || col.displayName || col.columnName}
{edited.display_name || col.display_name || col.column_name}
</span>
</div>
<div className="flex gap-1">
@@ -1077,8 +1077,8 @@ function ColumnListTab({
PK
</Badge>
)}
{/* 엔티티 타입이거나 referenceTable이 설정되어 있으면 조인 배지 표시 (FK와 동일 의미) */}
{(inputType === "entity" || edited.referenceTable || col.referenceTable) && (
{/* 엔티티 타입이거나 reference_table이 설정되어 있으면 조인 배지 표시 (FK와 동일 의미) */}
{(inputType === "entity" || edited.reference_table || col.reference_table) && (
<Badge variant="outline" className="bg-primary/10 text-primary text-[10px] px-1.5">
<Link2 className="mr-0.5 h-2.5 w-2.5" />
@@ -1087,7 +1087,7 @@ function ColumnListTab({
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
<span className="font-mono">{col.columnName}</span>
<span className="font-mono">{col.column_name}</span>
</div>
</div>
);
@@ -1122,12 +1122,12 @@ function ColumnDetailPanel({
loadingRefColumns,
onColumnChange,
}: ColumnDetailPanelProps) {
const currentLabel = editedColumn.displayName ?? columnInfo.displayName ?? "";
const currentRefTable = editedColumn.referenceTable ?? columnInfo.referenceTable ?? "";
const currentRefColumn = editedColumn.referenceColumn ?? columnInfo.referenceColumn ?? "";
const currentDisplayColumn = editedColumn.displayColumn ?? columnInfo.displayColumn ?? "";
// editedColumn에서 inputType을 가져옴 (초기화 시 이미 보정됨)
const currentInputType = (editedColumn.inputType ?? columnInfo.inputType ?? "text") as string;
const currentLabel = editedColumn.display_name ?? columnInfo.display_name ?? "";
const currentRefTable = editedColumn.reference_table ?? columnInfo.reference_table ?? "";
const currentRefColumn = editedColumn.reference_column ?? columnInfo.reference_column ?? "";
const currentDisplayColumn = editedColumn.display_column ?? columnInfo.display_column ?? "";
// editedColumn에서 input_type을 가져옴 (초기화 시 이미 보정됨)
const currentInputType = (editedColumn.input_type ?? columnInfo.input_type ?? "text") as string;
return (
<div className="flex h-full flex-col">
@@ -1139,9 +1139,9 @@ function ColumnDetailPanel({
</div>
<div className="mt-2 flex items-center gap-2">
<span className="font-mono text-sm bg-muted px-2 py-0.5 rounded">
{columnInfo.columnName}
{columnInfo.column_name}
</span>
<span className="text-xs text-muted-foreground">{columnInfo.dataType}</span>
<span className="text-xs text-muted-foreground">{columnInfo.data_type}</span>
{columnInfo.isPK && (
<Badge variant="outline" className="bg-amber-100 text-orange-700 text-[10px]">
Primary Key
@@ -1166,8 +1166,8 @@ function ColumnDetailPanel({
<Label className="text-xs"> </Label>
<Input
value={currentLabel}
onChange={(e) => onColumnChange("displayName", e.target.value)}
placeholder={columnInfo.columnName}
onChange={(e) => onColumnChange("display_name", e.target.value)}
placeholder={columnInfo.column_name}
className="h-9 text-sm"
/>
<p className="text-[10px] text-muted-foreground">
@@ -1179,7 +1179,7 @@ function ColumnDetailPanel({
<Label className="text-xs"> </Label>
<Select
value={currentInputType}
onValueChange={(v) => onColumnChange("inputType", v)}
onValueChange={(v) => onColumnChange("input_type", v)}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="입력 타입 선택" />
@@ -1210,7 +1210,7 @@ function ColumnDetailPanel({
<Label className="text-xs"> </Label>
<SearchableSelect
value={currentRefTable || "none"}
onValueChange={(v) => onColumnChange("referenceTable", v === "none" ? "" : v)}
onValueChange={(v) => onColumnChange("reference_table", v === "none" ? "" : v)}
options={tableOptions}
placeholder="테이블 선택"
/>
@@ -1228,7 +1228,7 @@ function ColumnDetailPanel({
) : (
<SearchableSelect
value={currentRefColumn}
onValueChange={(v) => onColumnChange("referenceColumn", v)}
onValueChange={(v) => onColumnChange("reference_column", v)}
options={getRefColumnOptions(currentRefTable)}
placeholder="컬럼 선택"
/>
@@ -1248,7 +1248,7 @@ function ColumnDetailPanel({
) : (
<SearchableSelect
value={currentDisplayColumn}
onValueChange={(v) => onColumnChange("displayColumn", v)}
onValueChange={(v) => onColumnChange("display_column", v)}
options={getRefColumnOptions(currentRefTable)}
placeholder="표시 컬럼 선택"
/>
@@ -1269,22 +1269,22 @@ function ColumnDetailPanel({
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-xs text-muted-foreground"> </span>
<p className="font-mono">{columnInfo.dataType}</p>
<p className="font-mono">{columnInfo.data_type}</p>
</div>
<div>
<span className="text-xs text-muted-foreground">NULL </span>
<p>{columnInfo.isNullable === "YES" ? "예" : "아니오"}</p>
<p>{columnInfo.is_nullable === "YES" ? "예" : "아니오"}</p>
</div>
{columnInfo.maxLength && (
{columnInfo.max_length && (
<div>
<span className="text-xs text-muted-foreground"> </span>
<p>{columnInfo.maxLength}</p>
<p>{columnInfo.max_length}</p>
</div>
)}
{columnInfo.defaultValue && (
{columnInfo.default_value && (
<div>
<span className="text-xs text-muted-foreground"></span>
<p className="font-mono text-xs">{columnInfo.defaultValue}</p>
<p className="font-mono text-xs">{columnInfo.default_value}</p>
</div>
)}
</div>
@@ -11,13 +11,29 @@ import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, EntityTypeConfig } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen";
type LocalEntityConfig = EntityTypeConfig & {
entityType?: string;
displayFields?: string[];
searchFields?: string[];
valueField?: string;
labelField?: string;
searchable?: boolean;
emptyMessage?: string;
pageSize?: number;
minSearchLength?: number;
defaultValue?: string;
required?: boolean;
readonly?: boolean;
apiEndpoint?: string;
};
export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component,
onUpdateComponent,
onUpdateProperty,
}) => {
const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as EntityTypeConfig) || {};
const config = (widget.webTypeConfig as unknown as LocalEntityConfig) || {};
// 테이블 타입 관리에서 설정된 참조 테이블 정보
const [referenceInfo, setReferenceInfo] = useState<{
@@ -35,7 +51,10 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
});
// 로컬 상태 (UI 관련 설정만)
const [localConfig, setLocalConfig] = useState<EntityTypeConfig>({
const [localConfig, setLocalConfig] = useState<LocalEntityConfig>({
referenceTable: config.referenceTable || "",
referenceColumn: config.referenceColumn || "",
displayColumns: config.displayColumns || [],
entityType: config.entityType || "",
displayFields: config.displayFields || [],
searchFields: config.searchFields || [],
@@ -58,7 +77,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
useEffect(() => {
const loadReferenceInfo = async () => {
// 컴포넌트의 테이블명과 컬럼명이 있는 경우에만 조회
const tableName = widget.tableName;
const tableName = (widget as any).tableName;
const columnName = widget.columnName;
if (!tableName || !columnName) {
@@ -142,12 +161,15 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
};
loadReferenceInfo();
}, [widget.tableName, widget.columnName]);
}, [(widget as any).tableName, widget.columnName]);
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
const currentConfig = (widget.webTypeConfig as unknown as LocalEntityConfig) || {};
setLocalConfig({
referenceTable: currentConfig.referenceTable || "",
referenceColumn: currentConfig.referenceColumn || "",
displayColumns: currentConfig.displayColumns || [],
entityType: currentConfig.entityType || "",
displayFields: currentConfig.displayFields || [],
searchFields: currentConfig.searchFields || [],
@@ -168,14 +190,14 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
}, [widget.webTypeConfig, referenceInfo.referenceColumn, referenceInfo.displayColumn]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
const updateConfig = (field: keyof LocalEntityConfig, value: any) => {
const newConfig = { ...localConfig, [field]: value };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
};
// 입력 필드용 업데이트 (로컬 상태만)
const updateConfigLocal = (field: keyof EntityTypeConfig, value: any) => {
const updateConfigLocal = (field: keyof LocalEntityConfig, value: any) => {
setLocalConfig({ ...localConfig, [field]: value });
};
@@ -11,16 +11,26 @@ import { Upload, File, X } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, FileTypeConfig } from "@/types/screen";
type LocalFileConfig = FileTypeConfig & {
dragAndDrop?: boolean;
required?: boolean;
readonly?: boolean;
browseText?: string;
uploadText?: string;
maxFileSize?: number;
acceptedTypes: string[];
};
export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
component,
onUpdateComponent,
onUpdateProperty,
}) => {
const widget = component as WidgetComponent;
const config = (widget.webTypeConfig as FileTypeConfig) || {};
const config = (widget.webTypeConfig as unknown as LocalFileConfig) || {};
// 로컬 상태
const [localConfig, setLocalConfig] = useState<FileTypeConfig>({
const [localConfig, setLocalConfig] = useState<LocalFileConfig>({
multiple: config.multiple || false,
maxFileSize: config.maxFileSize || 10, // MB
maxFiles: config.maxFiles || 1,
@@ -39,7 +49,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const currentConfig = (widget.webTypeConfig as FileTypeConfig) || {};
const currentConfig = (widget.webTypeConfig as unknown as LocalFileConfig) || {};
setLocalConfig({
multiple: currentConfig.multiple || false,
maxFileSize: currentConfig.maxFileSize || 10,
@@ -56,7 +66,7 @@ export const FileConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
const updateConfig = (field: keyof FileTypeConfig, value: any) => {
const updateConfig = (field: keyof LocalFileConfig, value: any) => {
const newConfig = { ...localConfig, [field]: value };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
@@ -15,8 +15,8 @@ import {
FileComponent,
WebTypeConfig,
TableInfo,
LayoutComponent,
} from "@/types/screen";
import { LayoutComponent } from "@/types/layout";
// 레거시 ButtonConfigPanel 제거됨
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
@@ -25,6 +25,17 @@ import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } fro
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/v2-components";
// TableInfo에 columns 추가 (런타임 데이터 형식)
type ColumnInfoWithLabel = {
columnName: string;
columnLabel?: string;
dataType: string;
[key: string]: any;
};
type TableInfoWithColumns = TableInfo & {
columns?: ColumnInfoWithLabel[];
};
// 새로운 컴포넌트 설정 패널들 import
import { ButtonConfigPanel as NewButtonConfigPanel } from "../config-panels/ButtonConfigPanel";
import { CardConfigPanel } from "../config-panels/CardConfigPanel";
@@ -44,7 +55,7 @@ import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
interface DetailSettingsPanelProps {
selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void;
currentTable?: TableInfo; // 현재 화면의 테이블 정보
currentTable?: TableInfoWithColumns; // 현재 화면의 테이블 정보
currentTableName?: string; // 현재 화면의 테이블명
tables?: TableInfo[]; // 전체 테이블 목록
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
@@ -68,7 +79,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
componentType: selectedComponent?.type,
currentTableName,
currentTable: currentTable?.tableName,
selectedComponentTableName: selectedComponent?.tableName,
selectedComponentTableName: (selectedComponent as any)?.tableName,
});
// console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개");
// console.log(`🔍 webTypes:`, webTypes);
@@ -355,7 +366,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
)}
{/* 카드 레이아웃 설정 */}
{layoutComponent.layoutType === "card-layout" && (
{(layoutComponent.layoutType as string) === "card-layout" && (
<div className="space-y-4">
<h4 className="text-sm font-medium text-foreground"> </h4>
@@ -365,7 +376,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
<h5 className="text-xs font-medium text-foreground"> </h5>
{currentTable && (
<span className="bg-accent text-primary rounded px-2 py-1 text-xs">
: {currentTable.table_name}
: {currentTable.tableName}
</span>
)}
</div>
@@ -678,7 +689,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
)}
{/* 존 목록 - 카드 레이아웃은 데이터 기반이므로 존 관리 불필요 */}
{layoutComponent.layoutType !== "card-layout" && (
{(layoutComponent.layoutType as string) !== "card-layout" && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-foreground"> </h4>
<div className="space-y-2">
@@ -850,11 +861,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
const componentType = selectedComponent.componentConfig?.type || selectedComponent.type;
const handleUpdateProperty = (path: string, value: any) => {
onUpdateProperty(selectedComponent.id, path, value);
onUpdateProperty(selectedComponent!.id, path, value);
};
const handleConfigChange = (newConfig: any) => {
onUpdateProperty(selectedComponent.id, "componentConfig.config", newConfig);
onUpdateProperty(selectedComponent!.id, "componentConfig.config", newConfig);
};
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
@@ -874,11 +885,11 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const config = currentConfig || (definition as any).defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
onUpdateProperty(selectedComponent!.id, "componentConfig", newConfig);
};
return (
@@ -1008,14 +1019,14 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}
// 레이아웃 컴포넌트 처리
if (selectedComponent.type === "layout") {
return renderLayoutConfig(selectedComponent as LayoutComponent);
if ((selectedComponent as any).type === "layout") {
return renderLayoutConfig(selectedComponent as unknown as LayoutComponent);
}
if (
selectedComponent.type !== "widget" &&
selectedComponent.type !== "file" &&
selectedComponent.type !== "button" &&
(selectedComponent as any).type !== "button" &&
selectedComponent.type !== "component"
) {
return (
@@ -1070,26 +1081,27 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}
// 레거시 버튼을 새로운 컴포넌트 시스템으로 강제 변환
if (selectedComponent.type === "button") {
if ((selectedComponent as any).type === "button") {
// console.log("🔄 레거시 버튼을 새로운 컴포넌트 시스템으로 변환:", selectedComponent);
const anyComp = selectedComponent as any;
// 레거시 버튼을 새로운 시스템으로 변환
const convertedComponent = {
...selectedComponent,
...anyComp,
type: "component" as const,
componentConfig: {
type: "button-primary",
webType: "button",
...selectedComponent.componentConfig,
...anyComp.componentConfig,
},
};
// 변환된 컴포넌트로 DB 업데이트
onUpdateProperty(selectedComponent.id, "type", "component");
onUpdateProperty(selectedComponent.id, "componentConfig", convertedComponent.componentConfig);
onUpdateProperty(anyComp.id, "type", "component");
onUpdateProperty(anyComp.id, "componentConfig", convertedComponent.componentConfig);
// 변환된 컴포넌트로 처리 계속
selectedComponent = convertedComponent;
selectedComponent = convertedComponent as unknown as ComponentData;
}
// 새로운 컴포넌트 시스템 처리 (type: "component")
@@ -1118,7 +1130,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 세부 타입 변경 핸들러
const handleDetailTypeChange = (newDetailType: string) => {
setLocalComponentDetailType(newDetailType);
onUpdateProperty(selectedComponent.id, "componentConfig.webType", newDetailType);
onUpdateProperty(selectedComponent!.id, "componentConfig.webType", newDetailType);
};
return (
@@ -1148,12 +1160,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</span>
</div>
)}
{selectedComponent.columnName && (
{(selectedComponent as any).columnName && (
<div className="flex items-center space-x-2">
<span className="text-muted-foreground text-xs">
:
</span>
<span className="text-xs text-foreground">{selectedComponent.columnName}</span>
<span className="text-xs text-foreground">{(selectedComponent as any).columnName}</span>
</div>
)}
</div>
@@ -1171,7 +1183,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// console.log("🔍 selectedComponent 전체:", selectedComponent);
return config;
})()}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName}
tableColumns={(() => {
// console.log("🔍 DetailSettingsPanel tableColumns 전달:", {
// currentTable,
@@ -1354,7 +1366,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
const widget = selectedComponent as WidgetComponent;
// 현재 웹타입의 기본 입력 타입 추출
const currentBaseInputType = getBaseInputType(widget.widgetType);
const currentBaseInputType = getBaseInputType(widget.widgetType as any);
// 선택 가능한 세부 타입 목록
const availableDetailTypes = getDetailTypes(currentBaseInputType);
+1 -1
View File
@@ -250,7 +250,7 @@ export const dataApi = {
const requestBody = {
table_name: tableName,
parentKeys,
parent_keys: parentKeys,
records,
delete_orphans: options?.deleteOrphans ?? true, // 기본값: true (기존 동작 유지)
};
+1 -1
View File
@@ -7,7 +7,7 @@ export const checkRelationshipNameDuplicate = async (relationshipName: string, e
const response = await apiClient.get("/dataflow-diagrams", {
params: {
searchTerm: relationshipName,
search_term: relationshipName,
page: 1,
size: 100, // 충분히 큰 수로 설정
},
+2 -2
View File
@@ -146,8 +146,8 @@ export class DynamicFormApi {
const response = await apiClient.patch(`/dynamic-form/${id}/partial`, {
table_name: tableName,
originalData,
newData,
original_data: originalData,
new_data: newData,
});
console.log("✅ 폼 데이터 부분 업데이트 성공:", response.data);
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { ComponentRendererProps } from "../../types";
import { ComponentRendererProps } from "@/types/component";
import { AccordionBasicConfig, AccordionItem, DataSourceConfig, ContentFieldConfig } from "./types";
import { apiClient } from "@/lib/api/client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
@@ -159,7 +159,7 @@ const CustomAccordion: React.FC<CustomAccordionProps> = ({
typeof item.content === "string"
? item.content
: Array.isArray(item.content)
? item.content.join("\n") // 배열인 경우 줄바꿈으로 연결
? (item.content as any).join("\n") // 배열인 경우 줄바꿈으로 연결
: typeof item.content === "object"
? Object.entries(item.content)
.map(([key, value]) => `${key}: ${value}`)
@@ -402,7 +402,7 @@ const useAccordionData = (
console.warn("⚠️ 테이블 API 호출 실패, 실제 화면에서도 더미 데이터로 대체:", apiError);
console.log("📊 테이블 API 오류 상세:", {
targetTableName,
error: apiError.message,
error: (apiError as Error).message,
dataSource,
timestamp: new Date().toISOString(),
});
@@ -464,7 +464,7 @@ const useAccordionData = (
console.warn("⚠️ 엔드포인트 API 호출 실패, 실제 화면에서도 더미 데이터로 대체:", apiError);
console.log("📊 엔드포인트 API 오류 상세:", {
apiEndpoint: dataSource.apiEndpoint,
error: apiError.message,
error: (apiError as Error).message,
dataSource,
timestamp: new Date().toISOString(),
});
@@ -572,7 +572,7 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
const handleClick = (e: React.MouseEvent) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
onClick?.();
}
};
@@ -178,7 +178,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
page: 1,
size: 1,
search: { [statusKeyField]: userId },
autoFilter: true,
auto_filter: true,
});
if (!isMounted) return;
@@ -25,6 +25,7 @@ import {
RepeatScreenModalProps,
CardData,
CardColumnConfig,
CardRowConfig,
GroupedCardData,
CardRowData,
AggregationConfig,
@@ -35,6 +36,7 @@ import {
FooterButtonConfig,
TableDataSourceConfig,
TableCrudConfig,
JoinCondition,
} from "./types";
import { ComponentRendererProps } from "@/types/component";
import { cn } from "@/lib/utils";
@@ -184,7 +186,7 @@ export function RepeatScreenModalComponent({
const cardId = keyParts.slice(0, -1).join("-"); // contentRowId를 제외한 나머지가 cardId
// contentRow 찾기
const contentRow = contentRows.find((r) => key.includes(r.id));
const contentRow = contentRows.find((r: CardContentRowConfig) => key.includes(r.id));
if (!contentRow?.tableDataSource?.enabled) continue;
// 🆕 v3.13: 해당 카드의 대표 데이터 찾기 (joinConditions의 targetKey 값을 가져오기 위해)
@@ -206,11 +208,11 @@ export function RepeatScreenModalComponent({
if (dirtyRows.length === 0) continue;
// 저장할 필드만 추출
const editableFields = (contentRow.tableColumns || []).filter((col) => col.editable).map((col) => col.field);
const editableFields = (contentRow.tableColumns || []).filter((col: TableColumnConfig) => col.editable).map((col: TableColumnConfig) => col.field);
// 🆕 v3.13: joinConditions에서 sourceKey (저장 대상 테이블의 FK 컬럼) 추출
const joinConditions = contentRow.tableDataSource.joinConditions || [];
const joinKeys = joinConditions.map((cond) => cond.sourceKey);
const joinKeys = joinConditions.map((cond: JoinCondition) => cond.sourceKey);
const allowedFields = [...new Set([...editableFields, ...joinKeys])];
@@ -453,7 +455,7 @@ export function RepeatScreenModalComponent({
const loadExternalTableData = async () => {
// contentRows에서 외부 테이블 데이터 소스가 있는 table 타입 행 찾기
const tableRowsWithExternalSource = contentRows.filter(
(row) => row.type === "table" && row.tableDataSource?.enabled,
(row: CardContentRowConfig) => row.type === "table" && row.tableDataSource?.enabled,
);
if (tableRowsWithExternalSource.length === 0) return;
@@ -687,7 +689,7 @@ export function RepeatScreenModalComponent({
if (groupedCardsData.length === 0) return;
// 외부 테이블 집계 또는 formula가 있는지 확인
const hasExternalAggregation = grouping.aggregations.some((agg) => {
const hasExternalAggregation = grouping.aggregations.some((agg: AggregationConfig) => {
const sourceType = agg.sourceType || "column";
if (sourceType === "formula") return true; // formula는 외부 테이블 참조 가능
if (sourceType === "column") {
@@ -701,7 +703,7 @@ export function RepeatScreenModalComponent({
// contentRows에서 외부 테이블 데이터 소스가 있는 모든 table 타입 행 찾기
const tableRowsWithExternalSource = contentRows.filter(
(row) => row.type === "table" && row.tableDataSource?.enabled,
(row: CardContentRowConfig) => row.type === "table" && row.tableDataSource?.enabled,
);
if (tableRowsWithExternalSource.length === 0) return;
@@ -723,7 +725,7 @@ export function RepeatScreenModalComponent({
// 집계 재계산
const newAggregations: Record<string, number> = {};
grouping.aggregations!.forEach((agg) => {
grouping.aggregations!.forEach((agg: AggregationConfig) => {
const sourceType = agg.sourceType || "column";
if (sourceType === "column") {
@@ -864,11 +866,11 @@ export function RepeatScreenModalComponent({
const response = await allocateNumberingCode(rowNumbering.numberingRuleId, userInputCode, newRowData);
if (response.success && response.data) {
newRowData[rowNumbering.targetColumn] = response.data.generated_code;
newRowData[rowNumbering.targetColumn] = response.data.generatedCode;
console.log("[RepeatScreenModal] 자동 채번 완료:", {
column: rowNumbering.targetColumn,
generated_code: response.data.generated_code,
generated_code: response.data.generatedCode,
});
} else {
console.warn("[RepeatScreenModal] 채번 실패:", response);
@@ -940,9 +942,8 @@ export function RepeatScreenModalComponent({
// tableColumns에서 editable: true인 필드만 추가 (읽기 전용 컬럼 제외)
if (contentRow.tableColumns) {
contentRow.tableColumns.forEach((col) => {
// editable이 명시적으로 true이거나, editable이 undefined가 아니고 false가 아닌 경우
// 또는 inputType이 있는 경우 (입력 가능한 컬럼)
if (col.field && (col.editable === true || col.inputType)) {
// editable이 명시적으로 true인 경우 (입력 가능한 컬럼)
if (col.field && col.editable === true) {
allowedFields.add(col.field);
}
});
@@ -961,7 +962,6 @@ export function RepeatScreenModalComponent({
contentRow.tableColumns?.map((c) => ({
field: c.field,
editable: c.editable,
inputType: c.inputType,
})),
);
@@ -1218,7 +1218,7 @@ export function RepeatScreenModalComponent({
// 기존 DB 데이터인 경우 (id가 있는 경우) 즉시 삭제
if (targetRow?._originalData?.id) {
try {
const contentRow = contentRows.find((r) => r.id === contentRowId);
const contentRow = contentRows.find((r: CardContentRowConfig) => r.id === contentRowId);
const targetTable = contentRow?.tableCrud?.targetTable || contentRow?.tableDataSource?.sourceTable;
if (!targetTable) {
@@ -1401,7 +1401,7 @@ export function RepeatScreenModalComponent({
// 1단계: 기본 테이블 컬럼 집계만 (외부 테이블 데이터는 아직 없음)
const aggregations: Record<string, number> = {};
if (groupingConfig.aggregations) {
groupingConfig.aggregations.forEach((agg) => {
groupingConfig.aggregations.forEach((agg: AggregationConfig) => {
const sourceType = agg.sourceType || "column";
if (sourceType === "column") {
@@ -1714,8 +1714,8 @@ export function RepeatScreenModalComponent({
// 집계값 재계산
const newAggregations: Record<string, number> = {};
if (grouping?.aggregations) {
grouping.aggregations.forEach((agg) => {
newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg);
grouping.aggregations.forEach((agg: AggregationConfig) => {
newAggregations[agg.resultField] = calculateAggregation(agg, updatedRows, [], newAggregations, card._representativeData);
});
}
@@ -1735,7 +1735,7 @@ export function RepeatScreenModalComponent({
const matches = title.match(/\{(\w+)\}/g);
if (matches) {
matches.forEach((match) => {
matches.forEach((match: string) => {
const field = match.slice(1, -1);
const value = data[field] || "";
title = title.replace(match, String(value));
@@ -1779,7 +1779,7 @@ export function RepeatScreenModalComponent({
for (const [key, rows] of Object.entries(externalTableData)) {
// key 형식: cardId-contentRowId
const [cardId, contentRowId] = key.split("-").slice(0, 2);
const contentRow = contentRows.find((r) => r.id === contentRowId || key.includes(r.id));
const contentRow = contentRows.find((r: CardContentRowConfig) => r.id === contentRowId || key.includes(r.id));
if (!contentRow?.tableDataSource?.enabled) continue;
@@ -2101,10 +2101,10 @@ export function RepeatScreenModalComponent({
if (isDesignMode) {
// 행 타입별 개수 계산
const rowTypeCounts = {
header: contentRows.filter((r) => r.type === "header").length,
aggregation: contentRows.filter((r) => r.type === "aggregation").length,
table: contentRows.filter((r) => r.type === "table").length,
fields: contentRows.filter((r) => r.type === "fields").length,
header: contentRows.filter((r: CardContentRowConfig) => r.type === "header").length,
aggregation: contentRows.filter((r: CardContentRowConfig) => r.type === "aggregation").length,
table: contentRows.filter((r: CardContentRowConfig) => r.type === "table").length,
fields: contentRows.filter((r: CardContentRowConfig) => r.type === "fields").length,
};
return (
@@ -2263,7 +2263,7 @@ export function RepeatScreenModalComponent({
<CardContent className="space-y-4">
{/* 🆕 v3: contentRows 기반 렌더링 */}
{useNewLayout ? (
contentRows.map((contentRow, rowIndex) => (
contentRows.map((contentRow: CardContentRowConfig, rowIndex: number) => (
<div key={contentRow.id || `crow-${rowIndex}`}>
{contentRow.type === "table" && contentRow.tableDataSource?.enabled ? (
// 🆕 v3.1: 외부 테이블 데이터 소스 사용
@@ -2294,8 +2294,8 @@ export function RepeatScreenModalComponent({
<TableRow className="bg-muted/50">
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || [])
.filter((col) => !col.hidden)
.map((col) => (
.filter((col: TableColumnConfig) => !col.hidden)
.map((col: TableColumnConfig) => (
<TableHead
key={col.id}
style={{ width: col.width }}
@@ -2315,7 +2315,7 @@ export function RepeatScreenModalComponent({
<TableRow>
<TableCell
colSpan={
(contentRow.tableColumns?.filter((col) => !col.hidden)?.length || 0) +
(contentRow.tableColumns?.filter((col: TableColumnConfig) => !col.hidden)?.length || 0) +
(contentRow.tableCrud?.allowDelete ? 1 : 0)
}
className="text-muted-foreground py-8 text-center"
@@ -2335,8 +2335,8 @@ export function RepeatScreenModalComponent({
>
{/* 🆕 v3.13: hidden 컬럼 필터링 */}
{(contentRow.tableColumns || [])
.filter((col) => !col.hidden)
.map((col) => (
.filter((col: TableColumnConfig) => !col.hidden)
.map((col: TableColumnConfig) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn(
@@ -2446,7 +2446,7 @@ export function RepeatScreenModalComponent({
<>
{tableLayout?.headerRows && tableLayout.headerRows.length > 0 && (
<div className="bg-muted/30 space-y-3 rounded-lg p-4">
{tableLayout.headerRows.map((row, rowIndex) => (
{tableLayout.headerRows.map((row: CardRowConfig, rowIndex: number) => (
<div
key={row.id || `hrow-${rowIndex}`}
className={cn(
@@ -2458,7 +2458,7 @@ export function RepeatScreenModalComponent({
)}
style={{ gap: row.gap || "16px" }}
>
{row.columns.map((col, colIndex) => (
{row.columns.map((col: CardColumnConfig, colIndex: number) => (
<div key={col.id || `hcol-${colIndex}`} style={{ width: col.width }}>
{renderHeaderColumn(col, card, grouping?.aggregations || [])}
</div>
@@ -2473,7 +2473,7 @@ export function RepeatScreenModalComponent({
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
{tableLayout.tableColumns.map((col) => (
{tableLayout.tableColumns.map((col: TableColumnConfig) => (
<TableHead
key={col.id}
style={{ width: col.width }}
@@ -2487,7 +2487,7 @@ export function RepeatScreenModalComponent({
<TableBody>
{card._rows.map((row) => (
<TableRow key={row._rowId} className={cn(row._isDirty && "bg-primary/5")}>
{tableLayout.tableColumns.map((col) => (
{tableLayout.tableColumns.map((col: TableColumnConfig) => (
<TableCell
key={`${row._rowId}-${col.id}`}
className={cn("text-sm", col.align && `text-${col.align}`)}
@@ -2525,7 +2525,7 @@ export function RepeatScreenModalComponent({
!footerConfig.alignment && "justify-end",
)}
>
{footerConfig.buttons.map((btn) => (
{footerConfig.buttons.map((btn: FooterButtonConfig) => (
<Button
key={btn.id}
variant={btn.variant || "default"}
@@ -2608,7 +2608,7 @@ export function RepeatScreenModalComponent({
<CardContent className="space-y-4">
{/* 🆕 v3: contentRows 기반 렌더링 */}
{useNewLayout
? contentRows.map((contentRow, rowIndex) => (
? contentRows.map((contentRow: CardContentRowConfig, rowIndex: number) => (
<div key={contentRow.id || `crow-${rowIndex}`}>
{renderSimpleContentRow(contentRow, card, (value, field) =>
handleCardDataChange(card._cardId, field, value),
@@ -2616,7 +2616,7 @@ export function RepeatScreenModalComponent({
</div>
))
: // 레거시: cardLayout 사용
cardLayout.map((row, rowIndex) => (
cardLayout.map((row: CardRowConfig, rowIndex: number) => (
<div
key={row.id || `row-${rowIndex}`}
className={cn(
@@ -2625,7 +2625,7 @@ export function RepeatScreenModalComponent({
)}
style={{ gap: row.gap || "16px" }}
>
{row.columns.map((col, colIndex) => (
{row.columns.map((col: CardColumnConfig, colIndex: number) => (
<div key={col.id || `col-${colIndex}`} style={{ width: col.width }}>
{renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))}
</div>
@@ -102,7 +102,7 @@ export interface CardContentRowConfig {
// === aggregation 타입일 때 ===
aggregationFields?: AggregationDisplayConfig[]; // 표시할 집계 필드들
aggregationLayout?: "horizontal" | "grid"; // 집계 레이아웃 (가로 나열 / 그리드)
aggregationLayout?: "horizontal" | "grid" | "vertical"; // 집계 레이아웃 (가로 나열 / 그리드 / 세로)
aggregationColumns?: number; // grid일 때 컬럼 수 (기본: 4)
// === table 타입일 때 ===
@@ -188,8 +188,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const response = await getCategoryValues(fieldSourceTable, field.name, false);
if (response.success && response.data && response.data.length > 0) {
newOptions[field.name] = response.data.map((item: any) => ({
if (response.success && 'data' in response && response.data && response.data.length > 0) {
newOptions[field.name] = (response.data as any[]).map((item: any) => ({
label: item.value_label,
value: item.value_code,
}));
@@ -588,7 +588,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
sourceData = items[0]?.originalData || {};
}
componentConfig.parentDataMapping.forEach((mapping) => {
componentConfig.parentDataMapping!.forEach((mapping) => {
let value: any;
// 수정 모드: originalData의 targetField 값 우선 사용
@@ -985,6 +985,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (!componentConfig.autoCalculation) return 0;
const { inputFields } = componentConfig.autoCalculation;
if (!inputFields) return 0;
// 기본 단가
const basePrice = parseFloat(entry[inputFields.basePrice] || "0");
@@ -1075,6 +1076,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 가격 관련 필드가 변경되면 자동 계산
if (componentConfig.autoCalculation) {
const { inputFields, targetField } = componentConfig.autoCalculation;
if (!inputFields) return item;
const priceRelatedFields = [
inputFields.basePrice,
inputFields.discountType,
@@ -2064,611 +2066,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
);
};
// 🔧 기존 renderGridLayout (백업 - 사용 안 함)
const renderGridLayout_OLD = () => {
return (
<div className="bg-card space-y-4 p-4">
{/* Modal 모드: 추가 버튼 */}
{isModalMode && !isEditing && items.length === 0 && (
<div className="py-8 text-center">
<p className="text-muted-foreground mb-4 text-sm"> </p>
<Button type="button" onClick={() => setIsEditing(true)} size="sm" className="text-xs sm:text-sm">
+
</Button>
</div>
)}
{/* Modal 모드: 편집 중인 항목 (입력창 표시) */}
{isModalMode &&
isEditing &&
editingItemId &&
(() => {
const editingItem = items.find((item) => item.id === editingItemId);
if (!editingItem) {
return null;
}
return (
<Card className="border-primary border-2 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>
:{" "}
{getFieldValue(editingItem.originalData, componentConfig.displayColumns?.[0]?.name || "") ||
"항목"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(false);
setEditingItemId(null);
setEditingDetailId(null);
}}
className="h-7 text-xs"
>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 원본 데이터 요약 */}
<div className="text-muted-foreground bg-muted rounded p-2 text-xs">
{componentConfig.displayColumns
?.map((col) => editingItem.originalData[col.name])
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 이미 입력된 상세 항목들 표시 */}
{editingItem.details.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-medium"> ({editingItem.details.length})</div>
{editingItem.details.map((detail, idx) => (
<div
key={detail.id}
className="bg-muted/30 flex items-center justify-between rounded border p-2 text-xs"
>
<span>
{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(editingItem.id, detail.id)}
className="h-6 w-6 p-0"
>
X
</Button>
</div>
))}
</div>
)}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields &&
componentConfig.additionalFields.length > 0 &&
editingDetailId &&
(() => {
// 현재 편집 중인 detail 찾기 (없으면 빈 객체)
const currentDetail = editingItem.details.find((d) => d.id === editingDetailId) || {
id: editingDetailId,
};
return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail);
})()}
{/* 액션 버튼들 */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
onClick={() => handleAddDetail(editingItem.id)}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
<Button type="button" variant="default" onClick={handleNextItem} size="sm" className="text-xs">
</Button>
</div>
</CardContent>
</Card>
);
})()}
{/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
{items.map((item, index) => {
// Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
if (isModalMode && isEditing && item.id === editingItemId) {
return null;
}
// Modal 모드: 작은 요약 카드
if (isModalMode) {
return (
<Card key={item.id} className="bg-muted/50 border shadow-sm">
<CardContent className="p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex-1">
<div className="mb-1 text-sm font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(true);
setEditingItemId(item.id);
const newDetailId = `detail-${Date.now()}`;
setEditingDetailId(newDetailId);
}}
className="h-7 text-xs text-amber-600"
>
</Button>
{componentConfig.allowRemove && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(item.id)}
className="h-7 w-7 text-destructive"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
{/* 🆕 입력된 상세 항목들 표시 */}
{item.details && item.details.length > 0 && (
<div className="border-primary mt-2 space-y-1 border-l-2 pl-4">
{item.details.map((detail, detailIdx) => (
<div key={detail.id} className="text-primary text-xs">
{detailIdx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// Inline 모드: 각 품목마다 여러 상세 항목 표시
return (
<Card key={item.id} className="border shadow-sm">
<CardContent className="space-y-3 p-4">
{/* 제목 (품명) */}
<div className="flex items-center justify-between">
<div className="text-base font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<Button
type="button"
onClick={() => {
const newDetailId = `detail-${Date.now()}`;
handleAddDetail(item.id);
}}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
</div>
{/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 각 상세 항목 표시 */}
{item.details && item.details.length > 0 ? (
<div className="border-primary space-y-3 border-l-2 pl-4">
{item.details.map((detail, detailIdx) => (
<Card key={detail.id} className="border-dashed">
<CardContent className="space-y-2 p-3">
<div className="flex items-center justify-between">
<div className="text-xs font-medium"> {detailIdx + 1}</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(item.id, detail.id)}
className="h-6 w-6 p-0 text-destructive"
>
X
</Button>
</div>
{/* 입력 필드들 */}
{renderFieldsByGroup(item.id, detail.id, detail)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-muted-foreground pl-4 text-xs italic"> .</div>
)}
</CardContent>
</Card>
);
})}
{/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
{isModalMode && !isEditing && items.length > 0 && (
<Button
type="button"
onClick={() => {
// 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
setIsEditing(true);
setEditingItemId(items[0]?.id || null);
}}
variant="outline"
size="sm"
className="w-full border-dashed text-xs sm:text-sm"
>
+
</Button>
)}
</div>
);
};
// 기존 테이블 레이아웃 (사용 안 함, 삭제 예정)
const renderOldGridLayout = () => {
return (
<div className="bg-card overflow-auto">
<Table>
<TableHeader>
<TableRow className="bg-background">
{componentConfig.showIndex && (
<TableHead className="h-12 w-12 px-4 py-3 text-center text-xs font-semibold sm:text-sm">#</TableHead>
)}
{/* 원본 데이터 컬럼 */}
{componentConfig.displayColumns?.map((col) => (
<TableHead key={col.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{col.label || col.name}
</TableHead>
))}
{/* 추가 입력 필드 컬럼 */}
{componentConfig.additionalFields?.map((field) => (
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{field.label}{(field.required || isColumnRequiredByMeta(componentConfig.targetTable, field.name)) && <span className="text-orange-500">*</span>}
</TableHead>
))}
{componentConfig.allowRemove && (
<TableHead className="h-12 w-20 px-4 py-3 text-center text-xs font-semibold sm:text-sm"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id} className="bg-background hover:bg-muted/50 transition-colors">
{/* 인덱스 번호 */}
{componentConfig.showIndex && (
<TableCell className="h-14 px-4 py-3 text-center text-xs font-medium sm:text-sm">
{index + 1}
</TableCell>
)}
{/* 원본 데이터 표시 */}
{componentConfig.displayColumns?.map((col) => (
<TableCell key={col.name} className="h-14 px-4 py-3 text-xs sm:text-sm">
{getFieldValue(item.originalData, col.name) || "-"}
</TableCell>
))}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields?.map((field) => (
<TableCell key={field.name} className="h-14 px-4 py-3">
{renderField(field, item)}
</TableCell>
))}
{/* 삭제 버튼 */}
{componentConfig.allowRemove && (
<TableCell className="h-14 px-4 py-3 text-center">
{!componentConfig.disabled && !componentConfig.readonly && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(item.id)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 w-7 sm:h-8 sm:w-8"
title="항목 제거"
>
<X className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
// 🆕 Card 레이아웃 렌더링 (Grid와 동일)
const renderCardLayout = () => {
return renderGridLayout();
};
// 🔧 기존 renderCardLayout (백업 - 사용 안 함)
const renderCardLayout_OLD = () => {
const isModalMode = componentConfig.inputMode === "modal";
return (
<div className="bg-card space-y-3 p-4">
{/* Modal 모드: 추가 버튼 */}
{isModalMode && !isEditing && items.length === 0 && (
<div className="py-8 text-center">
<p className="text-muted-foreground mb-4 text-sm"> </p>
<Button type="button" onClick={() => setIsEditing(true)} size="sm" className="text-xs sm:text-sm">
+
</Button>
</div>
)}
{/* Modal 모드: 편집 중인 항목 (입력창 표시) */}
{isModalMode &&
isEditing &&
editingItemId &&
(() => {
const editingItem = items.find((item) => item.id === editingItemId);
if (!editingItem) {
return null;
}
return (
<Card className="border-primary border-2 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>
:{" "}
{getFieldValue(editingItem.originalData, componentConfig.displayColumns?.[0]?.name || "") ||
"항목"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(false);
setEditingItemId(null);
setEditingDetailId(null);
}}
className="h-7 text-xs"
>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 원본 데이터 요약 */}
<div className="text-muted-foreground bg-muted rounded p-2 text-xs">
{componentConfig.displayColumns
?.map((col) => editingItem.originalData[col.name])
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 이미 입력된 상세 항목들 표시 */}
{editingItem.details.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-medium"> ({editingItem.details.length})</div>
{editingItem.details.map((detail, idx) => (
<div
key={detail.id}
className="bg-muted/30 flex items-center justify-between rounded border p-2 text-xs"
>
<span>
{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(editingItem.id, detail.id)}
className="h-6 w-6 p-0"
>
X
</Button>
</div>
))}
</div>
)}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields &&
componentConfig.additionalFields.length > 0 &&
editingDetailId &&
(() => {
// 현재 편집 중인 detail 찾기 (없으면 빈 객체)
const currentDetail = editingItem.details.find((d) => d.id === editingDetailId) || {
id: editingDetailId,
};
return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail);
})()}
{/* 액션 버튼들 */}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
onClick={() => handleAddDetail(editingItem.id)}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
<Button type="button" variant="default" onClick={handleNextItem} size="sm" className="text-xs">
</Button>
</div>
</CardContent>
</Card>
);
})()}
{/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
{items.map((item, index) => {
// Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
if (isModalMode && isEditing && item.id === editingItemId) {
return null;
}
// Modal 모드: 작은 요약 카드
if (isModalMode) {
return (
<Card key={item.id} className="bg-muted/50 border shadow-sm">
<CardContent className="flex items-center justify-between p-3">
<div className="flex-1">
<div className="mb-1 text-sm font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsEditing(true);
setEditingItemId(item.id);
}}
className="h-7 text-xs text-amber-600"
>
</Button>
{componentConfig.allowRemove && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveItem(item.id)}
className="text-destructive h-7 text-xs"
>
X
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// Inline 모드: 각 품목마다 여러 상세 항목 표시
return (
<Card key={item.id} className="border shadow-sm">
<CardContent className="space-y-3 p-4">
{/* 제목 (품명) */}
<div className="flex items-center justify-between">
<div className="text-base font-semibold">
{index + 1}.{" "}
{getFieldValue(item.originalData, componentConfig.displayColumns?.[0]?.name || "") || "항목"}
</div>
<Button
type="button"
onClick={() => {
const newDetailId = `detail-${Date.now()}`;
handleAddDetail(item.id);
}}
size="sm"
variant="outline"
className="text-xs"
>
+
</Button>
</div>
{/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
<div className="text-muted-foreground text-xs">
{componentConfig.displayColumns
?.map((col) => getFieldValue(item.originalData, col.name))
.filter(Boolean)
.join(" | ")}
</div>
{/* 🆕 각 상세 항목 표시 */}
{item.details && item.details.length > 0 ? (
<div className="border-primary space-y-3 border-l-2 pl-4">
{item.details.map((detail, detailIdx) => (
<Card key={detail.id} className="border-dashed">
<CardContent className="space-y-2 p-3">
<div className="flex items-center justify-between">
<div className="text-xs font-medium"> {detailIdx + 1}</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveDetail(item.id, detail.id)}
className="h-6 w-6 p-0 text-destructive"
>
X
</Button>
</div>
{/* 입력 필드들 */}
{renderFieldsByGroup(item.id, detail.id, detail)}
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-muted-foreground pl-4 text-xs italic"> .</div>
)}
</CardContent>
</Card>
);
})}
{/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
{isModalMode && !isEditing && items.length > 0 && (
<Button
type="button"
onClick={() => {
// 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
setIsEditing(true);
setEditingItemId(items[0]?.id || null);
}}
variant="outline"
size="sm"
className="w-full border-dashed text-xs sm:text-sm"
>
+
</Button>
)}
</div>
);
};
return (
<div style={componentStyle} className={cn("space-y-4", className)} onClick={handleClick}>
{/* 레이아웃에 따라 렌더링 */}
@@ -978,9 +978,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// repeater 계열 우선 탐색
for (const [id, receiver] of allReceivers) {
if (
receiver.componentType === "repeater-field-group" ||
receiver.componentType === "v2-repeater" ||
receiver.componentType === "repeater"
(receiver.componentType as string) === "repeater-field-group" ||
(receiver.componentType as string) === "v2-repeater" ||
(receiver.componentType as string) === "repeater"
) {
effectiveTargetComponentId = id;
console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`);
@@ -991,7 +991,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용
if (!effectiveTargetComponentId) {
for (const [id, receiver] of allReceivers) {
if (receiver.componentType === "table-list" || receiver.componentType === "data-table") {
if ((receiver.componentType as string) === "table-list" || (receiver.componentType as string) === "data-table") {
effectiveTargetComponentId = id;
console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`);
break;
@@ -1289,7 +1289,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
splitPanelContext: splitPanelContext
? {
selectedLeftData: splitPanelContext.selectedLeftData,
refreshRightPanel: splitPanelContext.refreshRightPanel,
}
: undefined,
} as ButtonActionContext;
@@ -162,7 +162,7 @@ function SortableColumnRow({
</PopoverContent>
</Popover>
) : isEntityJoin ? (
<Link2 className="text-primary h-3 w-3 shrink-0" title="Entity 조인 컬럼" />
<Link2 className="text-primary h-3 w-3 shrink-0" aria-label="Entity 조인 컬럼" />
) : (
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
)}
@@ -353,7 +353,7 @@ const ScreenSelector: React.FC<{
const { screenApi } = await import("@/lib/api/screen");
const response = await screenApi.getScreens({ page: 1, size: 1000 });
setScreens(
response.data.map((s) => ({ screen_id: s.screen_id, screen_name: s.screen_name, screen_code: s.screen_code })),
response.data.map((s) => ({ screen_id: s.screen_id!, screen_name: s.screen_name!, screen_code: s.screen_code! })),
);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
@@ -546,16 +546,16 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<CommandGroup className="max-h-[200px] overflow-auto">
{availableRightTables.map((table) => (
<CommandItem
key={table.table_name}
value={`${table.display_name || ""} ${table.table_name}`}
onSelect={() => updateTab({ tableName: table.table_name, columns: [] })}
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
>
<Check
className={cn("mr-2 h-4 w-4", tab.tableName === table.table_name ? "opacity-100" : "opacity-0")}
className={cn("mr-2 h-4 w-4", tab.tableName === table.tableName ? "opacity-100" : "opacity-0")}
/>
{table.display_name || table.table_name}
{table.display_name && (
<span className="text-muted-foreground ml-2 text-xs">({table.table_name})</span>
{table.displayName || table.tableName}
{table.displayName && (
<span className="text-muted-foreground ml-2 text-xs">({table.tableName})</span>
)}
</CommandItem>
))}
@@ -656,7 +656,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</SelectItem>
{leftTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -689,7 +689,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</SelectItem>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -862,11 +862,11 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
}}
availableChildColumns={tabColumns.map((c) => ({
columnName: c.columnName,
columnLabel: c.columnLabel || c.columnName,
columnLabel: c.displayName || c.columnName,
}))}
availableParentColumns={leftTableColumns.map((c) => ({
columnName: c.columnName,
columnLabel: c.columnLabel || c.columnName,
columnLabel: c.displayName || c.columnName,
}))}
/>
);
@@ -891,14 +891,14 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
updateTab({
columns: [
...selectedColumns,
{ name: column.columnName, label: column.columnLabel || column.columnName, width: 20 },
{ name: column.columnName, label: column.displayName || column.columnName, width: 20 },
],
});
}}
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</span>
</div>
))}
@@ -939,7 +939,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -962,7 +962,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<SelectContent>
{leftTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -1077,7 +1077,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<Link2 className="text-primary h-3 w-3 shrink-0" />
<span className="text-primary truncate text-xs">
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</span>
</div>
);
@@ -1136,7 +1136,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
newColumns[colIndex] = {
...col,
name: value,
label: selectedCol?.columnLabel || value,
label: selectedCol?.displayName || value,
};
updateTab({ addModalColumns: newColumns });
}}
@@ -1147,7 +1147,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<SelectContent>
{tabColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</SelectItem>
))}
</SelectContent>
@@ -1245,7 +1245,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -1267,7 +1267,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
<SelectContent>
{tabColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -1344,7 +1344,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
value={tab.editButton?.buttonLabel || ""}
onChange={(e) => {
updateTab({
editButton: { ...tab.editButton, enabled: true, buttonLabel: e.target.value || undefined },
editButton: { ...tab.editButton, mode: tab.editButton?.mode || "auto", enabled: true, buttonLabel: e.target.value || undefined },
});
}}
placeholder="수정"
@@ -1357,7 +1357,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
value={tab.editButton?.buttonVariant || "ghost"}
onValueChange={(value: "default" | "outline" | "ghost") => {
updateTab({
editButton: { ...tab.editButton, enabled: true, buttonVariant: value },
editButton: { ...tab.editButton, mode: tab.editButton?.mode || "auto", enabled: true, buttonVariant: value },
});
}}
>
@@ -1389,12 +1389,12 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
? [...current, col.columnName]
: current.filter((c) => c !== col.columnName);
updateTab({
editButton: { ...tab.editButton, enabled: true, groupByColumns: newColumns },
editButton: { ...tab.editButton, mode: tab.editButton?.mode || "auto", enabled: true, groupByColumns: newColumns },
});
}}
/>
<label htmlFor={`tab-${tabIndex}-groupby-${col.columnName}`} className="text-[10px]">
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</label>
</div>
))}
@@ -1449,7 +1449,7 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
value={tab.addButton?.buttonLabel || ""}
onChange={(e) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, buttonLabel: e.target.value || undefined },
addButton: { ...tab.addButton, mode: tab.addButton?.mode || "auto", enabled: true, buttonLabel: e.target.value || undefined },
});
}}
placeholder="추가"
@@ -1644,10 +1644,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
const columnsResponse = await tableTypeApi.getColumns(tableName);
console.log(`📊 테이블 ${tableName} 컬럼 응답:`, columnsResponse);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => ({
const columns = (columnsResponse || []).map((col: any) => ({
tableName: col.table_name || tableName,
columnName: col.column_name,
columnLabel: col.display_name || col.column_label || col.column_name,
displayName: col.display_name || col.column_label || col.column_name,
dataType: col.data_type,
webType: col.web_type,
input_type: col.input_type,
@@ -1662,7 +1662,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
referenceTable: col.reference_table, // 🆕 참조 테이블
referenceColumn: col.reference_column, // 🆕 참조 컬럼
displayColumn: col.display_column, // 🆕 표시 컬럼
}));
})) as ColumnInfo[];
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
@@ -1732,13 +1732,13 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
try {
const refColumnsResponse = await tableTypeApi.getColumns(refTableName);
const refColumns: ColumnInfo[] = (refColumnsResponse || []).map((col: any) => ({
const refColumns = (refColumnsResponse || []).map((col: any) => ({
tableName: col.table_name || refTableName,
columnName: col.column_name,
columnLabel: col.display_name || col.column_label || col.column_name,
displayName: col.display_name || col.column_label || col.column_name,
dataType: col.data_type,
input_type: col.input_type,
}));
})) as ColumnInfo[];
referenceTableData.push({ tableName: refTableName, columns: refColumns });
console.log(` ✅ 참조 테이블 ${refTableName} 컬럼 ${refColumns.length}개 로드됨`);
@@ -1776,8 +1776,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
// 🆕 좌측/우측 테이블이 모두 선택되면 엔티티 관계 자동 감지
const [autoDetectedRelations, setAutoDetectedRelations] = useState<
Array<{
left_column: string;
right_column: string;
leftColumn: string;
rightColumn: string;
direction: "left_to_right" | "right_to_left";
inputType: string;
displayColumn?: string;
@@ -1815,11 +1815,10 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
relation: {
...config.rightPanel?.relation,
type: "join",
useMultipleKeys: true,
keys: [
{
leftColumn: firstRel.left_column,
rightColumn: firstRel.right_column,
leftColumn: firstRel.leftColumn,
rightColumn: firstRel.rightColumn,
},
],
},
@@ -2398,7 +2397,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
...selectedColumns,
{
name: column.columnName,
label: column.columnLabel || column.columnName,
label: column.displayName || column.columnName,
width: 20,
},
],
@@ -2407,7 +2406,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</span>
</div>
))}
@@ -2488,7 +2487,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
/>
<Link2 className="text-primary h-3 w-3 shrink-0" />
<span className="text-primary truncate text-xs">
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</span>
</div>
);
@@ -2521,7 +2520,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
(col) =>
({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
}) as any,
@@ -3076,7 +3075,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
...selectedColumns,
{
name: column.columnName,
label: column.columnLabel || column.columnName,
label: column.displayName || column.columnName,
width: 20,
},
],
@@ -3085,7 +3084,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
>
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
<span className="text-muted-foreground truncate text-xs">
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</span>
</div>
))}
@@ -3163,7 +3162,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
/>
<Link2 className="text-primary h-3 w-3 shrink-0" />
<span className="text-primary truncate text-xs">
{column.columnLabel || column.columnName}
{column.displayName || column.columnName}
</span>
</div>
);
@@ -3198,7 +3197,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
(col) =>
({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
}) as any,
@@ -3254,7 +3253,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<SelectContent>
{rightTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -3323,7 +3322,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
@@ -111,7 +111,7 @@ function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] {
if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings;
// 레거시 호환: 기존 statusValues 객체가 있으면 변환
const sv = (src as Record<string, unknown>).statusValues as Record<string, string> | undefined;
const sv = (src as unknown as Record<string, unknown>).statusValues as Record<string, string> | undefined;
return [
{ dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const },
{ dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const },
@@ -428,7 +428,7 @@ export function PopCardListV2Component({
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
[VIRTUAL_SUB_PROCESS]: matched.processName,
[VIRTUAL_SUB_SEQ]: matched.seqNo,
};
} as RowData;
})
.filter((row): row is RowData => row !== null);
@@ -600,7 +600,7 @@ export function PopCardListV2Component({
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
[VIRTUAL_SUB_PROCESS]: matched.processName,
[VIRTUAL_SUB_SEQ]: matched.seqNo,
};
} as RowData;
})
.filter((row): row is RowData => row !== null);
@@ -323,7 +323,7 @@ function getConnectedComponentInfo(
}
if (Array.isArray(compCfg.listColumns)) {
for (const lc of compCfg.listColumns) {
if (lc.columnName) displayedColumns.add(lc.columnName);
if (lc.column_name) displayedColumns.add(lc.column_name);
}
}
}
@@ -357,8 +357,8 @@ function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode,
for (const res of results) {
if (res.success && res.data?.columns) {
for (const col of res.data.columns) {
if (!seen.has(col.columnName)) {
seen.add(col.columnName);
if (!seen.has(col.column_name)) {
seen.add(col.column_name);
allCols.push(col);
}
}
@@ -380,7 +380,7 @@ function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode,
const displayed: ColumnTypeInfo[] = [];
const others: ColumnTypeInfo[] = [];
for (const col of targetColumns) {
if (connInfo.displayedColumns.has(col.columnName)) {
if (connInfo.displayedColumns.has(col.column_name)) {
displayed.push(col);
} else {
others.push(col);
@@ -406,15 +406,15 @@ function FilterConnectionSection({ cfg, update, showFieldName, fixedFilterMode,
};
const renderColumnCheckbox = (col: ColumnTypeInfo) => (
<div key={col.columnName} className="flex items-center gap-2">
<div key={col.column_name} className="flex items-center gap-2">
<Checkbox
id={`filter_col_${col.columnName}`}
checked={selectedFilterCols.includes(col.columnName)}
onCheckedChange={() => toggleFilterColumn(col.columnName)}
id={`filter_col_${col.column_name}`}
checked={selectedFilterCols.includes(col.column_name)}
onCheckedChange={() => toggleFilterColumn(col.column_name)}
/>
<Label htmlFor={`filter_col_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
<Label htmlFor={`filter_col_${col.column_name}`} className="text-[10px]">
{col.display_name || col.column_name}
<span className="ml-1 text-muted-foreground">({col.column_name})</span>
</Label>
</div>
);
@@ -837,7 +837,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
className="h-8 w-full justify-between text-xs"
>
{mc.tableName
? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
? tables.find((t) => t.table_name === mc.tableName)?.displayName || mc.tableName
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
@@ -850,11 +850,11 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
<CommandGroup>
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.displayName || ""} ${t.tableName}`}
key={t.table_name}
value={`${t.display_name || ""} ${t.table_name}`}
onSelect={() => {
updateModal({
tableName: t.tableName,
tableName: t.table_name,
displayColumns: [],
searchColumns: [],
displayField: "",
@@ -868,13 +868,13 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
<Check
className={cn(
"mr-2 h-3 w-3",
mc.tableName === t.tableName ? "opacity-100" : "opacity-0"
mc.tableName === t.table_name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
{t.displayName && t.displayName !== t.tableName && (
<span className="text-[9px] text-muted-foreground">{t.tableName}</span>
<span className="font-medium">{t.display_name || t.table_name}</span>
{t.display_name && t.display_name !== t.table_name && (
<span className="text-[9px] text-muted-foreground">{t.table_name}</span>
)}
</div>
</CommandItem>
@@ -900,15 +900,15 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
) : (
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{columns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<div key={col.column_name} className="flex items-center gap-2">
<Checkbox
id={`disp_${col.columnName}`}
checked={mc.displayColumns?.includes(col.columnName) ?? false}
onCheckedChange={() => toggleArrayItem("displayColumns", col.columnName)}
id={`disp_${col.column_name}`}
checked={mc.displayColumns?.includes(col.column_name) ?? false}
onCheckedChange={() => toggleArrayItem("displayColumns", col.column_name)}
/>
<Label htmlFor={`disp_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
<Label htmlFor={`disp_${col.column_name}`} className="text-[10px]">
{col.display_name || col.column_name}
<span className="ml-1 text-muted-foreground">({col.column_name})</span>
</Label>
</div>
))}
@@ -922,7 +922,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
<Label className="text-[10px]"> </Label>
<div className="space-y-1 rounded border p-2">
{selectedDisplayCols.map((colName) => {
const colInfo = columns.find((c) => c.columnName === colName);
const colInfo = columns.find((c) => c.column_name === colName);
const defaultLabel = colInfo?.displayName || colName;
return (
<div key={colName} className="flex items-center gap-2">
@@ -950,15 +950,15 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
<Label className="text-[10px]"> </Label>
<div className="max-h-24 space-y-1 overflow-y-auto rounded border p-2">
{columns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2">
<div key={col.column_name} className="flex items-center gap-2">
<Checkbox
id={`search_${col.columnName}`}
checked={mc.searchColumns?.includes(col.columnName) ?? false}
onCheckedChange={() => toggleArrayItem("searchColumns", col.columnName)}
id={`search_${col.column_name}`}
checked={mc.searchColumns?.includes(col.column_name) ?? false}
onCheckedChange={() => toggleArrayItem("searchColumns", col.column_name)}
/>
<Label htmlFor={`search_${col.columnName}`} className="text-[10px]">
{col.displayName || col.columnName}
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
<Label htmlFor={`search_${col.column_name}`} className="text-[10px]">
{col.display_name || col.column_name}
<span className="ml-1 text-muted-foreground">({col.column_name})</span>
</Label>
</div>
))}
@@ -1034,8 +1034,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
<SelectContent>
<SelectItem value="__none__" className="text-xs text-muted-foreground"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.displayName || col.columnName} ({col.columnName})
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
{col.display_name || col.column_name} ({col.column_name})
</SelectItem>
))}
</SelectContent>
@@ -1058,8 +1058,8 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
<SelectContent>
<SelectItem value="__none__" className="text-xs text-muted-foreground"> </SelectItem>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.displayName || col.columnName} ({col.columnName})
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
{col.display_name || col.column_name} ({col.column_name})
</SelectItem>
))}
</SelectContent>
@@ -10,7 +10,7 @@
import { optimizedButtonDataflowService } from "../optimizedButtonDataflowService";
import { dataflowConfigCache } from "../dataflowCache";
import { dataflowJobQueue } from "../dataflowJobQueue";
import { ButtonActionType, ButtonTypeConfig } from "@/types/screen";
import { ButtonActionType, ExtendedButtonTypeConfig } from "@/types/screen";
// Mock API client
jest.mock("@/lib/api/client", () => ({
@@ -22,6 +22,17 @@ jest.mock("@/lib/api/client", () => ({
}));
describe("🔥 Button Dataflow Performance Tests", () => {
const mockButtonConfig: ExtendedButtonTypeConfig = {
actionType: "save" as ButtonActionType,
enableDataflowControl: true,
dataflowTiming: "after",
dataflowConfig: {
controlMode: "relationship",
selectedDiagramId: 1,
selectedRelationshipId: 1,
},
};
beforeEach(() => {
// 캐시 초기화
dataflowConfigCache.clearAllCache();
@@ -88,17 +99,6 @@ describe("🔥 Button Dataflow Performance Tests", () => {
});
describe("⚡ Button Execution Performance", () => {
const mockButtonConfig: ButtonTypeConfig = {
actionType: "save" as ButtonActionType,
enableDataflowControl: true,
dataflowTiming: "after",
dataflowConfig: {
controlMode: "simple",
selectedDiagramId: 1,
selectedRelationshipId: "rel-123",
},
};
it("should execute button action in under 200ms", async () => {
const startTime = performance.now();
@@ -145,10 +145,8 @@ describe("🔥 Button Dataflow Performance Tests", () => {
...mockButtonConfig,
dataflowTiming: "before" as const,
dataflowConfig: {
controlMode: "advanced" as const,
controlMode: "relationship" as const,
directControl: {
sourceTable: "test_table",
triggerType: "insert" as const,
conditions: [
{
id: "cond1",
@@ -72,7 +72,7 @@ export class OptimizedButtonDataflowService {
static async executeButtonWithDataflow(
buttonId: string,
actionType: ButtonActionType,
buttonConfig: ExtendedExtendedButtonTypeConfig,
buttonConfig: ExtendedButtonTypeConfig,
contextData: Record<string, any>,
companyCode: string,
): Promise<OptimizedExecutionResult> {
+33 -42
View File
@@ -334,7 +334,7 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
node.id === id
? {
...node,
data: { ...node.data, ...data },
data: { ...node.data, ...data } as FlowNode["data"],
}
: node,
),
@@ -740,7 +740,7 @@ function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): Validation
if (!hasIncoming && !hasOutgoing && !isSourceNode(node.type)) {
errors.push({
nodeId: node.id,
message: `노드 "${node.data.displayName || node.id}"가 연결되어 있지 않습니다.`,
message: `노드 "${(node.data as any).displayName || node.id}"가 연결되어 있지 않습니다.`,
severity: "warning",
});
}
@@ -752,7 +752,7 @@ function performFlowValidation(nodes: FlowNode[], edges: FlowEdge[]): Validation
if (!hasInput) {
errors.push({
nodeId: node.id,
message: `액션 노드 "${node.data.displayName || node.id}"에 입력 데이터가 없습니다.`,
message: `액션 노드 "${(node.data as any).displayName || node.id}"에 입력 데이터가 없습니다.`,
severity: "error",
});
}
@@ -842,7 +842,7 @@ function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
const cycle = path.slice(cycleStart).concat(neighbor);
const nodeNames = cycle.map((id) => {
const node = nodes.find((n) => n.id === id);
return node?.data.displayName || id;
return (node?.data as any)?.displayName || id;
});
cycles.push(nodeNames);
return true;
@@ -868,47 +868,48 @@ function detectCycles(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
*/
function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
const errors: ValidationResult["errors"] = [];
const data = node.data as any;
switch (node.type) {
case "tableSource":
if (!node.data.tableName || node.data.tableName.trim() === "") {
if (!data.tableName || data.tableName.trim() === "") {
errors.push({
nodeId: node.id,
message: `테이블 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
message: `테이블 소스 노드 "${data.displayName || node.id}": 테이블명이 필요합니다.`,
severity: "error",
});
}
break;
case "externalDBSource":
if (!node.data.connectionName || node.data.connectionName.trim() === "") {
if (!data.connectionName || data.connectionName.trim() === "") {
errors.push({
nodeId: node.id,
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 연결 이름이 필요합니다.`,
message: `외부 DB 소스 노드 "${data.displayName || node.id}": 연결 이름이 필요합니다.`,
severity: "error",
});
}
if (!node.data.tableName || node.data.tableName.trim() === "") {
if (!data.tableName || data.tableName.trim() === "") {
errors.push({
nodeId: node.id,
message: `외부 DB 소스 노드 "${node.data.displayName || node.id}": 테이블명이 필요합니다.`,
message: `외부 DB 소스 노드 "${data.displayName || node.id}": 테이블명이 필요합니다.`,
severity: "error",
});
}
break;
case "restAPISource":
if (!node.data.url || node.data.url.trim() === "") {
if (!data.url || data.url.trim() === "") {
errors.push({
nodeId: node.id,
message: `REST API 소스 노드 "${node.data.displayName || node.id}": URL이 필요합니다.`,
message: `REST API 소스 노드 "${data.displayName || node.id}": URL이 필요합니다.`,
severity: "error",
});
}
if (!node.data.method) {
if (!data.method) {
errors.push({
nodeId: node.id,
message: `REST API 소스 노드 "${node.data.displayName || node.id}": HTTP 메서드가 필요합니다.`,
message: `REST API 소스 노드 "${data.displayName || node.id}": HTTP 메서드가 필요합니다.`,
severity: "error",
});
}
@@ -917,27 +918,27 @@ function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
case "insertAction":
case "updateAction":
case "deleteAction":
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
if (!data.targetTable || data.targetTable.trim() === "") {
errors.push({
nodeId: node.id,
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
message: `${getActionTypeName(node.type)} 노드 "${data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
severity: "error",
});
}
if (node.type === "insertAction" || node.type === "updateAction") {
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
if (!data.fieldMappings || data.fieldMappings.length === 0) {
errors.push({
nodeId: node.id,
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
message: `${getActionTypeName(node.type)} 노드 "${data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
severity: "error",
});
}
}
if (node.type === "updateAction" || node.type === "deleteAction") {
if (!node.data.whereConditions || node.data.whereConditions.length === 0) {
if (!data.whereConditions || data.whereConditions.length === 0) {
errors.push({
nodeId: node.id,
message: `${getActionTypeName(node.type)} 노드 "${node.data.displayName || node.id}": WHERE 조건이 필요합니다.`,
message: `${getActionTypeName(node.type)} 노드 "${data.displayName || node.id}": WHERE 조건이 필요합니다.`,
severity: "error",
});
}
@@ -945,64 +946,54 @@ function validateNodeProperties(node: FlowNode): ValidationResult["errors"] {
break;
case "upsertAction":
if (!node.data.targetTable || node.data.targetTable.trim() === "") {
if (!data.targetTable || data.targetTable.trim() === "") {
errors.push({
nodeId: node.id,
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
message: `UPSERT 액션 노드 "${data.displayName || node.id}": 타겟 테이블이 필요합니다.`,
severity: "error",
});
}
if (!node.data.conflictKeys || node.data.conflictKeys.length === 0) {
if (!data.conflictKeys || data.conflictKeys.length === 0) {
errors.push({
nodeId: node.id,
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`,
message: `UPSERT 액션 노드 "${data.displayName || node.id}": 충돌 키(ON CONFLICT)가 필요합니다.`,
severity: "error",
});
}
if (!node.data.fieldMappings || node.data.fieldMappings.length === 0) {
if (!data.fieldMappings || data.fieldMappings.length === 0) {
errors.push({
nodeId: node.id,
message: `UPSERT 액션 노드 "${node.data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
message: `UPSERT 액션 노드 "${data.displayName || node.id}": 최소 하나의 필드 매핑이 필요합니다.`,
severity: "error",
});
}
break;
case "condition":
if (!node.data.conditions || node.data.conditions.length === 0) {
if (!data.conditions || data.conditions.length === 0) {
errors.push({
nodeId: node.id,
message: `조건 노드 "${node.data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`,
message: `조건 노드 "${data.displayName || node.id}": 최소 하나의 조건이 필요합니다.`,
severity: "error",
});
}
break;
case "fieldMapping":
if (!node.data.mappings || node.data.mappings.length === 0) {
errors.push({
nodeId: node.id,
message: `필드 매핑 노드 "${node.data.displayName || node.id}": 최소 하나의 매핑이 필요합니다.`,
severity: "warning",
});
}
break;
case "dataTransform":
if (!node.data.transformations || node.data.transformations.length === 0) {
if (!data.transformations || data.transformations.length === 0) {
errors.push({
nodeId: node.id,
message: `데이터 변환 노드 "${node.data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`,
message: `데이터 변환 노드 "${data.displayName || node.id}": 최소 하나의 변환 규칙이 필요합니다.`,
severity: "warning",
});
}
break;
case "log":
if (!node.data.message || node.data.message.trim() === "") {
if (!data.message || data.message.trim() === "") {
errors.push({
nodeId: node.id,
message: `로그 노드 "${node.data.displayName || node.id}": 로그 메시지가 필요합니다.`,
message: `로그 노드 "${data.displayName || node.id}": 로그 메시지가 필요합니다.`,
severity: "warning",
});
}
+34 -33
View File
@@ -97,6 +97,9 @@ export interface ButtonActionConfig {
dataSourceId?: string; // modalDataStore에서 데이터를 가져올 ID
fieldMappings?: Array<{ sourceField: string; targetField: string }>; // 필드 매핑
// 버튼 라벨
label?: string;
// 확인 메시지
confirmMessage?: string;
successMessage?: string;
@@ -1194,7 +1197,7 @@ export class ButtonActionExecutor {
if (Object.keys(rawSplitPanelData).length > 0) {
}
const dataWithUserInfo = {
const dataWithUserInfo: Record<string, any> = {
...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
@@ -1741,7 +1744,7 @@ export class ButtonActionExecutor {
if (!saveResult.success) {
const errorMsg =
saveResult.message ||
saveResult.error?.message ||
(saveResult as any).error?.message ||
"저장에 실패했습니다.";
toast.error(errorMsg);
return false;
@@ -1865,7 +1868,7 @@ export class ButtonActionExecutor {
}
// 메인 폼 데이터 구성 (사용자 정보 포함)
const mainFormData = {
const mainFormData: Record<string, any> = {
...formData,
writer: formData.writer || context.userId,
created_by: context.userId,
@@ -2105,7 +2108,7 @@ export class ButtonActionExecutor {
}
const existingResponse = await DynamicFormApi.getTableData(tableName, {
search: searchParams,
search: searchParams as any,
page: 1,
pageSize: 1000,
});
@@ -2187,7 +2190,7 @@ export class ButtonActionExecutor {
successCount++;
} else {
errorCount++;
const errorMsg = result.message || result.error || "알 수 없는 오류";
const errorMsg = result.message || (result as any).error || "알 수 없는 오류";
errors.push(errorMsg);
console.error(`❌ [handleRackStructureBatchSave] 저장 실패 (${i + 1}):`, errorMsg);
}
@@ -2456,7 +2459,7 @@ export class ButtonActionExecutor {
const existingMainId = mainRecordIdFromParent || formData.id;
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
const mainRowToSave = { ...commonFieldsData, ...userInfo };
const mainRowToSave: Record<string, any> = { ...commonFieldsData, ...userInfo };
// 메타데이터 제거
Object.keys(mainRowToSave).forEach((key) => {
@@ -3057,7 +3060,7 @@ export class ButtonActionExecutor {
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
// screenId 전달하여 제어관리 실행 가능하도록 함
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName, screenId);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName, screenId || "");
if (!deleteResult.success) {
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
}
@@ -3392,7 +3395,7 @@ export class ButtonActionExecutor {
const matches = finalTitle.match(/\{([^}]+)\}/g);
if (matches) {
matches.forEach((match) => {
matches.forEach((match: string) => {
const path = match.slice(1, -1);
const [tableName, columnName] = path.split(".");
if (tableName && columnName) {
@@ -3418,7 +3421,7 @@ export class ButtonActionExecutor {
const isPassDataMode = passSelectedData && selectedData.length > 0;
// 🔧 isEditMode 옵션이 명시적으로 true인 경우에만 수정 모드로 처리
const useAsEditData = config.isEditMode === true;
const useAsEditData = (config as any).isEditMode === true;
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
@@ -4315,7 +4318,7 @@ export class ButtonActionExecutor {
}
} else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
// 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합
let mergedFormData = { ...context.formData } || {};
let mergedFormData = { ...context.formData };
if (
controlDataSource === "table-selection" &&
@@ -4334,7 +4337,7 @@ export class ButtonActionExecutor {
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
};
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig, mergedFormData, {
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig as any, mergedFormData, {
buttonId: context.buttonId || "unknown",
screenId: context.screenId || "unknown",
userId: context.userId || "unknown",
@@ -4356,7 +4359,7 @@ export class ButtonActionExecutor {
console.error("❌ 관계 실행 실패:", executionResult);
showErrorToast(
config.errorMessage || "관계 실행에 실패했습니다",
executionResult.message || executionResult.error,
(executionResult as any).message || (executionResult as any).error,
{ guidance: "관계 설정과 데이터를 확인해 주세요." }
);
return false;
@@ -4597,7 +4600,7 @@ export class ButtonActionExecutor {
};
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
buttonConfig,
buttonConfig as any,
context.formData || {},
{
buttonId: context.buttonId || "unknown",
@@ -4613,7 +4616,7 @@ export class ButtonActionExecutor {
// 성공 토스트는 save 액션에서 이미 표시했으므로 추가로 표시하지 않음
} else {
console.error("❌ 저장 후 제어 실행 실패:", executionResult);
showErrorToast("저장은 완료되었으나 연결된 제어 실행에 실패했습니다", executionResult.message || executionResult.error, {
showErrorToast("저장은 완료되었으나 연결된 제어 실행에 실패했습니다", (executionResult as any).message || (executionResult as any).error, {
guidance: "제어 관계 설정을 확인해 주세요. 데이터는 정상 저장되었습니다.",
});
}
@@ -5106,7 +5109,7 @@ export class ButtonActionExecutor {
// 헤더와 컬럼 매핑
columnLabels = {};
downloadResponse.data.columns.forEach((col: string, index: number) => {
columnLabels![col] = downloadResponse.data.headers[index] || col;
columnLabels![col] = downloadResponse.data!.headers[index] || col;
});
} else {
showErrorToast("마스터-디테일 데이터 조회에 실패했습니다", null, {
@@ -5145,7 +5148,7 @@ export class ButtonActionExecutor {
}
// 파일명 생성
let defaultFileName = relationResponse.data.masterTable || "데이터";
let defaultFileName = (relationResponse.data as any).master_table || "데이터";
if (typeof window !== "undefined") {
const menuName = localStorage.getItem("currentMenuName");
if (menuName) defaultFileName = menuName;
@@ -5425,9 +5428,9 @@ export class ButtonActionExecutor {
const categoryColumnsResponse = await getCategoryColumns(context.tableName);
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
if (categoryColumnsResponse.success && "data" in categoryColumnsResponse && categoryColumnsResponse.data) {
// 백엔드에서 정의된 카테고리 컬럼들
categoryColumns = categoryColumnsResponse.data
categoryColumns = (categoryColumnsResponse as any).data
.map((col: any) => col.column_name || col.name)
.filter(Boolean); // undefined 제거
@@ -5436,10 +5439,10 @@ export class ButtonActionExecutor {
try {
const valuesResponse = await getCategoryValues(context.tableName, columnName, false);
if (valuesResponse.success && valuesResponse.data) {
if (valuesResponse.success && "data" in valuesResponse && valuesResponse.data) {
// valueCode → valueLabel 매핑
categoryMap[columnName] = {};
valuesResponse.data.forEach((catValue: any) => {
(valuesResponse as any).data.forEach((catValue: any) => {
const code = catValue.value_code || catValue.category_value_id;
const label = catValue.value_label || catValue.label || code;
if (code) {
@@ -5548,20 +5551,20 @@ export class ButtonActionExecutor {
// 업로드 후 제어: excelAfterUploadFlows를 우선 사용 (통합된 설정)
// masterDetailExcel.afterUploadFlows는 레거시 호환성을 위해 fallback으로만 사용
const afterUploadFlows =
config.excelAfterUploadFlows?.length > 0
(config.excelAfterUploadFlows?.length ?? 0) > 0
? config.excelAfterUploadFlows
: config.masterDetailExcel?.afterUploadFlows;
: (config as any).masterDetailExcel?.afterUploadFlows;
// masterDetailExcel 설정이 명시적으로 있을 때만 간단 모드 (디테일만 업로드)
// 설정이 없으면 기본 모드 (마스터+디테일 둘 다 업로드)
if (config.masterDetailExcel) {
if ((config as any).masterDetailExcel) {
masterDetailExcelConfig = {
...config.masterDetailExcel,
...(config as any).masterDetailExcel,
// 분할 패널에서 감지한 테이블 정보로 덮어쓰기
masterTable: relationResponse.data.masterTable,
detailTable: relationResponse.data.detailTable,
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
masterTable: relationResponse.data.master_table,
detailTable: relationResponse.data.detail_table,
masterKeyColumn: relationResponse.data.master_key_column,
detailFkColumn: relationResponse.data.detail_fk_column,
// 채번은 ExcelUploadModal에서 마스터 테이블 기반 자동 감지
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
afterUploadFlows,
@@ -5729,10 +5732,7 @@ export class ButtonActionExecutor {
onScanSuccess: (barcode: string) => {
// 대상 필드에 값 입력
if (config.barcodeTargetField && context.onFormDataChange) {
context.onFormDataChange({
...context.formData,
[config.barcodeTargetField]: barcode,
});
context.onFormDataChange(config.barcodeTargetField, barcode);
}
toast.success(`바코드 스캔 완료: ${barcode}`);
@@ -5950,6 +5950,7 @@ export class ButtonActionExecutor {
private static currentTripId: string | null = null;
private static trackingContext: ButtonActionContext | null = null;
private static trackingConfig: ButtonActionConfig | null = null;
private static trackingUserId: string | null = null;
/**
*
@@ -6089,7 +6090,7 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
} else {
// 타이머 정리 (추적 중인 경우에만)
clearInterval(this.trackingIntervalId);
clearInterval(this.trackingIntervalId!);
this.trackingIntervalId = null;
}