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

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/284
This commit is contained in:
hjlee 2025-12-12 15:47:15 +09:00
commit e8bc770439
5 changed files with 433 additions and 138 deletions

View File

@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
// 추가 데이터 조회 설정 // 추가 데이터 조회 설정
additionalQuery?: { additionalQuery?: {
enabled: boolean; enabled: boolean;
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
tableName: string; // 조회할 테이블명 (예: vehicles) tableName: string; // 조회할 테이블명 (예: vehicles)
matchColumn: string; // 매칭할 컬럼 (예: id) matchColumn: string; // 매칭할 컬럼 (예: id)
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일) sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시) // 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
displayColumns?: DisplayColumnConfig[]; displayColumns?: DisplayColumnConfig[];
}; };

View File

@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
checked={popupConfig.additionalQuery?.enabled || false} checked={popupConfig.additionalQuery?.enabled || false}
onCheckedChange={(enabled) => onCheckedChange={(enabled) =>
updatePopupConfig({ updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" }, additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
}) })
} }
aria-label="추가 데이터 조회 활성화" aria-label="추가 데이터 조회 활성화"
@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
{popupConfig.additionalQuery?.enabled && ( {popupConfig.additionalQuery?.enabled && (
<div className="space-y-2"> <div className="space-y-2">
{/* 조회 모드 선택 */}
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"> </Label>
<Input <Select
value={popupConfig.additionalQuery?.tableName || ""} value={popupConfig.additionalQuery?.queryMode || "table"}
onChange={(e) => onValueChange={(value: "table" | "custom") =>
updatePopupConfig({ updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value }, additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
}) })
} }
placeholder="vehicles" >
className="mt-1 h-8 text-xs" <SelectTrigger className="mt-1 h-8 text-xs">
/> <SelectValue />
</div> </SelectTrigger>
<div> <SelectContent>
<Label className="text-xs"> ( )</Label> <SelectItem value="table"> </SelectItem>
<Input <SelectItem value="custom"> </SelectItem>
value={popupConfig.additionalQuery?.matchColumn || ""} </SelectContent>
onChange={(e) => </Select>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
</div> </div>
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */} {/* 테이블 조회 모드 */}
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
<>
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
</div>
</>
)}
{/* 커스텀 쿼리 모드 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label className="text-xs"> </Label>
<textarea
value={popupConfig.additionalQuery?.customQuery || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
})
}
placeholder={`SELECT
v.vehicle_number AS "차량번호",
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
FROM vehicles v
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
WHERE v.id = {id}
GROUP BY v.id;`}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-muted-foreground mt-1 text-xs">
{"{id}"}, {"{vehicle_number}"}
</p>
</div>
</>
)}
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild> {/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs"> {popupConfig.additionalQuery?.queryMode !== "custom" && (
<span className="truncate"> <>
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 <Popover>
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨` <PopoverTrigger asChild>
: "전체 표시 (클릭하여 선택)"} <Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
</span> <span className="truncate">
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> {(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
</Button> ? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
</PopoverTrigger> : "전체 표시 (클릭하여 선택)"}
<PopoverContent className="w-72 p-2" align="start"> </span>
<div className="mb-2 flex items-center justify-between"> <ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
<span className="text-xs font-medium"> </span> </Button>
<Button </PopoverTrigger>
variant="ghost" <PopoverContent className="w-72 p-2" align="start">
size="sm" <div className="mb-2 flex items-center justify-between">
className="h-6 text-xs" <span className="text-xs font-medium"> </span>
onClick={() => <Button
updatePopupConfig({ variant="ghost"
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] }, size="sm"
}) className="h-6 text-xs"
} onClick={() =>
>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{queryResult?.columns.map((col) => {
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
updatePopupConfig({ updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns }, additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
}); })
}} }
> >
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span> </Button>
</div> </div>
); <div className="max-h-48 space-y-1 overflow-y-auto">
})} {/* 쿼리 결과 컬럼 목록 */}
{(!queryResult?.columns || queryResult.columns.length === 0) && ( {queryResult?.columns.map((col) => {
<p className="text-muted-foreground py-2 text-center text-xs"> const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
</p> typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</>
)}
{/* 커스텀 쿼리 모드: 직접 입력 방식 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<p className="text-muted-foreground mt-1 text-xs">
.
AS "라벨명" alias를 .
</p>
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Plus className="h-3 w-3" />
()
</Button>
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
)} )}
</div> </div>
</PopoverContent> </>
</Popover> )}
<p className="text-muted-foreground mt-1 text-xs"> </p>
{/* 선택된 컬럼 라벨 편집 */} {/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && ( {popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
</div> </div>
</div> </div>
)} )}
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground text-xs"> </p>
<div className="space-y-1.5">
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return (
<div key={index} className="flex items-center gap-2">
<Input
value={column}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column: e.target.value, label: label || e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="컬럼명 (쿼리 결과)"
className="h-7 flex-1 text-xs"
/>
<Input
value={label}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column, label: e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="표시 라벨"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
(_, i) => i !== index
);
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -64,22 +64,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행 // 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery; const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { if (additionalQuery?.enabled) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; const queryMode = additionalQuery.queryMode || "table";
const matchValue = row[sourceColumn];
// 커스텀 쿼리 모드
if (matchValue !== undefined && matchValue !== null) { if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true); setDetailPopupLoading(true);
try { try {
const query = ` // 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
SELECT * let query = additionalQuery.customQuery;
FROM ${additionalQuery.tableName} // console.log("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작");
WHERE ${additionalQuery.matchColumn} = '${matchValue}' // console.log("🔍 [ListWidget] 클릭한 행 데이터:", row);
LIMIT 1; // console.log("🔍 [ListWidget] 행 컬럼 목록:", Object.keys(row));
`;
Object.keys(row).forEach((key) => {
const value = row[key];
const placeholder = new RegExp(`\\{${key}\\}`, "g");
// SQL 인젝션 방지를 위해 값 이스케이프
const safeValue = typeof value === "string"
? value.replace(/'/g, "''")
: value;
query = query.replace(placeholder, String(safeValue ?? ""));
// console.log(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`);
});
// console.log("🔍 [ListWidget] 최종 쿼리:", query);
const { dashboardApi } = await import("@/lib/api/dashboard"); const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query); const result = await dashboardApi.executeQuery(query);
// console.log("🔍 [ListWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) { if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]); setAdditionalDetailData(result.rows[0]);
@ -87,12 +100,43 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
setAdditionalDetailData({}); setAdditionalDetailData({});
} }
} catch (error) { } catch (error) {
console.error("추가 데이터 로드 실패:", error); console.error("커스텀 쿼리 실행 실패:", error);
setAdditionalDetailData({}); setAdditionalDetailData({});
} finally { } finally {
setDetailPopupLoading(false); setDetailPopupLoading(false);
} }
} }
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (error) {
console.error("추가 데이터 로드 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
} }
}, },
[config.rowDetailPopup], [config.rowDetailPopup],
@ -190,22 +234,34 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => { const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = []; const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
? { ...row, ...additional } // additional이 row를 덮어씀
: row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체 // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
let basicFields: { column: string; label: string }[] = []; let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) { if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원 // DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns basicFields = displayColumns
.map((colConfig) => { .map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig; const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig; const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return { column, label }; return { column, label };
}) })
.filter((item) => item.column in row); .filter((item) => item.column in mergedData);
} else { } else {
// 전체 컬럼 // 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
basicFields = Object.keys(row).map((key) => ({ column: key, label: key })); if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
} else {
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
}
} }
groups.push({ groups.push({
@ -220,8 +276,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
})), })),
}); });
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (additional && Object.keys(additional).length > 0) { if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보 // 운행 정보
if (additional.last_trip_start || additional.last_trip_end) { if (additional.last_trip_start || additional.last_trip_end) {
groups.push({ groups.push({

View File

@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행 // 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery; const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) { if (additionalQuery?.enabled) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn; const queryMode = additionalQuery.queryMode || "table";
const matchValue = row[sourceColumn];
// 커스텀 쿼리 모드
if (matchValue !== undefined && matchValue !== null) { if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true); setDetailPopupLoading(true);
try { try {
const query = ` // 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
SELECT * let query = additionalQuery.customQuery;
FROM ${additionalQuery.tableName} // console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
WHERE ${additionalQuery.matchColumn} = '${matchValue}' // console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
LIMIT 1; // console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
`;
Object.keys(row).forEach((key) => {
const value = row[key];
const placeholder = new RegExp(`\\{${key}\\}`, "g");
// SQL 인젝션 방지를 위해 값 이스케이프
const safeValue = typeof value === "string"
? value.replace(/'/g, "''")
: value;
query = query.replace(placeholder, String(safeValue ?? ""));
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
});
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
const { dashboardApi } = await import("@/lib/api/dashboard"); const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query); const result = await dashboardApi.executeQuery(query);
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) { if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]); setAdditionalDetailData(result.rows[0]);
@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
setAdditionalDetailData({}); setAdditionalDetailData({});
} }
} catch (err) { } catch (err) {
console.error("추가 데이터 로드 실패:", err); console.error("커스텀 쿼리 실행 실패:", err);
setAdditionalDetailData({}); setAdditionalDetailData({});
} finally { } finally {
setDetailPopupLoading(false); setDetailPopupLoading(false);
} }
} }
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (err) {
console.error("추가 데이터 로드 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
} }
}, },
[config.rowDetailPopup], [config.rowDetailPopup],
@ -222,13 +266,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => { const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = []; const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns; const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
? { ...row, ...additional } // additional이 row를 덮어씀
: row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체 // 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외 const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
let basicFields: { column: string; label: string }[] = []; let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) { if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원 // DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns basicFields = displayColumns
.map((colConfig) => { .map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig; const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
@ -237,8 +289,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
}) })
.filter((item) => allKeys.includes(item.column)); .filter((item) => allKeys.includes(item.column));
} else { } else {
// 전체 컬럼 // 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
basicFields = allKeys.map((key) => ({ column: key, label: key })); if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional)
.filter((key) => !key.startsWith("_"))
.map((key) => ({ column: key, label: key }));
} else {
basicFields = allKeys.map((key) => ({ column: key, label: key }));
}
} }
groups.push({ groups.push({
@ -253,8 +311,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})), })),
}); });
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (additional && Object.keys(additional).length > 0) { if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보 // 운행 정보
if (additional.last_trip_start || additional.last_trip_end) { if (additional.last_trip_start || additional.last_trip_end) {
groups.push({ groups.push({

View File

@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier); setTripInfoLoading(identifier);
try { try {
// user_id 또는 vehicle_number로 조회 // user_id 또는 vehicle_number로 조회 (시간은 KST로 변환)
const query = `SELECT const query = `SELECT
id, vehicle_number, user_id, id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time, (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time, (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status departure, arrival, status
FROM vehicles FROM vehicles
WHERE user_id = '${identifier}' WHERE user_id = '${identifier}'
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return; if (identifiers.length === 0) return;
try { try {
// 모든 마커의 운행/공차 정보를 한 번에 조회 // 모든 마커의 운행/공차 정보를 한 번에 조회 (시간은 KST로 변환)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", "); const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT const query = `SELECT
id, vehicle_number, user_id, id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time, (last_trip_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_start,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time, (last_trip_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_trip_end,
last_trip_distance, last_trip_time,
(last_empty_start AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_start,
(last_empty_end AT TIME ZONE 'Asia/Seoul')::timestamp as last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status departure, arrival, status
FROM vehicles FROM vehicles
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")}) WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})