Compare commits

...

6 Commits

4 changed files with 486 additions and 193 deletions

View File

@ -43,6 +43,7 @@ const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker),
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false }); const Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { ssr: false });
const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false }); const GeoJSON = dynamic(() => import("react-leaflet").then((mod) => mod.GeoJSON), { ssr: false });
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
// 브이월드 API 키 // 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
@ -78,6 +79,13 @@ interface PolygonData {
opacity?: number; // 투명도 (0.0 ~ 1.0) opacity?: number; // 투명도 (0.0 ~ 1.0)
} }
// 이동경로 타입
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
}
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [markers, setMarkers] = useState<MarkerData[]>([]); const [markers, setMarkers] = useState<MarkerData[]>([]);
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용) const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
@ -87,6 +95,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [geoJsonData, setGeoJsonData] = useState<any>(null); const [geoJsonData, setGeoJsonData] = useState<any>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null); const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
// 이동경로 상태
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const [routeDate, setRouteDate] = useState<string>(new Date().toISOString().split('T')[0]); // YYYY-MM-DD 형식
// dataSources를 useMemo로 추출 (circular reference 방지) // dataSources를 useMemo로 추출 (circular reference 방지)
const dataSources = useMemo(() => { const dataSources = useMemo(() => {
return element?.dataSources || element?.chartConfig?.dataSources; return element?.dataSources || element?.chartConfig?.dataSources;
@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
return heading; return heading;
}, []); }, []);
// 이동경로 로드 함수
const loadRoute = useCallback(async (userId: string, date?: string) => {
if (!userId) {
console.log("🛣️ 이동경로 조회 불가: userId 없음");
return;
}
setRouteLoading(true);
setSelectedUserId(userId);
try {
// 선택한 날짜 기준으로 이동경로 조회
const targetDate = date || routeDate;
const startOfDay = `${targetDate}T00:00:00.000Z`;
const endOfDay = `${targetDate}T23:59:59.999Z`;
const query = `SELECT latitude, longitude, recorded_at
FROM vehicle_location_history
WHERE user_id = '${userId}'
AND recorded_at >= '${startOfDay}'
AND recorded_at <= '${endOfDay}'
ORDER BY recorded_at ASC`;
console.log("🛣️ 이동경로 쿼리:", query);
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 points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
}));
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
setRoutePoints(points);
} else {
console.log("🛣️ 이동경로 데이터 없음");
setRoutePoints([]);
}
}
} catch (error) {
console.error("이동경로 로드 실패:", error);
setRoutePoints([]);
}
setRouteLoading(false);
}, [routeDate]);
// 이동경로 숨기기
const clearRoute = useCallback(() => {
setSelectedUserId(null);
setRoutePoints([]);
}, []);
// 다중 데이터 소스 로딩 // 다중 데이터 소스 로딩
const loadMultipleDataSources = useCallback(async () => { const loadMultipleDataSources = useCallback(async () => {
if (!dataSources || dataSources.length === 0) { if (!dataSources || dataSources.length === 0) {
@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level, status: row.status || row.level,
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장 description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
source: sourceName, source: sourceName,
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑 color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
}); });
} else { } else {
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용) // 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 이동경로 날짜 선택 */}
{selectedUserId && (
<div className="flex items-center gap-1 rounded border bg-blue-50 px-2 py-1">
<span className="text-xs text-blue-600">🛣</span>
<input
type="date"
value={routeDate}
onChange={(e) => {
setRouteDate(e.target.value);
if (selectedUserId) {
loadRoute(selectedUserId, e.target.value);
}
}}
className="h-6 rounded border-none bg-transparent px-1 text-xs text-blue-600 focus:outline-none"
/>
<span className="text-xs text-blue-600">
({routePoints.length})
</span>
<button
onClick={clearRoute}
className="ml-1 text-xs text-blue-400 hover:text-blue-600"
>
</button>
</div>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -1306,6 +1410,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요 // 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -90도 회전 필요
const rotation = heading - 90; const rotation = heading - 90;
// 회전 각도가 90~270도 범위면 차량이 뒤집어짐 (바퀴가 위로)
// 이 경우 scaleY(-1)로 상하 반전하여 바퀴가 아래로 오도록 함
const normalizedRotation = ((rotation % 360) + 360) % 360;
const isFlipped = normalizedRotation > 90 && normalizedRotation < 270;
const transformStyle = isFlipped
? `translate(-50%, -50%) rotate(${rotation}deg) scaleY(-1)`
: `translate(-50%, -50%) rotate(${rotation}deg)`;
markerIcon = L.divIcon({ markerIcon = L.divIcon({
className: "custom-truck-marker", className: "custom-truck-marker",
html: ` html: `
@ -1315,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transform: translate(-50%, -50%) rotate(${rotation}deg); transform: ${transformStyle};
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
"> ">
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg"> <svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
@ -1528,12 +1640,51 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
<div className="text-muted-foreground border-t pt-2 text-[10px]"> <div className="text-muted-foreground border-t pt-2 text-[10px]">
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</div> </div>
{/* 이동경로 버튼 */}
{(() => {
try {
const parsed = JSON.parse(marker.description || "{}");
const userId = parsed.user_id;
if (userId) {
return (
<div className="mt-2 border-t pt-2">
<button
onClick={() => loadRoute(userId)}
disabled={routeLoading}
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
>
{routeLoading && selectedUserId === userId
? "로딩 중..."
: "🛣️ 이동경로 보기"}
</button>
</div>
);
}
return null;
} catch {
return null;
}
})()}
</div> </div>
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
); );
})} })}
{/* 이동경로 Polyline */}
{routePoints.length > 1 && (
<Polyline
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
pathOptions={{
color: "#3b82f6",
weight: 4,
opacity: 0.8,
dashArray: "10, 5",
}}
/>
)}
</MapContainer> </MapContainer>
)} )}
</div> </div>

View File

@ -24,6 +24,7 @@ const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLa
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false }); const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false }); const Circle = dynamic(() => import("react-leaflet").then((mod) => mod.Circle), { ssr: false });
const Polyline = dynamic(() => import("react-leaflet").then((mod) => mod.Polyline), { ssr: false });
// 브이월드 API 키 // 브이월드 API 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
@ -37,6 +38,16 @@ interface Vehicle {
status: "active" | "inactive" | "maintenance" | "warning" | "off"; status: "active" | "inactive" | "maintenance" | "warning" | "off";
speed: number; speed: number;
destination: string; destination: string;
userId?: string; // 이동경로 조회용
tripId?: string; // 현재 운행 ID
}
// 이동경로 좌표
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
speed?: number;
} }
interface VehicleMapOnlyWidgetProps { interface VehicleMapOnlyWidgetProps {
@ -49,6 +60,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [lastUpdate, setLastUpdate] = useState<Date>(new Date()); const [lastUpdate, setLastUpdate] = useState<Date>(new Date());
// 이동경로 상태
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const [routePoints, setRoutePoints] = useState<RoutePoint[]>([]);
const [isRouteLoading, setIsRouteLoading] = useState(false);
const loadVehicles = async () => { const loadVehicles = async () => {
setIsLoading(true); setIsLoading(true);
@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
: "inactive", : "inactive",
speed: parseFloat(row.speed) || 0, speed: parseFloat(row.speed) || 0,
destination: row.destination || "대기 중", destination: row.destination || "대기 중",
userId: row.user_id || row.userId || undefined,
tripId: row.trip_id || row.tripId || undefined,
}; };
}) })
// 유효한 위도/경도가 있는 차량만 필터링 // 유효한 위도/경도가 있는 차량만 필터링
@ -140,6 +158,78 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
setIsLoading(false); setIsLoading(false);
}; };
// 이동경로 로드 함수
const loadRoute = async (vehicle: Vehicle) => {
if (!vehicle.userId && !vehicle.tripId) {
console.log("🛣️ 이동경로 조회 불가: userId 또는 tripId 없음");
return;
}
setIsRouteLoading(true);
setSelectedVehicle(vehicle);
try {
// 오늘 날짜 기준으로 최근 이동경로 조회
const today = new Date();
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).toISOString();
// trip_id가 있으면 해당 운행만, 없으면 user_id로 오늘 전체 조회
let query = "";
if (vehicle.tripId) {
query = `SELECT latitude, longitude, speed, recorded_at
FROM vehicle_location_history
WHERE trip_id = '${vehicle.tripId}'
ORDER BY recorded_at ASC`;
} else if (vehicle.userId) {
query = `SELECT latitude, longitude, speed, recorded_at
FROM vehicle_location_history
WHERE user_id = '${vehicle.userId}'
AND recorded_at >= '${startOfDay}'
ORDER BY recorded_at ASC`;
}
console.log("🛣️ 이동경로 쿼리:", query);
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 points: RoutePoint[] = result.data.rows.map((row: any) => ({
lat: parseFloat(row.latitude),
lng: parseFloat(row.longitude),
recordedAt: row.recorded_at,
speed: row.speed ? parseFloat(row.speed) : undefined,
}));
console.log(`🛣️ 이동경로 ${points.length}개 포인트 로드 완료`);
setRoutePoints(points);
} else {
console.log("🛣️ 이동경로 데이터 없음");
setRoutePoints([]);
}
}
} catch (error) {
console.error("이동경로 로드 실패:", error);
setRoutePoints([]);
}
setIsRouteLoading(false);
};
// 이동경로 숨기기
const clearRoute = () => {
setSelectedVehicle(null);
setRoutePoints([]);
};
// useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치) // useEffect는 항상 같은 순서로 호출되어야 함 (early return 전에 배치)
useEffect(() => { useEffect(() => {
loadVehicles(); loadVehicles();
@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
keepBuffer={2} keepBuffer={2}
/> />
{/* 이동경로 Polyline */}
{routePoints.length > 1 && (
<Polyline
positions={routePoints.map((p) => [p.lat, p.lng] as [number, number])}
pathOptions={{
color: "#3b82f6",
weight: 4,
opacity: 0.8,
dashArray: "10, 5",
}}
/>
)}
{/* 차량 마커 */} {/* 차량 마커 */}
{vehicles.map((vehicle) => ( {vehicles.map((vehicle) => (
<React.Fragment key={vehicle.id}> <React.Fragment key={vehicle.id}>
@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
<div> <div>
<strong>:</strong> {vehicle.destination} <strong>:</strong> {vehicle.destination}
</div> </div>
{/* 이동경로 버튼 */}
{(vehicle.userId || vehicle.tripId) && (
<div className="mt-2 border-t pt-2">
<button
onClick={() => loadRoute(vehicle)}
disabled={isRouteLoading}
className="w-full rounded bg-blue-500 px-2 py-1 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
>
{isRouteLoading && selectedVehicle?.id === vehicle.id
? "로딩 중..."
: "🛣️ 이동경로 보기"}
</button>
</div>
)}
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
<div className="text-xs text-foreground"> </div> <div className="text-xs text-foreground"> </div>
)} )}
</div> </div>
{/* 이동경로 정보 표시 - 상단으로 이동하여 주석 처리 */}
{/* {selectedVehicle && routePoints.length > 0 && (
<div className="absolute bottom-2 right-2 z-[1000] rounded-lg bg-blue-500/90 p-2 shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2">
<div className="text-xs text-white">
<div className="font-semibold">🛣 {selectedVehicle.name} </div>
<div>{routePoints.length} </div>
</div>
<button
onClick={clearRoute}
className="rounded bg-white/20 px-2 py-1 text-xs text-white hover:bg-white/30"
>
</button>
</div>
</div>
)} */}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1183,13 +1183,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable, referenceTable: col.additionalJoinInfo!.referenceTable,
})); }));
// console.log("🔍 [TableList] API 호출 시작", { // 🎯 화면별 엔티티 표시 설정 수집
// tableName: tableConfig.selectedTable, const screenEntityConfigs: Record<string, any> = {};
// page, (tableConfig.columns || [])
// pageSize, .filter((col) => col.entityDisplayConfig && col.entityDisplayConfig.displayColumns?.length > 0)
// sortBy, .forEach((col) => {
// sortOrder, screenEntityConfigs[col.columnName] = {
// }); displayColumns: col.entityDisplayConfig!.displayColumns,
separator: col.entityDisplayConfig!.separator || " - ",
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
joinTable: col.entityDisplayConfig!.joinTable,
};
});
console.log("🎯 [TableList] 화면별 엔티티 설정:", screenEntityConfigs);
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
@ -1200,6 +1207,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
search: hasFilters ? filters : undefined, search: hasFilters ? filters : undefined,
enableEntityJoin: true, enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정 전달
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
}); });
@ -1756,33 +1764,46 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const formatCellValue = useCallback( const formatCellValue = useCallback(
(value: any, column: ColumnConfig, rowData?: Record<string, any>) => { (value: any, column: ColumnConfig, rowData?: Record<string, any>) => {
if (value === null || value === undefined) return "-"; // 🎯 엔티티 컬럼 표시 설정이 있는 경우 - value가 null이어도 rowData에서 조합 가능
// 이 체크를 가장 먼저 수행 (null 체크보다 앞에)
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
// 🎯 엔티티 컬럼 표시 설정이 있는 경우
if (column.entityDisplayConfig && rowData) { if (column.entityDisplayConfig && rowData) {
// displayColumns 또는 selectedColumns 둘 다 체크 const displayColumns = column.entityDisplayConfig.displayColumns || (column.entityDisplayConfig as any).selectedColumns;
const displayColumns = column.entityDisplayConfig.displayColumns || column.entityDisplayConfig.selectedColumns;
const separator = column.entityDisplayConfig.separator; const separator = column.entityDisplayConfig.separator;
if (displayColumns && displayColumns.length > 0) { if (displayColumns && displayColumns.length > 0) {
// 선택된 컬럼들의 값을 구분자로 조합 // 선택된 컬럼들의 값을 구분자로 조합
const values = displayColumns const values = displayColumns
.map((colName) => { .map((colName: string) => {
const cellValue = rowData[colName]; // 1. 먼저 직접 컬럼명으로 시도 (기본 테이블 컬럼인 경우)
let cellValue = rowData[colName];
// 2. 없으면 ${sourceColumn}_${colName} 형식으로 시도 (조인 테이블 컬럼인 경우)
if (cellValue === null || cellValue === undefined) {
const joinedKey = `${column.columnName}_${colName}`;
cellValue = rowData[joinedKey];
}
if (cellValue === null || cellValue === undefined) return ""; if (cellValue === null || cellValue === undefined) return "";
return String(cellValue); return String(cellValue);
}) })
.filter((v) => v !== ""); // 빈 값 제외 .filter((v: string) => v !== ""); // 빈 값 제외
return values.join(separator || " - "); const result = values.join(separator || " - ");
if (result) {
return result; // 결과가 있으면 반환
}
// 결과가 비어있으면 아래로 계속 진행 (원래 값 사용)
} }
} }
// value가 null/undefined면 "-" 반환
if (value === null || value === undefined) return "-";
// 🎯 writer 컬럼 자동 변환: user_id -> user_name
if (column.columnName === "writer" && rowData && rowData.writer_name) {
return rowData.writer_name;
}
const meta = columnMeta[column.columnName]; const meta = columnMeta[column.columnName];
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)

View File

@ -467,42 +467,22 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드 // 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
const loadEntityDisplayConfig = async (column: ColumnConfig) => { const loadEntityDisplayConfig = async (column: ColumnConfig) => {
if (!column.isEntityJoin || !column.entityJoinInfo) { const configKey = `${column.columnName}`;
return;
}
// entityDisplayConfig가 없으면 초기화 // 이미 로드된 경우 스킵
if (!column.entityDisplayConfig) { if (entityDisplayConfigs[configKey]) return;
// sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
if (!initialSourceTable) { if (!column.isEntityJoin) {
return; // 엔티티 컬럼이 아니면 빈 상태로 설정하여 로딩 상태 해제
} setEntityDisplayConfigs((prev) => ({
...prev,
const updatedColumns = config.columns?.map((col) => { [configKey]: {
if (col.columnName === column.columnName) { sourceColumns: [],
return { joinColumns: [],
...col, selectedColumns: [],
entityDisplayConfig: { separator: " - ",
displayColumns: [], },
separator: " - ", }));
sourceTable: initialSourceTable,
joinTable: "",
},
};
}
return col;
});
if (updatedColumns) {
handleChange("columns", updatedColumns);
// 업데이트된 컬럼으로 다시 시도
const updatedColumn = updatedColumns.find((col) => col.columnName === column.columnName);
if (updatedColumn) {
return loadEntityDisplayConfig(updatedColumn);
}
}
return; return;
} }
@ -512,32 +492,56 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 3. config.selectedTable // 3. config.selectedTable
// 4. screenTableName // 4. screenTableName
const sourceTable = const sourceTable =
column.entityDisplayConfig.sourceTable || column.entityDisplayConfig?.sourceTable ||
column.entityJoinInfo?.sourceTable || column.entityJoinInfo?.sourceTable ||
config.selectedTable || config.selectedTable ||
screenTableName; screenTableName;
let joinTable = column.entityDisplayConfig.joinTable; // sourceTable이 비어있으면 빈 상태로 설정
// sourceTable이 여전히 비어있으면 에러
if (!sourceTable) { if (!sourceTable) {
console.warn("⚠️ sourceTable을 찾을 수 없음:", column.columnName);
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
return; return;
} }
if (!joinTable && sourceTable) { let joinTable = column.entityDisplayConfig?.joinTable;
// joinTable이 없으면 tableTypeApi로 조회해서 설정
// joinTable이 없으면 tableTypeApi로 조회해서 설정
if (!joinTable) {
try { try {
console.log("🔍 tableTypeApi로 컬럼 정보 조회:", {
tableName: sourceTable,
columnName: column.columnName,
});
const columnList = await tableTypeApi.getColumns(sourceTable); const columnList = await tableTypeApi.getColumns(sourceTable);
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName); const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
console.log("🔍 컬럼 정보 조회 결과:", {
columnInfo: columnInfo,
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
});
if (columnInfo?.reference_table || columnInfo?.referenceTable) { if (columnInfo?.reference_table || columnInfo?.referenceTable) {
joinTable = columnInfo.reference_table || columnInfo.referenceTable; joinTable = columnInfo.reference_table || columnInfo.referenceTable;
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", joinTable);
// entityDisplayConfig 업데이트 // entityDisplayConfig 업데이트
const updatedConfig = { const updatedConfig = {
...column.entityDisplayConfig, ...column.entityDisplayConfig,
sourceTable: sourceTable, sourceTable: sourceTable,
joinTable: joinTable, joinTable: joinTable,
displayColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
}; };
// 컬럼 설정 업데이트 // 컬럼 설정 업데이트
@ -553,74 +557,27 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
} }
} catch (error) { } catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error); console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
} }
} else if (!joinTable) {
console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
} }
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable }); console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
const configKey = `${column.columnName}`;
// 이미 로드된 경우 스킵
if (entityDisplayConfigs[configKey]) return;
// joinTable이 비어있으면 tableTypeApi로 컬럼 정보를 다시 가져와서 referenceTable 정보를 찾기
let actualJoinTable = joinTable;
if (!actualJoinTable && sourceTable) {
try {
console.log("🔍 tableTypeApi로 컬럼 정보 다시 조회:", {
tableName: sourceTable,
columnName: column.columnName,
});
const columnList = await tableTypeApi.getColumns(sourceTable);
const columnInfo = columnList.find((col: any) => (col.column_name || col.columnName) === column.columnName);
console.log("🔍 컬럼 정보 조회 결과:", {
columnInfo: columnInfo,
referenceTable: columnInfo?.reference_table || columnInfo?.referenceTable,
referenceColumn: columnInfo?.reference_column || columnInfo?.referenceColumn,
});
if (columnInfo?.reference_table || columnInfo?.referenceTable) {
actualJoinTable = columnInfo.reference_table || columnInfo.referenceTable;
console.log("✅ tableTypeApi에서 조인 테이블 정보 찾음:", actualJoinTable);
// entityDisplayConfig 업데이트
const updatedConfig = {
...column.entityDisplayConfig,
joinTable: actualJoinTable,
};
// 컬럼 설정 업데이트
const updatedColumns = config.columns?.map((col) =>
col.columnName === column.columnName ? { ...col, entityDisplayConfig: updatedConfig } : col,
);
if (updatedColumns) {
handleChange("columns", updatedColumns);
}
}
} catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
}
}
// sourceTable과 joinTable이 모두 있어야 로드
if (!sourceTable || !actualJoinTable) {
return;
}
try { try {
// 기본 테이블과 조인 테이블의 컬럼 정보를 병렬로 로드 // 기본 테이블 컬럼 정보는 항상 로드
const [sourceResult, joinResult] = await Promise.all([ const sourceResult = await entityJoinApi.getReferenceTableColumns(sourceTable);
entityJoinApi.getReferenceTableColumns(sourceTable),
entityJoinApi.getReferenceTableColumns(actualJoinTable),
]);
const sourceColumns = sourceResult.columns || []; const sourceColumns = sourceResult.columns || [];
const joinColumns = joinResult.columns || [];
// joinTable이 있으면 조인 테이블 컬럼도 로드
let joinColumns: Array<{ columnName: string; displayName: string; dataType: string }> = [];
if (joinTable) {
try {
const joinResult = await entityJoinApi.getReferenceTableColumns(joinTable);
joinColumns = joinResult.columns || [];
} catch (joinError) {
console.warn("⚠️ 조인 테이블 컬럼 로드 실패:", joinTable, joinError);
// 조인 테이블 로드 실패해도 소스 테이블 컬럼은 표시
}
}
setEntityDisplayConfigs((prev) => ({ setEntityDisplayConfigs((prev) => ({
...prev, ...prev,
@ -633,6 +590,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
})); }));
} catch (error) { } catch (error) {
console.error("엔티티 표시 컬럼 정보 로드 실패:", error); console.error("엔티티 표시 컬럼 정보 로드 실패:", error);
// 에러 발생 시에도 빈 상태로 설정하여 로딩 상태 해제
setEntityDisplayConfigs((prev) => ({
...prev,
[configKey]: {
sourceColumns: [],
joinColumns: [],
selectedColumns: column.entityDisplayConfig?.displayColumns || [],
separator: column.entityDisplayConfig?.separator || " - ",
},
}));
} }
}; };
@ -873,76 +840,95 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
{/* 표시 컬럼 선택 (다중 선택) */} {/* 표시 컬럼 선택 (다중 선택) */}
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Popover> {entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
<PopoverTrigger asChild> entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
<Button <div className="py-2 text-center text-xs text-gray-400">
variant="outline" .
className="h-6 w-full justify-between text-xs" {!column.entityDisplayConfig?.joinTable && (
style={{ fontSize: "12px" }} <p className="mt-1 text-[10px]">
> .
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 </p>
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨` )}
: "컬럼 선택"} </div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> ) : (
</Button> <Popover>
</PopoverTrigger> <PopoverTrigger asChild>
<PopoverContent className="w-full p-0" align="start"> <Button
<Command> variant="outline"
<CommandInput placeholder="컬럼 검색..." className="text-xs" /> className="h-6 w-full justify-between text-xs"
<CommandList> style={{ fontSize: "12px" }}
<CommandEmpty className="text-xs"> .</CommandEmpty> >
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && ( {entityDisplayConfigs[column.columnName].selectedColumns.length > 0
<CommandGroup heading={`기본: ${column.entityDisplayConfig?.sourceTable}`}> ? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => ( : "컬럼 선택"}
<CommandItem <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
key={`source-${col.columnName}`} </Button>
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)} </PopoverTrigger>
className="text-xs" <PopoverContent className="w-full p-0" align="start">
> <Command>
<Check <CommandInput placeholder="컬럼 검색..." className="text-xs" />
className={cn( <CommandList>
"mr-2 h-4 w-4", <CommandEmpty className="text-xs"> .</CommandEmpty>
entityDisplayConfigs[column.columnName].selectedColumns.includes( {entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
col.columnName, <CommandGroup heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}>
) {entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
? "opacity-100" <CommandItem
: "opacity-0", key={`source-${col.columnName}`}
)} onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
/> className="text-xs"
{col.displayName} >
</CommandItem> <Check
))} className={cn(
</CommandGroup> "mr-2 h-4 w-4",
)} entityDisplayConfigs[column.columnName].selectedColumns.includes(
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && ( col.columnName,
<CommandGroup heading={`조인: ${column.entityDisplayConfig?.joinTable}`}> )
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => ( ? "opacity-100"
<CommandItem : "opacity-0",
key={`join-${col.columnName}`} )}
onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)} />
className="text-xs" {col.displayName}
> </CommandItem>
<Check ))}
className={cn( </CommandGroup>
"mr-2 h-4 w-4", )}
entityDisplayConfigs[column.columnName].selectedColumns.includes( {entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
col.columnName, <CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
) {entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
? "opacity-100" <CommandItem
: "opacity-0", key={`join-${col.columnName}`}
)} onSelect={() => toggleEntityDisplayColumn(column.columnName, col.columnName)}
/> className="text-xs"
{col.displayName} >
</CommandItem> <Check
))} className={cn(
</CommandGroup> "mr-2 h-4 w-4",
)} entityDisplayConfigs[column.columnName].selectedColumns.includes(
</CommandList> col.columnName,
</Command> )
</PopoverContent> ? "opacity-100"
</Popover> : "opacity-0",
)}
/>
{col.displayName}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div> </div>
{/* 참조 테이블 미설정 안내 */}
{!column.entityDisplayConfig?.joinTable && entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
. .
</div>
)}
{/* 선택된 컬럼 미리보기 */} {/* 선택된 컬럼 미리보기 */}
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && ( {entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
<div className="space-y-1"> <div className="space-y-1">