refactor: 코드 정리 및 불필요한 로그 제거

- scheduleService.ts에서 스케줄 생성 로직을 간소화하고, 불필요한 줄바꿈을 제거하여 가독성을 향상시켰습니다.
- v2-sales-order-modal-layout.json에서 JSON 포맷을 정리하여 일관성을 유지했습니다.
- page.tsx, ScreenModal.tsx, ScreenDesigner.tsx, V2Input.tsx, V2Select.tsx, V2SelectConfigPanel.tsx, SimpleRepeaterTableComponent.tsx, ButtonPrimaryComponent.tsx, FileUploadComponent.tsx 등 여러 파일에서 디버깅 로그를 제거하여 코드의 깔끔함을 유지했습니다.
- 전반적으로 코드의 가독성을 높이고, 불필요한 로그를 제거하여 유지보수성을 개선했습니다.
This commit is contained in:
kjs
2026-02-05 17:35:13 +09:00
parent 34202be843
commit 73d05b991c
21 changed files with 1023 additions and 1478 deletions

View File

@@ -220,8 +220,8 @@ export function RepeaterTable({
columns
.filter((col) => !col.hidden)
.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
});
@@ -404,10 +404,10 @@ export function RepeaterTable({
// 데이터가 있으면 데이터 기반 자동 맞춤, 없으면 균등 분배
const timer = setTimeout(() => {
if (data.length > 0) {
applyAutoFitWidths();
} else {
applyEqualizeWidths();
}
applyAutoFitWidths();
} else {
applyEqualizeWidths();
}
}, 50);
return () => clearTimeout(timer);
@@ -654,11 +654,17 @@ export function RepeaterTable({
<thead className="sticky top-0 z-20 bg-gray-50">
<tr>
{/* 드래그 핸들 헤더 - 좌측 고정 */}
<th key="header-drag" className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700">
<th
key="header-drag"
className="sticky left-0 z-30 w-8 border-r border-b border-gray-200 bg-gray-50 px-1 py-2 text-center font-medium text-gray-700"
>
<span className="sr-only"></span>
</th>
{/* 체크박스 헤더 - 좌측 고정 */}
<th key="header-checkbox" className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700">
<th
key="header-checkbox"
className="sticky left-8 z-30 w-10 border-r border-b border-gray-200 bg-gray-50 px-3 py-2 text-center font-medium text-gray-700"
>
<Checkbox
checked={isAllSelected}
// @ts-expect-error - indeterminate는 HTML 속성
@@ -790,7 +796,7 @@ export function RepeaterTable({
<td
key={`drag-${rowIndex}`}
className={cn(
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
"sticky left-0 z-10 border-r border-b border-gray-200 px-1 py-1 text-center",
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
)}
>
@@ -810,7 +816,7 @@ export function RepeaterTable({
<td
key={`check-${rowIndex}`}
className={cn(
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
"sticky left-8 z-10 border-r border-b border-gray-200 px-3 py-1 text-center",
selectedRows.has(rowIndex) ? "bg-blue-50" : "bg-white",
)}
>

View File

@@ -90,7 +90,7 @@ export function SimpleRepeaterTableComponent({
const newRowDefaults = componentConfig?.newRowDefaults || {};
const summaryConfig = componentConfig?.summaryConfig;
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
// 🆕 컴포넌트 레벨의 저장 테이블 설정
const componentTargetTable = componentConfig?.targetTable || componentConfig?.saveTable;
const componentFkColumn = componentConfig?.fkColumn;
@@ -149,14 +149,11 @@ export function SimpleRepeaterTableComponent({
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${initialConfig.sourceTable}/data`,
{
search: filters,
page: 1,
size: 1000, // 대량 조회
}
);
const response = await apiClient.post(`/table-management/tables/${initialConfig.sourceTable}/data`, {
search: filters,
page: 1,
size: 1000, // 대량 조회
});
if (response.data.success && response.data.data?.data) {
const loadedData = response.data.data.data;
@@ -182,7 +179,7 @@ export function SimpleRepeaterTableComponent({
// 2. 조인 데이터 처리
const joinColumns = columns.filter(
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey,
);
if (joinColumns.length > 0) {
@@ -208,25 +205,20 @@ export function SimpleRepeaterTableComponent({
const [tableName] = groupKey.split(":");
// 조인 키 값 수집 (중복 제거)
const keyValues = Array.from(new Set(
baseMappedData
.map((row: any) => row[key])
.filter((v: any) => v !== undefined && v !== null)
));
const keyValues = Array.from(
new Set(baseMappedData.map((row: any) => row[key]).filter((v: any) => v !== undefined && v !== null)),
);
if (keyValues.length === 0) return;
try {
// 조인 테이블 조회
// refKey(타겟 테이블 컬럼)로 검색
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
page: 1,
size: 1000,
}
);
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
page: 1,
size: 1000,
});
if (response.data.success && response.data.data?.data) {
const joinedRows = response.data.data.data;
@@ -251,7 +243,7 @@ export function SimpleRepeaterTableComponent({
console.error(`조인 실패 (${tableName}):`, error);
// 실패 시 무시하고 진행 (값은 undefined)
}
})
}),
);
}
@@ -296,7 +288,7 @@ export function SimpleRepeaterTableComponent({
// 🆕 컴포넌트 레벨의 targetTable이 설정되어 있으면 우선 사용
if (componentTargetTable) {
console.log("✅ [SimpleRepeaterTable] 컴포넌트 레벨 저장 테이블 사용:", componentTargetTable);
// 모든 행을 해당 테이블에 저장
const dataToSave = value.map((row: any) => {
// 메타데이터 필드 제외 (_, _rowIndex 등)
@@ -399,9 +391,12 @@ export function SimpleRepeaterTableComponent({
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) {
// 테이블별 데이터를 통합하여 전달
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table }))
));
onFormDataChange(
columnName,
Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table })),
),
);
}
};
@@ -543,24 +538,14 @@ export function SimpleRepeaterTableComponent({
if (!allowAdd || readOnly || value.length >= maxRows) return null;
return (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-8 text-xs"
>
<Plus className="h-3.5 w-3.5 mr-1" />
<Button type="button" variant="outline" size="sm" onClick={handleAddRow} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
{addButtonText}
</Button>
);
};
const renderCell = (
row: any,
column: SimpleRepeaterColumnConfig,
rowIndex: number
) => {
const renderCell = (row: any, column: SimpleRepeaterColumnConfig, rowIndex: number) => {
const cellValue = row[column.field];
// 계산 필드는 편집 불가
@@ -583,9 +568,7 @@ export function SimpleRepeaterTableComponent({
<Input
type="number"
value={cellValue || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
onChange={(e) => handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)}
className="h-7 text-xs"
/>
);
@@ -604,19 +587,19 @@ export function SimpleRepeaterTableComponent({
return (
<Select
value={cellValue || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
onValueChange={(newValue) => handleCellEdit(rowIndex, column.field, newValue)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.filter((option) => option.value && option.value !== "").map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
{column.selectOptions
?.filter((option) => option.value && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
@@ -636,11 +619,11 @@ export function SimpleRepeaterTableComponent({
// 로딩 중일 때
if (isLoading) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> ...</p>
<Loader2 className="text-primary mx-auto mb-2 h-8 w-8 animate-spin" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
</div>
@@ -650,14 +633,14 @@ export function SimpleRepeaterTableComponent({
// 에러 발생 시
if (loadError) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
<X className="h-6 w-6 text-destructive" />
<div className="bg-destructive/10 mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full">
<X className="text-destructive h-6 w-6" />
</div>
<p className="text-sm font-medium text-destructive mb-1"> </p>
<p className="text-xs text-muted-foreground">{loadError}</p>
<p className="text-destructive mb-1 text-sm font-medium"> </p>
<p className="text-muted-foreground text-xs">{loadError}</p>
</div>
</div>
</div>
@@ -668,30 +651,27 @@ export function SimpleRepeaterTableComponent({
const totalColumns = columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0);
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className={cn("bg-background overflow-hidden rounded-md border", className)}>
{/* 상단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "bottom" && (
<div className="p-2 border-b bg-muted/50">
<div className="bg-muted/50 border-b p-2">
<AddRowButton />
</div>
)}
<div
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }}
>
<div className="overflow-x-auto overflow-y-auto" style={{ maxHeight }}>
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
{showRowNumber && (
<th key="header-rownum" className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
<th key="header-rownum" className="text-muted-foreground w-12 px-4 py-2 text-left font-medium">
#
</th>
)}
{columns.map((col) => (
<th
key={`header-${col.field}`}
className="px-4 py-2 text-left font-medium text-muted-foreground"
className="text-muted-foreground px-4 py-2 text-left font-medium"
style={{ width: col.width }}
>
{col.label}
@@ -699,7 +679,7 @@ export function SimpleRepeaterTableComponent({
</th>
))}
{!readOnly && allowDelete && (
<th key="header-delete" className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
<th key="header-delete" className="text-muted-foreground w-20 px-4 py-2 text-left font-medium">
</th>
)}
@@ -708,11 +688,7 @@ export function SimpleRepeaterTableComponent({
<tbody className="bg-background">
{value.length === 0 ? (
<tr key="empty-row">
<td
key="empty-cell"
colSpan={totalColumns}
className="px-4 py-8 text-center text-muted-foreground"
>
<td key="empty-cell" colSpan={totalColumns} className="text-muted-foreground px-4 py-8 text-center">
{allowAdd ? (
<div className="flex flex-col items-center gap-2">
<span> </span>
@@ -725,9 +701,9 @@ export function SimpleRepeaterTableComponent({
</tr>
) : (
value.map((row, rowIndex) => (
<tr key={`row-${rowIndex}`} className="border-t hover:bg-accent/50">
<tr key={`row-${rowIndex}`} className="hover:bg-accent/50 border-t">
{showRowNumber && (
<td key={`rownum-${rowIndex}`} className="px-4 py-2 text-center text-muted-foreground">
<td key={`rownum-${rowIndex}`} className="text-muted-foreground px-4 py-2 text-center">
{rowIndex + 1}
</td>
)}
@@ -743,7 +719,7 @@ export function SimpleRepeaterTableComponent({
size="sm"
onClick={() => handleRowDelete(rowIndex)}
disabled={value.length <= minRows}
className="h-7 w-7 p-0 text-destructive hover:text-destructive disabled:opacity-50"
className="text-destructive hover:text-destructive h-7 w-7 p-0 disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -758,35 +734,29 @@ export function SimpleRepeaterTableComponent({
{/* 합계 표시 */}
{summaryConfig?.enabled && summaryValues && (
<div className={cn(
"border-t bg-muted/30 p-3",
summaryConfig.position === "bottom-right" && "flex justify-end"
)}>
<div className={cn(
summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full"
)}>
<div
className={cn("bg-muted/30 border-t p-3", summaryConfig.position === "bottom-right" && "flex justify-end")}
>
<div className={cn(summaryConfig.position === "bottom-right" ? "w-auto min-w-[200px]" : "w-full")}>
{summaryConfig.title && (
<div className="text-xs font-medium text-muted-foreground mb-2">
{summaryConfig.title}
</div>
<div className="text-muted-foreground mb-2 text-xs font-medium">{summaryConfig.title}</div>
)}
<div className={cn(
"grid gap-2",
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4"
)}>
<div
className={cn(
"grid gap-2",
summaryConfig.position === "bottom-right" ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{summaryConfig.fields.map((field) => (
<div
key={field.field}
className={cn(
"flex justify-between items-center px-3 py-1.5 rounded",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background"
"flex items-center justify-between rounded px-3 py-1.5",
field.highlight ? "bg-primary/10 font-semibold" : "bg-background",
)}
>
<span className="text-xs text-muted-foreground">{field.label}</span>
<span className={cn(
"text-sm font-medium",
field.highlight && "text-primary"
)}>
<span className="text-muted-foreground text-xs">{field.label}</span>
<span className={cn("text-sm font-medium", field.highlight && "text-primary")}>
{formatSummaryValue(field, summaryValues[field.field] || 0)}
</span>
</div>
@@ -798,10 +768,10 @@ export function SimpleRepeaterTableComponent({
{/* 하단 행 추가 버튼 */}
{allowAdd && addButtonPosition !== "top" && value.length > 0 && (
<div className="p-2 border-t bg-muted/50 flex justify-between items-center">
<div className="bg-muted/50 flex items-center justify-between border-t p-2">
<AddRowButton />
{maxRows !== Infinity && (
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{value.length} / {maxRows}
</span>
)}
@@ -810,4 +780,3 @@ export function SimpleRepeaterTableComponent({
</div>
);
}

View File

@@ -1098,28 +1098,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const screenContextFormData = screenContext?.formData || {};
const propsFormData = formData || {};
// 🔧 디버그: formData 소스 확인
console.log("🔍 [v2-button-primary] formData 소스 확인:", {
propsFormDataKeys: Object.keys(propsFormData),
screenContextFormDataKeys: Object.keys(screenContextFormData),
propsHasCompanyImage: "company_image" in propsFormData,
propsHasCompanyLogo: "company_logo" in propsFormData,
screenHasCompanyImage: "company_image" in screenContextFormData,
screenHasCompanyLogo: "company_logo" in screenContextFormData,
});
// 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드
// (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음)
let effectiveFormData = { ...propsFormData, ...screenContextFormData };
console.log("🔍 [v2-button-primary] effectiveFormData 병합 결과:", {
keys: Object.keys(effectiveFormData),
hasCompanyImage: "company_image" in effectiveFormData,
hasCompanyLogo: "company_logo" in effectiveFormData,
companyImageValue: effectiveFormData.company_image,
companyLogoValue: effectiveFormData.company_logo,
});
// 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용
if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) {
effectiveFormData = { ...splitPanelParentData };
@@ -1289,20 +1271,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
const userStyle = component.style
? Object.fromEntries(
Object.entries(component.style).filter(
([key]) => !["background", "backgroundColor"].includes(key),
),
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)),
)
: {};
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
const buttonWidth = component.size?.width ? `${component.size.width}px` : (style?.width || "100%");
const buttonHeight = component.size?.height ? `${component.size.height}px` : (style?.height || "100%");
const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%";
const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%";
const buttonElementStyle: React.CSSProperties = {
width: buttonWidth,
height: buttonHeight,
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
border: "none",
borderRadius: "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
@@ -1328,26 +1308,26 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 버튼 텍스트 결정 (다양한 소스에서 가져옴)
// "기본 버튼"은 컴포넌트 생성 시 기본값이므로 무시
const labelValue = component.label === "기본 버튼" ? undefined : component.label;
// 액션 타입에 따른 기본 텍스트 (modal 액션과 동일하게)
const actionType = processedConfig.action?.type || component.componentConfig?.action?.type;
const actionDefaultText: Record<string, string> = {
save: "저장",
delete: "삭제",
delete: "삭제",
modal: "등록",
edit: "수정",
copy: "복사",
close: "닫기",
cancel: "취소",
};
const buttonContent =
processedConfig.text ||
component.webTypeConfig?.text ||
component.componentConfig?.text ||
component.config?.text ||
const buttonContent =
processedConfig.text ||
component.webTypeConfig?.text ||
component.componentConfig?.text ||
component.config?.text ||
component.style?.labelText ||
labelValue ||
labelValue ||
actionDefaultText[actionType as string] ||
"버튼";

View File

@@ -123,34 +123,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [isRecordMode, recordTableName, recordId, columnName]);
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
// 🆕 columnName을 포함하여 같은 화면의 여러 파일 업로드 컴포넌트 구분
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID:컬럼명 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}_${columnName}`;
}
// 기본 모드: 컴포넌트 ID 사용
return `fileUpload_${component.id}`;
}, [isRecordMode, recordTableName, recordId, component.id]);
// 기본 모드: 컴포넌트 ID + 컬럼명 사용
return `fileUpload_${component.id}_${columnName}`;
}, [isRecordMode, recordTableName, recordId, component.id, columnName]);
// 🔍 디버깅: 레코드 모드 상태 로깅
useEffect(() => {
console.log("📎 [FileUploadComponent] 모드 확인:", {
isRecordMode,
recordTableName,
recordId,
columnName,
targetObjid: getRecordTargetObjid(),
uniqueKey: getUniqueKey(),
formDataKeys: formData ? Object.keys(formData) : [],
// 🔍 추가 디버깅: formData.id 확인 (수정 모드 판단에 사용됨)
"formData.id": formData?.id,
"formData.tableName": formData?.tableName,
"formData.image": formData?.image,
"component.tableName": component.tableName,
"component.columnName": component.columnName,
"component.id": component.id,
});
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
@@ -160,19 +142,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const modeChanged = prevIsRecordModeRef.current !== null && prevIsRecordModeRef.current !== isRecordMode;
if (recordIdChanged || modeChanged) {
console.log("📎 [FileUploadComponent] 레코드 상태 변경 감지:", {
prevRecordId: prevRecordIdRef.current,
currentRecordId: recordId,
prevIsRecordMode: prevIsRecordModeRef.current,
currentIsRecordMode: isRecordMode,
});
prevRecordIdRef.current = recordId;
prevIsRecordModeRef.current = isRecordMode;
// 레코드 ID가 변경되거나 등록 모드(isRecordMode=false)로 전환되면 파일 목록 초기화
// 등록 모드에서는 항상 빈 상태로 시작해야 함
if (isRecordMode || !recordId) {
console.log("📎 [FileUploadComponent] 파일 목록 초기화 (새 레코드 또는 레코드 변경)");
setUploadedFiles([]);
setRepresentativeImageUrl(null);
}
@@ -189,7 +164,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 등록 모드(새 레코드)인 경우 파일 복원 스킵 - 빈 상태 유지
if (!isRecordMode || !recordId) {
console.log("📎 [FileUploadComponent] 등록 모드: 파일 복원 스킵 (빈 상태 유지)");
return;
}
@@ -200,13 +174,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원 (레코드별 고유 키 사용)
@@ -224,26 +191,20 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행
// 🔑 수정 모드: formData[columnName]에 저장된 objid로 이미지 로드
// 이 로직은 isRecordMode와 상관없이 formData에 이미지 objid가 있으면 표시
// 🆕 formData 전체가 아닌 특정 컬럼 값만 의존하도록 수정 (다른 컴포넌트 영향 방지)
const imageObjidFromFormData = formData?.[columnName];
useEffect(() => {
const imageObjid = formData?.[columnName];
// 이미지 objid가 있고, 숫자 문자열인 경우에만 처리
if (imageObjid && /^\d+$/.test(String(imageObjid))) {
console.log("🖼️ [FileUploadComponent] formData에서 이미지 objid 발견:", {
columnName,
imageObjid,
currentFilesCount: uploadedFiles.length,
});
if (imageObjidFromFormData && /^\d+$/.test(String(imageObjidFromFormData))) {
const objidStr = String(imageObjidFromFormData);
// 이미 같은 objid의 파일이 로드되어 있으면 스킵
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === String(imageObjid));
const alreadyLoaded = uploadedFiles.some(f => String(f.objid) === objidStr);
if (alreadyLoaded) {
console.log("🖼️ [FileUploadComponent] 이미 로드된 이미지, 스킵");
return;
}
const objidStr = String(imageObjid);
const previewUrl = `/api/files/preview/${objidStr}`;
// 🔑 실제 파일 정보 조회
@@ -254,12 +215,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (fileInfoResponse.success && fileInfoResponse.data) {
const { realFileName, fileSize, fileExt, regdate, isRepresentative } = fileInfoResponse.data;
console.log("🖼️ [FileUploadComponent] 파일 정보 조회 성공:", {
objid: objidStr,
realFileName,
fileExt,
});
const fileInfo = {
objid: objidStr,
realFileName: realFileName,
@@ -296,46 +251,39 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
})();
}
}, [formData, columnName, uploadedFiles]);
}, [imageObjidFromFormData, columnName, component.id]); // 🆕 formData 대신 특정 컬럼 값만 의존
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
// 🆕 columnName도 체크하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
useEffect(() => {
const handleDesignModeFileChange = (event: CustomEvent) => {
console.log("🎯🎯🎯 FileUploadComponent 화면설계 모드 파일 변경 이벤트 수신:", {
eventComponentId: event.detail.componentId,
currentComponentId: component.id,
isMatch: event.detail.componentId === component.id,
filesCount: event.detail.files?.length || 0,
action: event.detail.action,
source: event.detail.source,
eventDetail: event.detail,
});
const eventColumnName = event.detail.eventColumnName || event.detail.columnName;
// 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크
const isForThisComponent =
(event.detail.uniqueKey && event.detail.uniqueKey === currentUniqueKey) ||
(event.detail.componentId === component.id && eventColumnName === columnName) ||
(event.detail.componentId === component.id && !eventColumnName); // 이전 호환성
// 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (event.detail.componentId === component.id && event.detail.source === "designMode") {
// 🆕 현재 컴포넌트와 일치하고 화면설계 모드에서 온 이벤트인 경우
if (isForThisComponent && event.detail.source === "designMode") {
// 파일 상태 업데이트
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
const backupKey = currentUniqueKey;
localStorage.setItem(backupKey, JSON.stringify(newFiles));
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
fileCount: newFiles.length,
});
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
// 전역 상태 업데이트
// 전역 상태 업데이트 (🆕 고유 키 사용)
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: newFiles,
[currentUniqueKey]: newFiles,
};
}
@@ -346,11 +294,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
lastFileUpdate: event.detail.timestamp,
});
}
console.log("🎉🎉🎉 화면설계 모드 → 실제 화면 동기화 완료:", {
componentId: component.id,
finalFileCount: newFiles.length,
});
}
};
@@ -369,25 +312,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 🔑 등록 모드(새 레코드)인 경우 파일 조회 스킵 - 빈 상태 유지
if (!isRecordMode || !recordId) {
console.log("📂 [FileUploadComponent] 등록 모드: 파일 조회 스킵 (빈 상태 유지)", {
isRecordMode,
recordId,
componentId: component.id,
});
return false;
}
try {
// 🔑 레코드 모드: 해당 행의 파일만 조회
if (isRecordMode && recordTableName && recordId) {
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
targetObjid: getRecordTargetObjid(),
});
}
// 1. formData에서 screenId 가져오기
let screenId = formData?.screenId;
@@ -424,8 +352,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
};
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
const response = await getComponentFiles(params);
if (response.success) {
@@ -457,12 +383,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
uniqueKey,
serverFiles: formattedFiles.length,
localFiles: parsedBackupFiles.length,
finalFiles: finalFiles.length,
});
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
@@ -505,16 +425,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const componentFiles = (component as any)?.uploadedFiles || [];
const lastUpdate = (component as any)?.lastFileUpdate;
console.log("🔄 FileUploadComponent 파일 동기화 시작:", {
componentId: component.id,
componentFiles: componentFiles.length,
formData: formData,
screenId: formData?.screenId,
tableName: formData?.tableName, // 🔍 테이블명 확인
recordId: formData?.id, // 🔍 레코드 ID 확인
currentUploadedFiles: uploadedFiles.length,
});
// 🔒 항상 DB에서 최신 파일 목록을 조회 (멀티테넌시 격리)
loadComponentFiles().then((dbLoadSuccess) => {
if (dbLoadSuccess) {
@@ -523,9 +433,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// DB 로드 실패 시에만 기존 로직 사용 (하위 호환성)
// 전역 상태에서 최신 파일 정보 가져오기
// 전역 상태에서 최신 파일 정보 가져오기 (🆕 고유 키 사용)
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
const uniqueKeyForFallback = getUniqueKey();
const globalFiles = globalFileState[uniqueKeyForFallback] || globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : componentFiles;
@@ -540,36 +451,27 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}, [loadComponentFiles, component.id, (component as any)?.uploadedFiles, (component as any)?.lastFileUpdate]);
// 전역 상태 변경 감지 (모든 파일 컴포넌트 동기화 + 화면 복원)
// 🆕 columnName을 포함한 고유 키로 구분하여 다른 파일 업로드 컴포넌트에 영향 방지
const currentUniqueKey = getUniqueKey();
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
const { componentId, files, fileCount, timestamp, isRestore } = event.detail;
const { componentId, files, fileCount, timestamp, isRestore, uniqueKey: eventUniqueKey, eventColumnName } = event.detail;
console.log("🔄 FileUploadComponent 전역 상태 변경 감지:", {
currentComponentId: component.id,
eventComponentId: componentId,
isForThisComponent: componentId === component.id,
newFileCount: fileCount,
currentFileCount: uploadedFiles.length,
timestamp,
isRestore: !!isRestore,
});
// 🆕 고유 키 또는 (컴포넌트ID + 컬럼명) 조합으로 체크
const isForThisComponent =
(eventUniqueKey && eventUniqueKey === currentUniqueKey) ||
(componentId === component.id && eventColumnName === columnName);
// 같은 컴포넌트 ID인 경우에만 업데이트
if (componentId === component.id) {
const logMessage = isRestore ? "🔄 화면 복원으로 파일 상태 동기화" : "✅ 파일 상태 동기화 적용";
console.log(logMessage, {
componentId: component.id,
이전파일수: uploadedFiles?.length || 0,
새파일수: files?.length || 0,
files: files?.map((f: any) => ({ objid: f.objid, name: f.realFileName })) || [],
});
// 🆕 같은 고유 키인 경우에만 업데이트 (componentId + columnName 조합)
if (isForThisComponent) {
setUploadedFiles(files);
setForceUpdate((prev) => prev + 1);
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
const backupKey = currentUniqueKey;
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
@@ -584,7 +486,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
};
}
}, [component.id, uploadedFiles.length]);
}, [component.id, columnName, currentUniqueKey, uploadedFiles.length]);
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
const safeComponentConfig = componentConfig || {};
@@ -598,18 +500,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 파일 선택 핸들러
const handleFileSelect = useCallback(() => {
console.log("🎯 handleFileSelect 호출됨:", {
hasFileInputRef: !!fileInputRef.current,
fileInputRef: fileInputRef.current,
fileInputType: fileInputRef.current?.type,
fileInputHidden: fileInputRef.current?.className,
});
if (fileInputRef.current) {
console.log("✅ fileInputRef.current.click() 호출");
fileInputRef.current.click();
} else {
console.log("❌ fileInputRef.current가 null입니다");
}
}, []);
@@ -680,34 +572,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
// 🎯 레코드 모드: 특정 행에 파일 연결
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
console.log("📁 [레코드 모드] 파일 업로드:", {
targetObjid,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
});
} else if (screenId) {
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
}
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
userCompanyCode,
isRecordMode: effectiveIsRecordMode,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
const finalLinkedTable = effectiveIsRecordMode
@@ -732,27 +607,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
isRecordMode: effectiveIsRecordMode,
};
console.log("📤 [FileUploadComponent] uploadData 최종:", {
isRecordMode: effectiveIsRecordMode,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
filesCount: filesToUpload.length,
uploadData,
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
const fileData = response.files || (response as any).data || [];
@@ -811,9 +670,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
});
// 모든 파일 컴포넌트에 동기화 이벤트 발생
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
eventColumnName: columnName, // 🆕 컬럼명 추가
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
@@ -822,25 +683,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
},
});
window.dispatchEvent(syncEvent);
console.log("🌐 전역 파일 상태 업데이트 및 동기화 이벤트 발생:", {
componentId: component.id,
fileCount: updatedFiles.length,
globalState: Object.keys(globalFileState).map((id) => ({
id,
fileCount: globalFileState[id]?.length || 0,
})),
});
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
console.log("🔄 onUpdate 호출:", {
componentId: component.id,
uploadedFiles: updatedFiles.length,
timestamp: timestamp,
});
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp,
@@ -858,15 +705,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
? fileObjids.join(',') // 복수 파일: 콤마 구분
: (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID
console.log("📎 [파일 업로드] 컬럼 데이터 동기화:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
columnValue,
fileCount: updatedFiles.length,
isMultiple: fileConfig.multiple,
});
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
onFormDataChange(effectiveColumnName, columnValue);
}
@@ -883,13 +721,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
},
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
fileCount: updatedFiles.length,
});
}
// 컴포넌트 설정 콜백
@@ -972,9 +803,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
eventColumnName: columnName, // 🆕 컬럼명 추가
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
@@ -985,12 +818,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
},
});
window.dispatchEvent(syncEvent);
console.log("🗑️ 파일 삭제 후 전역 상태 동기화:", {
componentId: component.id,
deletedFile: fileName,
remainingFiles: updatedFiles.length,
});
}
// 컴포넌트 업데이트
@@ -1010,14 +837,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
? fileObjids.join(',')
: (fileObjids[0] || '');
console.log("📎 [파일 삭제] 컬럼 데이터 동기화:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
columnValue,
remainingFiles: updatedFiles.length,
});
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
onFormDataChange(columnName, columnValue);
}
@@ -1053,16 +872,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 🔑 이미 previewUrl이 설정된 경우 바로 사용 (API 호출 스킵)
if (file.previewUrl) {
console.log("🖼️ 대표 이미지: previewUrl 사용:", file.previewUrl);
setRepresentativeImageUrl(file.previewUrl);
return;
}
console.log("🖼️ 대표 이미지 로드 시작:", {
objid: file.objid,
fileName: file.realFileName,
});
// API 클라이언트를 통해 Blob으로 다운로드 (인증 토큰 포함)
// 🔑 download 대신 preview 사용 (공개 접근)
const response = await apiClient.get(`/files/preview/${file.objid}`, {
@@ -1082,7 +895,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
setRepresentativeImageUrl(url);
console.log("✅ 대표 이미지 로드 성공:", url);
} catch (error: any) {
console.error("❌ 대표 이미지 로드 실패:", {
file: file.realFileName,
@@ -1113,12 +925,6 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 대표 이미지 로드
loadRepresentativeImage(file);
console.log("✅ 대표 파일 설정 완료:", {
componentId: component.id,
representativeFile: file.realFileName,
objid: file.objid,
});
} catch (e) {
console.error("❌ 대표 파일 설정 실패:", e);
}
@@ -1146,22 +952,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 드래그 앤 드롭 핸들러
const handleDragOver = useCallback(
(e: React.DragEvent) => {
console.log("🎯 드래그 오버 이벤트 감지:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
dragOver: dragOver,
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
setDragOver(true);
console.log("✅ 드래그 오버 활성화");
} else {
console.log("❌ 드래그 차단됨: readonly 또는 disabled");
}
},
[safeComponentConfig.readonly, safeComponentConfig.disabled, dragOver],
[safeComponentConfig.readonly, safeComponentConfig.disabled],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
@@ -1189,19 +986,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 클릭 핸들러
const handleClick = useCallback(
(e: React.MouseEvent) => {
console.log("🖱️ 파일 업로드 영역 클릭:", {
readonly: safeComponentConfig.readonly,
disabled: safeComponentConfig.disabled,
hasHandleFileSelect: !!handleFileSelect,
});
e.preventDefault();
e.stopPropagation();
if (!safeComponentConfig.readonly && !safeComponentConfig.disabled) {
console.log("✅ 파일 선택 함수 호출");
handleFileSelect();
} else {
console.log("❌ 클릭 차단됨: readonly 또는 disabled");
}
onClick?.();
},

View File

@@ -23,9 +23,15 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
// formData에서 현재 값 가져오기 (기본값 지원)
const defaultValue = config.defaultValue || "";
let currentValue = formData?.[columnName] ?? component.value ?? "";
// 🆕 formData에 값이 없고 기본값이 설정된 경우, 기본값 적용
if ((currentValue === "" || currentValue === undefined || currentValue === null) && defaultValue && isInteractive && onFormDataChange && columnName) {
if (
(currentValue === "" || currentValue === undefined || currentValue === null) &&
defaultValue &&
isInteractive &&
onFormDataChange &&
columnName
) {
// 초기 렌더링 시 기본값을 formData에 설정
setTimeout(() => {
if (!formData?.[columnName]) {

View File

@@ -1033,7 +1033,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// localStorage에 저장된 정렬이 없으면 defaultSort 설정 적용
if (tableConfig.defaultSort?.columnName) {
console.log("📊 기본 정렬 설정 적용:", tableConfig.defaultSort);
setSortColumn(tableConfig.defaultSort.columnName);
setSortDirection(tableConfig.defaultSort.direction || "asc");
hasInitializedSort.current = true;
@@ -1139,16 +1138,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🔍 디버깅: 캐시 사용 시 로그
console.log("📊 [TableListComponent] 캐시에서 inputTypes 로드:", {
tableName: tableConfig.selectedTable,
cacheKey: cacheKey,
hasInputTypes: !!cached.inputTypes,
inputTypesLength: cached.inputTypes?.length || 0,
imageInputType: inputTypeMap["image"],
cacheAge: Date.now() - cached.timestamp,
});
cached.columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName;
meta[col.columnName] = {
@@ -1172,14 +1161,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
inputTypeMap[col.columnName] = col.inputType;
});
// 🔍 디버깅: inputTypes 확인
console.log("📊 [TableListComponent] inputTypes 조회 결과:", {
tableName: tableConfig.selectedTable,
inputTypes: inputTypes,
inputTypeMap: inputTypeMap,
imageColumn: inputTypes.find((col: any) => col.columnName === "image"),
});
tableColumnCache.set(cacheKey, {
columns,
inputTypes,
@@ -4079,17 +4060,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 🔍 디버깅: image 컬럼인 경우 로그 출력
if (column.columnName === "image") {
console.log("🖼️ [formatCellValue] image 컬럼 처리:", {
columnName: column.columnName,
value: value,
meta: meta,
inputType: inputType,
columnInputType: column.inputType,
});
}
// 🖼️ 이미지 타입: 작은 썸네일 표시
if (inputType === "image" && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용

View File

@@ -5,32 +5,10 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
@@ -52,10 +30,7 @@ interface ColumnInfo {
displayName: string;
}
export function TimelineSchedulerConfigPanel({
config,
onChange,
}: TimelineSchedulerConfigPanelProps) {
export function TimelineSchedulerConfigPanel({ config, onChange }: TimelineSchedulerConfigPanelProps) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
@@ -74,7 +49,7 @@ export function TimelineSchedulerConfigPanel({
tableList.map((t: any) => ({
tableName: t.table_name || t.tableName,
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
}))
})),
);
}
} catch (err) {
@@ -100,7 +75,7 @@ export function TimelineSchedulerConfigPanel({
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
})),
);
}
} catch (err) {
@@ -125,7 +100,7 @@ export function TimelineSchedulerConfigPanel({
columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
}))
})),
);
}
} catch (err) {
@@ -168,11 +143,9 @@ export function TimelineSchedulerConfigPanel({
<Accordion type="multiple" defaultValue={["source", "resource", "display"]}>
{/* 소스 데이터 설정 (스케줄 생성 기준) */}
<AccordionItem value="source">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<p className="text-[10px] text-muted-foreground mb-2">
<p className="text-muted-foreground mb-2 text-[10px]">
(저장: schedule_mng)
</p>
@@ -208,20 +181,14 @@ export function TimelineSchedulerConfigPanel({
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.sourceConfig?.tableName ? (
tables.find((t) => t.tableName === config.sourceConfig?.tableName)
?.displayName || config.sourceConfig.tableName
) : (
"소스 테이블 선택..."
)}
{config.sourceConfig?.tableName
? tables.find((t) => t.tableName === config.sourceConfig?.tableName)?.displayName ||
config.sourceConfig.tableName
: "소스 테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
const lowerSearch = search.toLowerCase();
@@ -233,9 +200,7 @@ export function TimelineSchedulerConfigPanel({
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
@@ -250,16 +215,12 @@ export function TimelineSchedulerConfigPanel({
<Check
className={cn(
"mr-2 h-3 w-3",
config.sourceConfig?.tableName === table.tableName
? "opacity-100"
: "opacity-0"
config.sourceConfig?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
@@ -272,11 +233,11 @@ export function TimelineSchedulerConfigPanel({
{/* 소스 필드 매핑 */}
{config.sourceConfig?.tableName && (
<div className="space-y-2 mt-2">
<div className="mt-2 space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
{/* 기준일 필드 */}
<div className="space-y-1 col-span-2">
<div className="col-span-2 space-y-1">
<Label className="text-[10px]"> (/) *</Label>
<Select
value={config.sourceConfig?.dueDateField || ""}
@@ -293,9 +254,7 @@ export function TimelineSchedulerConfigPanel({
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
{/* 수량 필드 */}
@@ -339,7 +298,7 @@ export function TimelineSchedulerConfigPanel({
</div>
{/* 그룹명 필드 */}
<div className="space-y-1 col-span-2">
<div className="col-span-2 space-y-1">
<Label className="text-[10px]"> ()</Label>
<Select
value={config.sourceConfig?.groupNameField || ""}
@@ -365,21 +324,14 @@ export function TimelineSchedulerConfigPanel({
{/* 리소스 설정 */}
<AccordionItem value="resource">
<AccordionTrigger className="text-sm font-medium">
(/)
</AccordionTrigger>
<AccordionTrigger className="text-sm font-medium"> (/)</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<p className="text-[10px] text-muted-foreground mb-2">
Y축에 (, )
</p>
<p className="text-muted-foreground mb-2 text-[10px]"> Y축에 (, )</p>
{/* 리소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover
open={resourceTableSelectOpen}
onOpenChange={setResourceTableSelectOpen}
>
<Popover open={resourceTableSelectOpen} onOpenChange={setResourceTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
@@ -388,20 +340,13 @@ export function TimelineSchedulerConfigPanel({
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{config.resourceTable ? (
tables.find((t) => t.tableName === config.resourceTable)
?.displayName || config.resourceTable
) : (
"리소스 테이블 선택..."
)}
{config.resourceTable
? tables.find((t) => t.tableName === config.resourceTable)?.displayName || config.resourceTable
: "리소스 테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
const lowerSearch = search.toLowerCase();
@@ -413,9 +358,7 @@ export function TimelineSchedulerConfigPanel({
>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs">
.
</CommandEmpty>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
@@ -430,16 +373,12 @@ export function TimelineSchedulerConfigPanel({
<Check
className={cn(
"mr-2 h-3 w-3",
config.resourceTable === table.tableName
? "opacity-100"
: "opacity-0"
config.resourceTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-[10px] text-muted-foreground">
{table.tableName}
</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
@@ -452,7 +391,7 @@ export function TimelineSchedulerConfigPanel({
{/* 리소스 필드 매핑 */}
{config.resourceTable && (
<div className="space-y-2 mt-2">
<div className="mt-2 space-y-2">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
{/* ID 필드 */}
@@ -502,18 +441,14 @@ export function TimelineSchedulerConfigPanel({
{/* 표시 설정 */}
<AccordionItem value="display">
<AccordionTrigger className="text-sm font-medium">
</AccordionTrigger>
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 기본 줌 레벨 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.defaultZoomLevel || "day"}
onValueChange={(v) =>
updateConfig({ defaultZoomLevel: v as any })
}
onValueChange={(v) => updateConfig({ defaultZoomLevel: v as any })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@@ -534,9 +469,7 @@ export function TimelineSchedulerConfigPanel({
<Input
type="number"
value={config.height || 500}
onChange={(e) =>
updateConfig({ height: parseInt(e.target.value) || 500 })
}
onChange={(e) => updateConfig({ height: parseInt(e.target.value) || 500 })}
className="h-8 text-xs"
/>
</div>
@@ -547,9 +480,7 @@ export function TimelineSchedulerConfigPanel({
<Input
type="number"
value={config.rowHeight || 50}
onChange={(e) =>
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
}
onChange={(e) => updateConfig({ rowHeight: parseInt(e.target.value) || 50 })}
className="h-8 text-xs"
/>
</div>
@@ -558,26 +489,17 @@ export function TimelineSchedulerConfigPanel({
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.editable ?? true}
onCheckedChange={(v) => updateConfig({ editable: v })}
/>
<Switch checked={config.editable ?? true} onCheckedChange={(v) => updateConfig({ editable: v })} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.draggable ?? true}
onCheckedChange={(v) => updateConfig({ draggable: v })}
/>
<Switch checked={config.draggable ?? true} onCheckedChange={(v) => updateConfig({ draggable: v })} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<Switch
checked={config.resizable ?? true}
onCheckedChange={(v) => updateConfig({ resizable: v })}
/>
<Switch checked={config.resizable ?? true} onCheckedChange={(v) => updateConfig({ resizable: v })} />
</div>
<div className="flex items-center justify-between">

View File

@@ -3,13 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
import {
TimelineSchedulerConfig,
ScheduleItem,
Resource,
ZoomLevel,
UseTimelineDataResult,
} from "../types";
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
@@ -37,16 +31,14 @@ const addDays = (date: Date, days: number): Date => {
export function useTimelineData(
config: TimelineSchedulerConfig,
externalSchedules?: ScheduleItem[],
externalResources?: Resource[]
externalResources?: Resource[],
): UseTimelineDataResult {
// 상태
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(
config.defaultZoomLevel || "day"
);
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
if (config.initialDate) {
return new Date(config.initialDate);
@@ -69,9 +61,7 @@ export function useTimelineData(
}, [viewStartDate, zoomLevel]);
// 테이블명: 기본적으로 schedule_mng 사용, 커스텀 테이블 설정 시 해당 테이블 사용
const tableName = config.useCustomTable && config.customTableName
? config.customTableName
: SCHEDULE_TABLE;
const tableName = config.useCustomTable && config.customTableName ? config.customTableName : SCHEDULE_TABLE;
const resourceTableName = config.resourceTable;
@@ -88,7 +78,7 @@ export function useTimelineData(
const fieldMapping = useMemo(() => {
const mapping = config.fieldMapping;
if (!mapping) return defaultTimelineSchedulerConfig.fieldMapping!;
return {
id: mapping.id || mapping.idField || "id",
resourceId: mapping.resourceId || mapping.resourceIdField || "resource_id",
@@ -134,17 +124,13 @@ export function useTimelineData(
sourceKeys: currentSourceKeys,
});
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
page: 1,
size: 10000,
autoFilter: true,
}
);
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
page: 1,
size: 10000,
autoFilter: true,
});
const responseData =
response.data?.data?.data || response.data?.data || [];
const responseData = response.data?.data?.data || response.data?.data || [];
let rawData = Array.isArray(responseData) ? responseData : [];
// 클라이언트 측 필터링 적용 (schedule_mng 테이블인 경우)
@@ -156,9 +142,7 @@ export function useTimelineData(
// 선택된 품목 필터 (source_group_key 기준)
if (currentSourceKeys.length > 0) {
rawData = rawData.filter((row: any) =>
currentSourceKeys.includes(row.source_group_key)
);
rawData = rawData.filter((row: any) => currentSourceKeys.includes(row.source_group_key));
}
console.log("[useTimelineData] 필터링 후 스케줄:", rawData.length, "건");
@@ -194,9 +178,7 @@ export function useTimelineData(
title: String(row[effectiveMapping.title] || ""),
startDate: row[effectiveMapping.startDate] || "",
endDate: row[effectiveMapping.endDate] || "",
status: effectiveMapping.status
? row[effectiveMapping.status] || "planned"
: "planned",
status: effectiveMapping.status ? row[effectiveMapping.status] || "planned" : "planned",
progress,
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
data: row,
@@ -228,26 +210,20 @@ export function useTimelineData(
}
try {
const response = await apiClient.post(
`/table-management/tables/${resourceTableName}/data`,
{
page: 1,
size: 1000,
autoFilter: true,
}
);
const response = await apiClient.post(`/table-management/tables/${resourceTableName}/data`, {
page: 1,
size: 1000,
autoFilter: true,
});
const responseData =
response.data?.data?.data || response.data?.data || [];
const responseData = response.data?.data?.data || response.data?.data || [];
const rawData = Array.isArray(responseData) ? responseData : [];
// 데이터를 Resource 형태로 변환
const mappedResources: Resource[] = rawData.map((row: any) => ({
id: String(row[resourceFieldMapping.id] || ""),
name: String(row[resourceFieldMapping.name] || ""),
group: resourceFieldMapping.group
? row[resourceFieldMapping.group]
: undefined,
group: resourceFieldMapping.group ? row[resourceFieldMapping.group] : undefined,
}));
setResources(mappedResources);
@@ -270,44 +246,41 @@ export function useTimelineData(
// 이벤트 버스 리스너 - 테이블 선택 변경 (품목 선택 시 해당 스케줄만 표시)
useEffect(() => {
const unsubscribeSelection = v2EventBus.subscribe(
V2_EVENTS.TABLE_SELECTION_CHANGE,
(payload) => {
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
tableName: payload.tableName,
selectedCount: payload.selectedCount,
});
const unsubscribeSelection = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, (payload) => {
console.log("[useTimelineData] TABLE_SELECTION_CHANGE 수신:", {
tableName: payload.tableName,
selectedCount: payload.selectedCount,
});
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
const groupByField = config.sourceConfig?.groupByField;
// 설정된 그룹 필드명 사용 (없으면 기본값들 fallback)
const groupByField = config.sourceConfig?.groupByField;
// 선택된 데이터에서 source_group_key 추출
const sourceKeys: string[] = [];
for (const row of payload.selectedRows || []) {
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
let key: string | undefined;
if (groupByField && row[groupByField]) {
key = row[groupByField];
} else {
// fallback: 일반적으로 사용되는 필드명들
key = row.part_code || row.source_group_key || row.item_code;
}
if (key && !sourceKeys.includes(key)) {
sourceKeys.push(key);
}
// 선택된 데이터에서 source_group_key 추출
const sourceKeys: string[] = [];
for (const row of payload.selectedRows || []) {
// 설정된 필드명 우선, 없으면 일반적인 필드명 fallback
let key: string | undefined;
if (groupByField && row[groupByField]) {
key = row[groupByField];
} else {
// fallback: 일반적으로 사용되는 필드명들
key = row.part_code || row.source_group_key || row.item_code;
}
console.log("[useTimelineData] 선택된 그룹 키:", {
groupByField,
keys: sourceKeys,
});
// 상태 업데이트 및 ref 동기화
selectedSourceKeysRef.current = sourceKeys;
setSelectedSourceKeys(sourceKeys);
if (key && !sourceKeys.includes(key)) {
sourceKeys.push(key);
}
}
);
console.log("[useTimelineData] 선택된 그룹 키:", {
groupByField,
keys: sourceKeys,
});
// 상태 업데이트 및 ref 동기화
selectedSourceKeysRef.current = sourceKeys;
setSelectedSourceKeys(sourceKeys);
});
return () => {
unsubscribeSelection();
@@ -325,27 +298,21 @@ export function useTimelineData(
// 이벤트 버스 리스너 - 스케줄 생성 완료 및 테이블 새로고침
useEffect(() => {
// TABLE_REFRESH 이벤트 수신 - 스케줄 새로고침
const unsubscribeRefresh = v2EventBus.subscribe(
V2_EVENTS.TABLE_REFRESH,
(payload) => {
// schedule_mng 또는 해당 테이블에 대한 새로고침
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
const unsubscribeRefresh = v2EventBus.subscribe(V2_EVENTS.TABLE_REFRESH, (payload) => {
// schedule_mng 또는 해당 테이블에 대한 새로고침
if (payload.tableName === tableName || payload.tableName === SCHEDULE_TABLE) {
console.log("[useTimelineData] TABLE_REFRESH 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
);
});
// SCHEDULE_GENERATE_COMPLETE 이벤트 수신 - 스케줄 자동 생성 완료 시 새로고침
const unsubscribeComplete = v2EventBus.subscribe(
V2_EVENTS.SCHEDULE_GENERATE_COMPLETE,
(payload) => {
if (payload.success) {
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
const unsubscribeComplete = v2EventBus.subscribe(V2_EVENTS.SCHEDULE_GENERATE_COMPLETE, (payload) => {
if (payload.success) {
console.log("[useTimelineData] SCHEDULE_GENERATE_COMPLETE 수신, 스케줄 새로고침:", payload);
fetchSchedules();
}
);
});
return () => {
unsubscribeRefresh();
@@ -390,23 +357,20 @@ export function useTimelineData(
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
if (updates.title) updateData[fieldMapping.title] = updates.title;
if (updates.status && fieldMapping.status)
updateData[fieldMapping.status] = updates.status;
if (updates.status && fieldMapping.status) updateData[fieldMapping.status] = updates.status;
if (updates.progress !== undefined && fieldMapping.progress)
updateData[fieldMapping.progress] = updates.progress;
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
// 로컬 상태 업데이트
setSchedules((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
);
setSchedules((prev) => prev.map((s) => (s.id === id ? { ...s, ...updates } : s)));
} catch (err: any) {
console.error("스케줄 업데이트 오류:", err);
throw err;
}
},
[tableName, fieldMapping, config.editable]
[tableName, fieldMapping, config.editable],
);
// 스케줄 추가
@@ -427,10 +391,7 @@ export function useTimelineData(
if (fieldMapping.progress && schedule.progress !== undefined)
insertData[fieldMapping.progress] = schedule.progress;
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
insertData
);
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, insertData);
const newId = response.data?.data?.id || Date.now().toString();
@@ -441,7 +402,7 @@ export function useTimelineData(
throw err;
}
},
[tableName, fieldMapping, config.editable]
[tableName, fieldMapping, config.editable],
);
// 스케줄 삭제
@@ -459,7 +420,7 @@ export function useTimelineData(
throw err;
}
},
[tableName, config.editable]
[tableName, config.editable],
);
// 새로고침

View File

@@ -10,12 +10,7 @@ export type ZoomLevel = "day" | "week" | "month";
/**
* 스케줄 상태
*/
export type ScheduleStatus =
| "planned"
| "in_progress"
| "completed"
| "delayed"
| "cancelled";
export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
/**
* 스케줄 항목 (간트 바)
@@ -107,10 +102,10 @@ export interface ResourceFieldMapping {
* 스케줄 타입 (schedule_mng.schedule_type)
*/
export type ScheduleType =
| "PRODUCTION" // 생산계획
| "MAINTENANCE" // 정비계획
| "SHIPPING" // 배차계획
| "WORK_ASSIGN"; // 작업배정
| "PRODUCTION" // 생산계획
| "MAINTENANCE" // 정비계획
| "SHIPPING" // 배차계획
| "WORK_ASSIGN"; // 작업배정
/**
* 소스 데이터 설정 (스케줄 생성 기준이 되는 원본 데이터)