diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index c1553b38..bc7b995d 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -230,15 +230,115 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { ...prev, [identifier]: result.data.rows[0], })); + } else { + // 데이터가 없는 경우에도 "로드 완료" 상태로 표시 (빈 객체 저장) + setTripInfo((prev) => ({ + ...prev, + [identifier]: { _noData: true }, + })); } + } else { + // API 실패 시에도 "로드 완료" 상태로 표시 + setTripInfo((prev) => ({ + ...prev, + [identifier]: { _noData: true }, + })); } } catch (err) { console.error("공차/운행 정보 로드 실패:", err); + // 에러 시에도 "로드 완료" 상태로 표시 + setTripInfo((prev) => ({ + ...prev, + [identifier]: { _noData: true }, + })); } setTripInfoLoading(null); }, [tripInfo]); + // 마커 로드 시 운행/공차 정보 미리 일괄 조회 + const preloadTripInfo = useCallback(async (loadedMarkers: MarkerData[]) => { + if (!loadedMarkers || loadedMarkers.length === 0) return; + + // 마커에서 identifier 추출 (user_id 또는 vehicle_number) + const identifiers: string[] = []; + loadedMarkers.forEach((marker) => { + try { + const parsed = JSON.parse(marker.description || "{}"); + const identifier = parsed.user_id || parsed.vehicle_number || parsed.id; + if (identifier && !tripInfo[identifier]) { + identifiers.push(identifier); + } + } catch { + // 파싱 실패 시 무시 + } + }); + + if (identifiers.length === 0) return; + + try { + // 모든 마커의 운행/공차 정보를 한 번에 조회 + const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); + const query = `SELECT + id, vehicle_number, user_id, + last_trip_start, last_trip_end, last_trip_distance, last_trip_time, + last_empty_start, last_empty_end, last_empty_distance, last_empty_time, + departure, arrival, status + FROM vehicles + WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")}) + OR vehicle_number IN (${identifiers.map(id => `'${id}'`).join(", ")})`; + + const response = await fetch(getApiUrl("/api/dashboards/execute-query"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${typeof window !== "undefined" ? localStorage.getItem("authToken") || "" : ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (response.ok) { + const result = await response.json(); + if (result.success && result.data.rows.length > 0) { + const newTripInfo: Record = {}; + + // 조회된 데이터를 identifier별로 매핑 + result.data.rows.forEach((row: any) => { + const hasData = row.last_trip_start || row.last_trip_end || + row.last_trip_distance || row.last_trip_time || + row.last_empty_start || row.last_empty_end || + row.last_empty_distance || row.last_empty_time; + + if (row.user_id) { + newTripInfo[row.user_id] = hasData ? row : { _noData: true }; + } + if (row.vehicle_number) { + newTripInfo[row.vehicle_number] = hasData ? row : { _noData: true }; + } + }); + + // 조회되지 않은 identifier는 _noData로 표시 + identifiers.forEach((id) => { + if (!newTripInfo[id]) { + newTripInfo[id] = { _noData: true }; + } + }); + + setTripInfo((prev) => ({ ...prev, ...newTripInfo })); + } else { + // 결과가 없으면 모든 identifier를 _noData로 표시 + const noDataInfo: Record = {}; + identifiers.forEach((id) => { + noDataInfo[id] = { _noData: true }; + }); + setTripInfo((prev) => ({ ...prev, ...noDataInfo })); + } + } + } catch (err) { + console.error("운행/공차 정보 미리 로드 실패:", err); + } + }, [tripInfo]); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { if (!dataSources || dataSources.length === 0) { @@ -311,6 +411,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { setMarkers(markersWithHeading); setPolygons(allPolygons); setLastRefreshTime(new Date()); + + // 마커 로드 후 운행/공차 정보 미리 일괄 조회 + preloadTripInfo(markersWithHeading); } catch (err: any) { setError(err.message); } finally { @@ -1856,6 +1959,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return mins > 0 ? `${hours}시간 ${mins}분` : `${hours}시간`; }; + // 이미 로드했는데 데이터가 없는 경우 (버튼 숨김) + const loadedInfo = tripInfo[identifier]; + if (loadedInfo && loadedInfo._noData) { + return null; // 데이터 없음 - 버튼도 정보도 표시 안 함 + } + // 데이터가 없고 아직 로드 안 했으면 로드 버튼 표시 if (!hasEmptyTripInfo && !hasTripInfo && !tripInfo[identifier]) { return ( diff --git a/frontend/components/tax-invoice/TaxInvoiceForm.tsx b/frontend/components/tax-invoice/TaxInvoiceForm.tsx index 9112ad33..9748e9e3 100644 --- a/frontend/components/tax-invoice/TaxInvoiceForm.tsx +++ b/frontend/components/tax-invoice/TaxInvoiceForm.tsx @@ -62,7 +62,7 @@ import { CostType, costTypeLabels, } from "@/lib/api/taxInvoice"; -import { apiClient } from "@/lib/api/client"; +import { uploadFiles } from "@/lib/api/file"; interface TaxInvoiceFormProps { open: boolean; @@ -223,36 +223,35 @@ export function TaxInvoiceForm({ open, onClose, onSave, invoice }: TaxInvoiceFor }); }; - // 파일 업로드 + // 파일 업로드 (화면 관리 파일 업로드 컴포넌트와 동일한 방식 사용) const handleFileUpload = async (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; setUploading(true); try { - for (const file of Array.from(files)) { - const formDataUpload = new FormData(); - formDataUpload.append("files", file); // 백엔드 Multer 필드명: "files" - formDataUpload.append("category", "tax-invoice"); + // 화면 관리 파일 업로드 컴포넌트와 동일한 uploadFiles 함수 사용 + const response = await uploadFiles({ + files: files, + tableName: "tax_invoice", + fieldName: "attachments", + recordId: invoice?.id, + docType: "tax-invoice", + docTypeName: "세금계산서", + }); - const response = await apiClient.post("/files/upload", formDataUpload, { - headers: { "Content-Type": "multipart/form-data" }, - }); - - if (response.data.success && response.data.files?.length > 0) { - const uploadedFile = response.data.files[0]; - const newAttachment: TaxInvoiceAttachment = { - id: uploadedFile.objid || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - file_name: uploadedFile.realFileName || file.name, - file_path: uploadedFile.filePath, - file_size: uploadedFile.fileSize || file.size, - file_type: file.type, - uploaded_at: new Date().toISOString(), - uploaded_by: "", - }; - setAttachments((prev) => [...prev, newAttachment]); - toast.success(`'${file.name}' 업로드 완료`); - } + if (response.success && response.files?.length > 0) { + const newAttachments: TaxInvoiceAttachment[] = response.files.map((uploadedFile) => ({ + id: uploadedFile.id || `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + file_name: uploadedFile.name, + file_path: uploadedFile.serverPath || "", + file_size: uploadedFile.size, + file_type: uploadedFile.type, + uploaded_at: uploadedFile.uploadedAt || new Date().toISOString(), + uploaded_by: "", + })); + setAttachments((prev) => [...prev, ...newAttachments]); + toast.success(`${response.files.length}개 파일 업로드 완료`); } } catch (error: any) { toast.error("파일 업로드 실패", { description: error.message }); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 816483fc..b039ac38 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -447,10 +447,13 @@ export const DynamicComponentRenderer: React.FC = // 디자인 모드 플래그 전달 - isPreview와 명확히 구분 isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) - groupedData: props.groupedData, + // Note: 이 props들은 DOM 요소에 전달되면 안 됨 + // 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함 + _groupedData: props.groupedData, // 🆕 UniversalFormModal용 initialData 전달 // originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨) - initialData: originalData || formData, + _initialData: originalData || formData, + _originalData: originalData, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx index 84955c3e..6303cdee 100644 --- a/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx +++ b/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableComponent.tsx @@ -48,17 +48,28 @@ export function SimpleRepeaterTableComponent({ allowAdd: propAllowAdd, maxHeight: propMaxHeight, - // DOM에 전달되면 안 되는 props 명시적 제거 (부모에서 전달될 수 있음) - initialData: _initialData, - originalData: _originalData, - groupedData: _groupedData, + // DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용) + _initialData, + _originalData, + _groupedData, + // 레거시 호환성 (일부 컴포넌트에서 직접 전달할 수 있음) + initialData: legacyInitialData, + originalData: legacyOriginalData, + groupedData: legacyGroupedData, ...props }: SimpleRepeaterTableComponentProps & { + _initialData?: any; + _originalData?: any; + _groupedData?: any; initialData?: any; originalData?: any; groupedData?: any; }) { + // 실제 사용할 데이터 (새 props 우선, 레거시 fallback) + const effectiveInitialData = _initialData || legacyInitialData; + const effectiveOriginalData = _originalData || legacyOriginalData; + const effectiveGroupedData = _groupedData || legacyGroupedData; // config 또는 component.config 또는 개별 prop 우선순위로 병합 const componentConfig = { ...config, diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 11b3aa43..1ff1bb70 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -48,11 +48,16 @@ export function UniversalFormModalComponent({ isSelected = false, className, style, - initialData, + initialData: propInitialData, + // DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용) + _initialData, onSave, onCancel, onChange, -}: UniversalFormModalComponentProps) { + ...restProps // 나머지 props는 DOM에 전달하지 않음 +}: UniversalFormModalComponentProps & { _initialData?: any }) { + // initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop + const initialData = propInitialData || _initialData; // 설정 병합 const config: UniversalFormModalConfig = useMemo(() => { const componentConfig = component?.config || {};