Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management

This commit is contained in:
kjs
2025-12-02 18:07:24 +09:00
42 changed files with 8829 additions and 653 deletions

View File

@@ -838,18 +838,53 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 화면의 기본 테이블/REST API 정보 로드
useEffect(() => {
const loadScreenDataSource = async () => {
console.log("🔍 [ScreenDesigner] 데이터 소스 로드 시작:", {
screenId: selectedScreen?.screenId,
screenName: selectedScreen?.screenName,
dataSourceType: selectedScreen?.dataSourceType,
tableName: selectedScreen?.tableName,
restApiConnectionId: selectedScreen?.restApiConnectionId,
restApiEndpoint: selectedScreen?.restApiEndpoint,
restApiJsonPath: selectedScreen?.restApiJsonPath,
// 전체 selectedScreen 객체도 출력
fullScreen: selectedScreen,
});
// REST API 데이터 소스인 경우
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
// 1. dataSourceType이 "restapi"인 경우
// 2. tableName이 restapi_ 또는 _restapi_로 시작하는 경우
// 3. restApiConnectionId가 있는 경우
const isRestApi = selectedScreen?.dataSourceType === "restapi" ||
selectedScreen?.tableName?.startsWith("restapi_") ||
selectedScreen?.tableName?.startsWith("_restapi_") ||
!!selectedScreen?.restApiConnectionId;
console.log("🔍 [ScreenDesigner] REST API 여부:", { isRestApi });
if (isRestApi && (selectedScreen?.restApiConnectionId || selectedScreen?.tableName)) {
try {
// 연결 ID 추출 (restApiConnectionId가 없으면 tableName에서 추출)
let connectionId = selectedScreen?.restApiConnectionId;
if (!connectionId && selectedScreen?.tableName) {
const match = selectedScreen.tableName.match(/restapi_(\d+)/);
connectionId = match ? parseInt(match[1]) : undefined;
}
if (!connectionId) {
throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
}
console.log("🌐 [ScreenDesigner] REST API 데이터 로드:", { connectionId });
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
selectedScreen.restApiConnectionId,
selectedScreen.restApiEndpoint,
selectedScreen.restApiJsonPath || "data",
connectionId,
selectedScreen?.restApiEndpoint,
selectedScreen?.restApiJsonPath || "response", // 기본값을 response로 변경
);
// REST API 응답에서 컬럼 정보 생성
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
tableName: `restapi_${connectionId}`,
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
@@ -861,10 +896,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}));
const tableInfo: TableInfo = {
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
tableName: `restapi_${connectionId}`,
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
columns,
};
console.log("✅ [ScreenDesigner] REST API 컬럼 로드 완료:", {
tableName: tableInfo.tableName,
tableLabel: tableInfo.tableLabel,
columnsCount: columns.length,
columns: columns.map(c => c.columnName),
});
setTables([tableInfo]);
console.log("REST API 데이터 소스 로드 완료:", {
@@ -4256,8 +4298,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 통합 패널 */}
{panelStates.unified?.isOpen && (
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm">
<div className="border-border flex items-center justify-between border-b px-4 py-3">
<div className="border-border bg-card flex h-full w-[240px] flex-col border-r shadow-sm overflow-hidden">
<div className="border-border flex items-center justify-between border-b px-4 py-3 shrink-0">
<h3 className="text-foreground text-sm font-semibold"></h3>
<button
onClick={() => closePanel("unified")}
@@ -4266,7 +4308,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-2 gap-1">
<TabsTrigger value="components" className="text-xs">

View File

@@ -41,6 +41,7 @@ import { cn } from "@/lib/utils";
import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal";
import dynamic from "next/dynamic";
@@ -132,10 +133,18 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
description: "",
isActive: "Y",
tableName: "",
dataSourceType: "database" as "database" | "restapi",
restApiConnectionId: null as number | null,
restApiEndpoint: "",
restApiJsonPath: "data",
});
const [tables, setTables] = useState<Array<{ tableName: string; tableLabel: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// REST API 연결 관련 상태 (편집용)
const [editRestApiConnections, setEditRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [editRestApiComboboxOpen, setEditRestApiComboboxOpen] = useState(false);
// 미리보기 관련 상태
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
@@ -272,11 +281,19 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const handleEdit = async (screen: ScreenDefinition) => {
setScreenToEdit(screen);
// 데이터 소스 타입 결정
const isRestApi = screen.dataSourceType === "restapi" || screen.tableName?.startsWith("_restapi_");
setEditFormData({
screenName: screen.screenName,
description: screen.description || "",
isActive: screen.isActive,
tableName: screen.tableName || "",
dataSourceType: isRestApi ? "restapi" : "database",
restApiConnectionId: (screen as any).restApiConnectionId || null,
restApiEndpoint: (screen as any).restApiEndpoint || "",
restApiJsonPath: (screen as any).restApiJsonPath || "data",
});
setEditDialogOpen(true);
@@ -298,14 +315,50 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
} finally {
setLoadingTables(false);
}
// REST API 연결 목록 로드
try {
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
setEditRestApiConnections(connections);
} catch (error) {
console.error("REST API 연결 목록 조회 실패:", error);
setEditRestApiConnections([]);
}
};
const handleEditSave = async () => {
if (!screenToEdit) return;
try {
// 데이터 소스 타입에 따라 업데이트 데이터 구성
const updateData: any = {
screenName: editFormData.screenName,
description: editFormData.description,
isActive: editFormData.isActive,
dataSourceType: editFormData.dataSourceType,
};
if (editFormData.dataSourceType === "database") {
updateData.tableName = editFormData.tableName;
updateData.restApiConnectionId = null;
updateData.restApiEndpoint = null;
updateData.restApiJsonPath = null;
} else {
// REST API
updateData.tableName = `_restapi_${editFormData.restApiConnectionId}`;
updateData.restApiConnectionId = editFormData.restApiConnectionId;
updateData.restApiEndpoint = editFormData.restApiEndpoint;
updateData.restApiJsonPath = editFormData.restApiJsonPath || "data";
}
console.log("📤 화면 편집 저장 요청:", {
screenId: screenToEdit.screenId,
editFormData,
updateData,
});
// 화면 정보 업데이트 API 호출
await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData);
await screenApi.updateScreenInfo(screenToEdit.screenId, updateData);
// 선택된 테이블의 라벨 찾기
const selectedTable = tables.find((t) => t.tableName === editFormData.tableName);
@@ -318,10 +371,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
? {
...s,
screenName: editFormData.screenName,
tableName: editFormData.tableName,
tableName: updateData.tableName,
tableLabel: tableLabel,
description: editFormData.description,
isActive: editFormData.isActive,
dataSourceType: editFormData.dataSourceType,
}
: s,
),
@@ -1216,65 +1270,184 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
placeholder="화면명을 입력하세요"
/>
</div>
{/* 데이터 소스 타입 선택 */}
<div className="space-y-2">
<Label htmlFor="edit-tableName"> *</Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={loadingTables}
>
{loadingTables
? "로딩 중..."
: editFormData.tableName
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
: "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.tableLabel}`}
onSelect={() => {
setEditFormData({ ...editFormData, tableName: table.tableName });
setTableComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.tableLabel}</span>
<span className="text-[10px] text-gray-500">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Label> </Label>
<Select
value={editFormData.dataSourceType}
onValueChange={(value: "database" | "restapi") => {
setEditFormData({
...editFormData,
dataSourceType: value,
tableName: "",
restApiConnectionId: null,
restApiEndpoint: "",
restApiJsonPath: "data",
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="database"></SelectItem>
<SelectItem value="restapi">REST API</SelectItem>
</SelectContent>
</Select>
</div>
{/* 데이터베이스 선택 (database 타입인 경우) */}
{editFormData.dataSourceType === "database" && (
<div className="space-y-2">
<Label htmlFor="edit-tableName"> *</Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={loadingTables}
>
{loadingTables
? "로딩 중..."
: editFormData.tableName
? tables.find((table) => table.tableName === editFormData.tableName)?.tableLabel || editFormData.tableName
: "테이블을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.tableLabel}`}
onSelect={() => {
setEditFormData({ ...editFormData, tableName: table.tableName });
setTableComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
editFormData.tableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.tableLabel}</span>
<span className="text-[10px] text-gray-500">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* REST API 선택 (restapi 타입인 경우) */}
{editFormData.dataSourceType === "restapi" && (
<>
<div className="space-y-2">
<Label>REST API *</Label>
<Popover open={editRestApiComboboxOpen} onOpenChange={setEditRestApiComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={editRestApiComboboxOpen}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{editFormData.restApiConnectionId
? editRestApiConnections.find((c) => c.id === editFormData.restApiConnectionId)?.connection_name || "선택된 연결"
: "REST API 연결 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="연결 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm">
.
</CommandEmpty>
<CommandGroup>
{editRestApiConnections.map((conn) => (
<CommandItem
key={conn.id}
value={conn.connection_name}
onSelect={() => {
setEditFormData({ ...editFormData, restApiConnectionId: conn.id || null });
setEditRestApiComboboxOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
editFormData.restApiConnectionId === conn.id ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-[10px] text-gray-500">{conn.base_url}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="edit-restApiEndpoint">API </Label>
<Input
id="edit-restApiEndpoint"
value={editFormData.restApiEndpoint}
onChange={(e) => setEditFormData({ ...editFormData, restApiEndpoint: e.target.value })}
placeholder="예: /api/data/list"
/>
<p className="text-muted-foreground text-[10px]">
API
</p>
</div>
<div className="space-y-2">
<Label htmlFor="edit-restApiJsonPath">JSON </Label>
<Input
id="edit-restApiJsonPath"
value={editFormData.restApiJsonPath}
onChange={(e) => setEditFormData({ ...editFormData, restApiJsonPath: e.target.value })}
placeholder="예: data 또는 result.items"
/>
<p className="text-muted-foreground text-[10px]">
JSON에서 (기본: data)
</p>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="edit-description"></Label>
<Textarea
@@ -1305,7 +1478,14 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
</Button>
<Button onClick={handleEditSave} disabled={!editFormData.screenName.trim() || !editFormData.tableName.trim()}>
<Button
onClick={handleEditSave}
disabled={
!editFormData.screenName.trim() ||
(editFormData.dataSourceType === "database" && !editFormData.tableName.trim()) ||
(editFormData.dataSourceType === "restapi" && !editFormData.restApiConnectionId)
}
>
</Button>
</DialogFooter>

View File

@@ -503,7 +503,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="empty_vehicle"></SelectItem>
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
<SelectItem value="operation_control"> </SelectItem>
</SelectContent>
</Select>
@@ -1664,190 +1664,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 위치정보 가져오기 설정 */}
{(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
{/* 공차등록 설정 - 운행알림으로 통합되어 주석 처리 */}
{/* {(component.componentConfig?.action?.type || "save") === "empty_vehicle" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">🚛 </h4>
{/* 테이블 선택 */}
<div>
<Label htmlFor="geolocation-table"> </Label>
<Select
value={config.action?.geolocationTableName || currentTableName || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationTableName", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-lat-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lat-field"
placeholder="latitude"
value={config.action?.geolocationLatField || "latitude"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-lng-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lng-field"
placeholder="longitude"
value={config.action?.geolocationLngField || "longitude"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-accuracy-field"> ()</Label>
<Input
id="geolocation-accuracy-field"
placeholder="accuracy"
value={config.action?.geolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-timestamp-field"> ()</Label>
<Input
id="geolocation-timestamp-field"
placeholder="location_time"
value={config.action?.geolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-high-accuracy"> </Label>
<p className="text-xs text-muted-foreground">GPS ( )</p>
</div>
<Switch
id="geolocation-high-accuracy"
checked={config.action?.geolocationHighAccuracy !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
/>
</div>
{/* 자동 저장 옵션 */}
<div className="border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-auto-save">DB </Label>
<p className="text-xs text-muted-foreground"> DB에 </p>
</div>
<Switch
id="geolocation-auto-save"
checked={config.action?.geolocationAutoSave === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
/>
</div>
{config.action?.geolocationAutoSave && (
<div className="mt-3 space-y-3 rounded-md bg-amber-50 p-3 dark:bg-amber-950">
<div className="grid grid-cols-2 gap-2">
<div>
<Label> (WHERE )</Label>
<Input
placeholder="user_id"
value={config.action?.geolocationKeyField || "user_id"}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationKeyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Select
value={config.action?.geolocationKeySourceField || "__userId__"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.geolocationKeySourceField", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__userId__" className="text-xs font-medium text-blue-600">
🔑 ID
</SelectItem>
<SelectItem value="__companyCode__" className="text-xs font-medium text-blue-600">
🏢
</SelectItem>
<SelectItem value="__userName__" className="text-xs font-medium text-blue-600">
👤
</SelectItem>
{tableColumns.length > 0 && (
<>
<SelectItem value="__divider__" disabled className="text-xs text-muted-foreground">
</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 추가 필드 변경 (status 등) */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label> ()</Label>
<Input
placeholder="status"
value={config.action?.geolocationExtraField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label> </Label>
<Input
placeholder="inactive"
value={config.action?.geolocationExtraValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-amber-700 dark:text-amber-300">
status .
</p>
</div>
)}
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong>:</strong> HTTPS .
</p>
</div>
... 공차등록 설정 UI 생략 ...
</div>
)}
)} */}
{/* 운행알림 및 종료 설정 */}
{(component.componentConfig?.action?.type || "save") === "operation_control" && (