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:
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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] ||
|
||||
"버튼";
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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 사용
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
// 새로고침
|
||||
|
||||
@@ -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"; // 작업배정
|
||||
|
||||
/**
|
||||
* 소스 데이터 설정 (스케줄 생성 기준이 되는 원본 데이터)
|
||||
|
||||
Reference in New Issue
Block a user