Merge pull request 'lhj' (#271) from lhj into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/271
This commit is contained in:
hjlee 2025-12-10 16:49:21 +09:00
commit e84764dc2b
5 changed files with 159 additions and 32 deletions

View File

@ -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<string, any> = {};
// 조회된 데이터를 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<string, any> = {};
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 (

View File

@ -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<HTMLInputElement>) => {
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 });

View File

@ -447,10 +447,13 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 디자인 모드 플래그 전달 - 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,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -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,

View File

@ -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 || {};