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 Polygon = dynamic(() => import("react-leaflet").then((mod) => mod.Polygon), { 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 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
@ -78,6 +79,13 @@ interface PolygonData {
opacity?: number; // 투명도 (0.0 ~ 1.0)
}
// 이동경로 타입
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
}
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [markers, setMarkers] = useState<MarkerData[]>([]);
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
@ -86,6 +94,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [error, setError] = useState<string | null>(null);
const [geoJsonData, setGeoJsonData] = useState<any>(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 방지)
const dataSources = useMemo(() => {
@ -107,6 +121,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
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 () => {
if (!dataSources || dataSources.length === 0) {
@ -509,7 +587,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
status: row.status || row.level,
description: JSON.stringify(row, null, 2), // 항상 전체 row 객체를 JSON으로 저장
source: sourceName,
color: dataSource?.markerColor || "#3b82f6", // 사용자 지정 색상 또는 기본 파랑
color: row.marker_color || row.color || dataSource?.markerColor || "#3b82f6", // 쿼리 색상 > 설정 색상 > 기본 파랑
});
} else {
// 위도/경도가 없는 육지 지역 → 폴리곤으로 추가 (GeoJSON 매칭용)
@ -1005,6 +1083,32 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
</p>
</div>
<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
variant="outline"
size="sm"
@ -1305,6 +1409,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 트럭 마커
// 트럭 아이콘이 오른쪽(90도)을 보고 있으므로, 북쪽(0도)으로 가려면 -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({
className: "custom-truck-marker",
@ -1315,7 +1427,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
display: flex;
align-items: 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));
">
<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]">
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</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>
</Popup>
</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>
)}
</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 Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { 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 키
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
@ -37,6 +38,16 @@ interface Vehicle {
status: "active" | "inactive" | "maintenance" | "warning" | "off";
speed: number;
destination: string;
userId?: string; // 이동경로 조회용
tripId?: string; // 현재 운행 ID
}
// 이동경로 좌표
interface RoutePoint {
lat: number;
lng: number;
recordedAt: string;
speed?: number;
}
interface VehicleMapOnlyWidgetProps {
@ -48,6 +59,11 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [isLoading, setIsLoading] = useState(false);
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 () => {
setIsLoading(true);
@ -121,6 +137,8 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
: "inactive",
speed: parseFloat(row.speed) || 0,
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);
};
// 이동경로 로드 함수
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(() => {
loadVehicles();
@ -220,6 +310,19 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
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) => (
<React.Fragment key={vehicle.id}>
@ -248,6 +351,20 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
<div>
<strong>:</strong> {vehicle.destination}
</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>
</Popup>
</Marker>
@ -271,6 +388,24 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
<div className="text-xs text-foreground"> </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>

View File

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

View File

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