Compare commits

...

21 Commits

Author SHA1 Message Date
kjs 35581ac8d2 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-10-27 18:36:22 +09:00
kjs 6dd321ddab Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management 2025-10-27 18:35:49 +09:00
hyeonsu d7e8feafc8 Merge pull request '대시보드 기타 수정사항(3d 야드 위주)' (#151) from feat/rest-api into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/151
2025-10-27 17:27:23 +09:00
dohyeons 9b337496b8 3d요소 디자인 변경 2025-10-27 17:05:33 +09:00
dohyeons 8a318ea741 야드 캔버스 수정 2025-10-27 16:09:06 +09:00
dohyeons 640a9a741c 야드 관리 수정 안되는 현상 해결 2025-10-27 16:06:51 +09:00
dohyeons d1e399b1c4 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/rest-api 2025-10-27 16:01:27 +09:00
dohyeons 8788b47663 에러 해결 2025-10-27 15:46:13 +09:00
dohyeons 270c322daf 대시보드 기타 수정사항 적용 2025-10-27 15:19:48 +09:00
dohyeons d4579e4221 뷰어 부분 반응형 적용 및 원형 차트 설정 변경 2025-10-27 14:38:43 +09:00
hyeonsu 4f2cf6c0ff Merge pull request '기타 수정사항' (#150) from feat/rest-api into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/150
2025-10-27 13:35:02 +09:00
dohyeons cc4dd5ffdc 파일 에러 수정 2025-10-27 13:24:25 +09:00
dohyeons 4bbe29e18e 어드민 계정 식별 방법 수정 2025-10-27 13:20:49 +09:00
dohyeons bc36c00712 리스트 위젯 제목 한 개만 렌더링 2025-10-27 13:20:31 +09:00
dohyeons 189f0e03a0 새요소 추가 시에도 위로 올리기 체크 구현 2025-10-27 12:02:15 +09:00
dohyeons 3b5f0b638f 중력 적용 및 요소 쌓기 구현 2025-10-27 11:40:11 +09:00
dohyeons f0bb349c8c 3d요소에 그리드 스냅 시스템 적용 2025-10-27 11:16:54 +09:00
hyeonsu 1116bb2b73 Merge pull request '외부커넥션 관리 - rest api ui개선' (#149) from feat/rest-api into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/149
2025-10-27 09:43:58 +09:00
dohyeons 463cbd29f9 Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/rest-api 2025-10-27 09:40:34 +09:00
dohyeons ef5b86cc4c rest api 연결 ui 개선 2025-10-27 09:39:11 +09:00
kjs 07f65e43c7 Merge pull request 'feature/screen-management' (#148) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/148
2025-10-24 18:08:45 +09:00
12 changed files with 913 additions and 344 deletions

View File

@ -105,7 +105,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
} }
return ( return (
<div className="h-screen bg-gray-50"> <div className="h-screen">
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */} {/* 대시보드 헤더 - 보기 모드에서는 숨김 */}
{/* <div className="border-b border-gray-200 bg-white px-6 py-4"> {/* <div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -73,6 +73,9 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
// 연결 정보가 변경될 때 폼 데이터 업데이트 // 연결 정보가 변경될 때 폼 데이터 업데이트
useEffect(() => { useEffect(() => {
// 테스트 관련 상태 초기화
setTestResult(null);
if (connection) { if (connection) {
setFormData({ setFormData({
...connection, ...connection,
@ -304,7 +307,9 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl"> <DialogContent className="max-h-[90vh] max-w-[95vw] overflow-y-auto sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg">{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle> <DialogTitle className="text-base sm:text-lg">
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
@ -437,7 +442,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
> >
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
@ -464,7 +469,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
> >
{testingConnection ? "테스트 중..." : "연결 테스트"} {testingConnection ? "테스트 중..." : "연결 테스트"}
</Button> </Button>
{testingConnection && <div className="text-sm text-gray-500"> ...</div>} {testingConnection && <div className="text-muted-foreground text-sm"> ...</div>}
</div> </div>
{/* 테스트 결과 표시 */} {/* 테스트 결과 표시 */}
@ -492,7 +497,9 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
{!testResult.success && testResult.error && ( {!testResult.success && testResult.error && (
<div className="mt-2 text-xs"> <div className="mt-2 text-xs">
<div> : {testResult.error.code}</div> <div> : {testResult.error.code}</div>
{testResult.error.details && <div className="mt-1 text-destructive">{testResult.error.details}</div>} {testResult.error.details && (
<div className="text-destructive mt-1">{testResult.error.details}</div>
)}
</div> </div>
)} )}
</div> </div>
@ -602,7 +609,11 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
> >
</Button> </Button>
<Button onClick={handleSave} disabled={loading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"> <Button
onClick={handleSave}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"} {loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -206,7 +206,7 @@ export function RestApiConnectionList() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 검색 */} {/* 검색 */}
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="연결명 또는 URL로 검색..." placeholder="연결명 또는 URL로 검색..."
value={searchTerm} value={searchTerm}
@ -246,118 +246,125 @@ export function RestApiConnectionList() {
{/* 추가 버튼 */} {/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* 연결 목록 */} {/* 연결 목록 */}
{loading ? ( {loading ? (
<div className="flex h-64 items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="bg-card flex h-64 items-center justify-center rounded-lg border shadow-sm">
<div className="text-sm text-muted-foreground"> ...</div> <div className="text-muted-foreground text-sm"> ...</div>
</div> </div>
) : connections.length === 0 ? ( ) : connections.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> REST API </p> <p className="text-muted-foreground text-sm"> REST API </p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="rounded-lg border bg-card shadow-sm"> <div className="bg-card rounded-lg border shadow-sm">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> URL</TableHead> <TableHead className="h-12 text-sm font-semibold"> URL</TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead> <TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead> <TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead> <TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead> <TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead> <TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{connections.map((connection) => ( {connections.map((connection) => (
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50"> <TableRow key={connection.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm"> <TableCell className="h-16 text-sm">
<div className="font-medium">{connection.connection_name}</div> <div className="max-w-[200px]">
<div className="truncate font-medium" title={connection.connection_name}>
{connection.connection_name}
</div>
{connection.description && ( {connection.description && (
<div className="mt-1 text-xs text-muted-foreground">{connection.description}</div> <div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}>
)} {connection.description}
</TableCell>
<TableCell className="h-16 font-mono text-sm">{connection.base_url}</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant="outline">
{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}
</Badge>
</TableCell>
<TableCell className="h-16 text-center text-sm">
{Object.keys(connection.default_headers || {}).length}
</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16 text-sm">
{connection.last_test_date ? (
<div>
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
<Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1"
>
{connection.last_test_result === "Y" ? "성공" : "실패"}
</Badge>
</div> </div>
) : (
<span className="text-muted-foreground">-</span>
)} )}
</TableCell> </div>
<TableCell className="h-16 text-sm"> </TableCell>
<div className="flex items-center gap-2"> <TableCell className="h-16 font-mono text-sm">
<Button <div className="max-w-[300px] truncate" title={connection.base_url}>
variant="outline" {connection.base_url}
size="sm" </div>
onClick={() => handleTestConnection(connection)} </TableCell>
disabled={testingConnections.has(connection.id!)} <TableCell className="h-16 text-sm">
className="h-9 text-sm" <Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
</TableCell>
<TableCell className="h-16 text-center text-sm">
{Object.keys(connection.default_headers || {}).length}
</TableCell>
<TableCell className="h-16 text-sm">
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
{connection.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</TableCell>
<TableCell className="h-16 text-sm">
{connection.last_test_date ? (
<div>
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
<Badge
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
className="mt-1"
> >
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"} {connection.last_test_result === "Y" ? "성공" : "실패"}
</Button> </Badge>
{testResults.has(connection.id!) && (
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
{testResults.get(connection.id!) ? "성공" : "실패"}
</Badge>
)}
</div> </div>
</TableCell> ) : (
<TableCell className="h-16 text-right"> <span className="text-muted-foreground">-</span>
<div className="flex justify-end gap-2"> )}
<Button </TableCell>
variant="ghost" <TableCell className="h-16 text-sm">
size="icon" <div className="flex items-center gap-2">
onClick={() => handleEditConnection(connection)} <Button
className="h-8 w-8" variant="outline"
> size="sm"
<Pencil className="h-4 w-4" /> onClick={() => handleTestConnection(connection)}
</Button> disabled={testingConnections.has(connection.id!)}
<Button className="h-9 text-sm"
variant="ghost" >
size="icon" {testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
onClick={() => handleDeleteConnection(connection)} </Button>
className="h-8 w-8 text-destructive hover:bg-destructive/10" {testResults.has(connection.id!) && (
> <Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
<Trash2 className="h-4 w-4" /> {testResults.get(connection.id!) ? "성공" : "실패"}
</Button> </Badge>
</div> )}
</TableCell> </div>
</TableRow> </TableCell>
))} <TableCell className="h-16 text-right">
</TableBody> <div className="flex justify-end gap-2">
</Table> <Button
</div> variant="ghost"
size="icon"
onClick={() => handleEditConnection(connection)}
className="h-8 w-8"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteConnection(connection)}
className="text-destructive hover:bg-destructive/10 h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)} )}
{/* 연결 설정 모달 */} {/* 연결 설정 모달 */}
@ -377,8 +384,7 @@ export function RestApiConnectionList() {
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle> <AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm"> <AlertDialogDescription className="text-xs sm:text-sm">
"{connectionToDelete?.connection_name}" ? "{connectionToDelete?.connection_name}" ?
<br /> <br /> .
.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0"> <AlertDialogFooter className="gap-2 sm:gap-0">
@ -390,7 +396,7 @@ export function RestApiConnectionList() {
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDeleteConnection} onClick={confirmDeleteConnection}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm" className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
</AlertDialogAction> </AlertDialogAction>

View File

@ -46,6 +46,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
const [testEndpoint, setTestEndpoint] = useState(""); const [testEndpoint, setTestEndpoint] = useState("");
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null); const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// 기존 연결 데이터 로드 // 기존 연결 데이터 로드
@ -77,6 +78,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setTestResult(null); setTestResult(null);
setTestEndpoint(""); setTestEndpoint("");
setTestRequestUrl("");
}, [connection, isOpen]); }, [connection, isOpen]);
// 연결 테스트 // 연결 테스트
@ -94,6 +96,10 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setTesting(true); setTesting(true);
setTestResult(null); setTestResult(null);
// 사용자가 테스트하려는 실제 외부 API URL 설정
const fullUrl = testEndpoint ? `${baseUrl}${testEndpoint}` : baseUrl;
setTestRequestUrl(fullUrl);
try { try {
const result = await ExternalRestApiConnectionAPI.testConnection({ const result = await ExternalRestApiConnectionAPI.testConnection({
base_url: baseUrl, base_url: baseUrl,
@ -220,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="connection-name"> <Label htmlFor="connection-name">
<span className="text-red-500">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="connection-name" id="connection-name"
@ -243,7 +249,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="base-url"> <Label htmlFor="base-url">
URL <span className="text-red-500">*</span> URL <span className="text-destructive">*</span>
</Label> </Label>
<Input <Input
id="base-url" id="base-url"
@ -283,14 +289,14 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<button <button
type="button" type="button"
onClick={() => setShowAdvanced(!showAdvanced)} onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center space-x-2 text-sm font-semibold hover:text-blue-600" className="hover:text-primary flex items-center space-x-2 text-sm font-semibold transition-colors"
> >
<span> </span> <span> </span>
{showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {showAdvanced ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="space-y-4 rounded-md border bg-gray-50 p-4"> <div className="bg-muted space-y-4 rounded-md border p-4">
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="timeout"> (ms)</Label> <Label htmlFor="timeout"> (ms)</Label>
@ -342,7 +348,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
id="test-endpoint" id="test-endpoint"
value={testEndpoint} value={testEndpoint}
onChange={(e) => setTestEndpoint(e.target.value)} onChange={(e) => setTestEndpoint(e.target.value)}
placeholder="/api/v1/test 또는 빈칸 (기본 URL만 테스트)" placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
/> />
</div> </div>
@ -351,6 +357,41 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
{testing ? "테스트 중..." : "연결 테스트"} {testing ? "테스트 중..." : "연결 테스트"}
</Button> </Button>
{/* 테스트 요청 정보 표시 */}
{testRequestUrl && (
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> URL</div>
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
</div>
{Object.keys(defaultHeaders).length > 0 && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<div className="space-y-1">
{Object.entries(defaultHeaders).map(([key, value]) => (
<code key={key} className="text-foreground block text-xs">
{key}: {value}
</code>
))}
</div>
</div>
)}
{authType !== "none" && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<code className="text-foreground block text-xs">
{authType === "api-key" && "API Key"}
{authType === "bearer" && "Bearer Token"}
{authType === "basic" && "Basic Auth"}
{authType === "oauth2" && "OAuth 2.0"}
</code>
</div>
)}
</div>
)}
{testResult && ( {testResult && (
<div <div
className={`rounded-md border p-4 ${ className={`rounded-md border p-4 ${

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState, useRef } from "react";
import { DashboardElement, QueryResult, ChartData } from "../types"; import { DashboardElement, QueryResult, ChartData } from "../types";
import { Chart } from "./Chart"; import { Chart } from "./Chart";
import { transformQueryResultToChartData } from "../utils/chartDataTransform"; import { transformQueryResultToChartData } from "../utils/chartDataTransform";
@ -21,11 +21,39 @@ interface ChartRendererProps {
* - QueryResult를 ChartData로 * - QueryResult를 ChartData로
* - D3 Chart * - D3 Chart
*/ */
export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) { export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(width || 250);
const [chartData, setChartData] = useState<ChartData | null>(null); const [chartData, setChartData] = useState<ChartData | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 컨테이너 너비 측정 (width가 undefined일 때)
useEffect(() => {
if (width !== undefined) {
setContainerWidth(width);
return;
}
const updateWidth = () => {
if (containerRef.current) {
const measuredWidth = containerRef.current.offsetWidth;
console.log("📏 컨테이너 너비 측정:", measuredWidth);
setContainerWidth(measuredWidth || 500); // 기본값 500
}
};
// 약간의 지연을 두고 측정 (DOM 렌더링 완료 후)
const timer = setTimeout(updateWidth, 100);
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
clearTimeout(timer);
window.removeEventListener("resize", updateWidth);
};
}, [width]);
// 데이터 페칭 // 데이터 페칭
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -212,15 +240,39 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
} }
// D3 차트 렌더링 // D3 차트 렌더링
const actualWidth = width !== undefined ? width : containerWidth;
// 원형 차트는 더 큰 크기가 필요 (최소 400px)
const isCircularChart = element.subtype === "pie" || element.subtype === "donut";
const minWidth = isCircularChart ? 400 : 200;
const finalWidth = Math.max(actualWidth - 20, minWidth);
// 원형 차트는 범례 공간을 위해 더 많은 여백 필요
const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300);
console.log("🎨 ChartRenderer:", {
elementSubtype: element.subtype,
propWidth: width,
containerWidth,
actualWidth,
finalWidth,
finalHeight,
hasChartData: !!chartData,
chartDataLabels: chartData?.labels,
chartDataDatasets: chartData?.datasets?.length,
isCircularChart,
});
return ( return (
<div className="flex h-full w-full items-center justify-center bg-white p-2"> <div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-2">
<Chart <div className="flex items-center justify-center">
chartType={element.subtype} <Chart
data={chartData} chartType={element.subtype}
config={element.chartConfig} data={chartData}
width={width - 20} config={element.chartConfig}
height={height - 20} width={finalWidth}
/> height={finalHeight}
/>
</div>
</div> </div>
); );
} }

View File

@ -24,12 +24,17 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa
const svg = d3.select(svgRef.current); const svg = d3.select(svgRef.current);
svg.selectAll("*").remove(); svg.selectAll("*").remove();
const margin = { top: 40, right: 150, bottom: 40, left: 120 }; // 범례를 위한 여백 확보 (아래 80px)
const legendHeight = config.showLegend !== false ? 80 : 0;
const margin = { top: 20, right: 20, bottom: 20 + legendHeight, left: 20 };
const chartWidth = width - margin.left - margin.right; const chartWidth = width - margin.left - margin.right;
const chartHeight = height - margin.top - margin.bottom; const chartHeight = height - margin.top - margin.bottom - legendHeight;
const radius = Math.min(chartWidth, chartHeight) / 2; const radius = Math.min(chartWidth, chartHeight) / 2;
const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`); // 차트를 위쪽에 배치 (범례 공간 확보)
const centerX = width / 2;
const centerY = margin.top + radius + 20;
const g = svg.append("g").attr("transform", `translate(${centerX},${centerY})`);
// 색상 팔레트 // 색상 팔레트
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"]; const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"];
@ -136,33 +141,35 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa
.text(config.title); .text(config.title);
} }
// 범례 (차트 오른쪽, 세로 배치) // 범례 (차트 아래, 가로 배치, 중앙 정렬)
if (config.showLegend !== false) { if (config.showLegend !== false) {
const legendX = width / 2 + radius + 30; // 차트 오른쪽 const itemSpacing = 100; // 각 범례 항목 사이 간격 (줄임)
const legendY = (height - pieData.length * 25) / 2; // 세로 중앙 정렬 const totalWidth = pieData.length * itemSpacing;
const legendStartX = (width - totalWidth) / 2; // 시작 위치
const legend = svg const legendY = centerY + radius + 40; // 차트 아래 40px
.append("g")
.attr("class", "legend") const legend = svg.append("g").attr("class", "legend");
.attr("transform", `translate(${legendX}, ${legendY})`);
pieData.forEach((d, i) => { pieData.forEach((d, i) => {
const legendItem = legend const legendItem = legend
.append("g") .append("g")
.attr("transform", `translate(0, ${i * 25})`); .attr("transform", `translate(${legendStartX + i * itemSpacing + itemSpacing / 2}, ${legendY})`);
legendItem legendItem
.append("rect") .append("rect")
.attr("width", 15) .attr("x", -6) // 사각형을 중앙 기준으로
.attr("height", 15) .attr("y", -6)
.attr("width", 12)
.attr("height", 12)
.attr("fill", colors[i % colors.length]) .attr("fill", colors[i % colors.length])
.attr("rx", 3); .attr("rx", 2);
legendItem legendItem
.append("text") .append("text")
.attr("x", 20) .attr("x", 0)
.attr("y", 12) .attr("y", 18)
.style("font-size", "11px") .attr("text-anchor", "middle") // 텍스트 중앙 정렬
.style("font-size", "10px")
.style("fill", "#333") .style("fill", "#333")
.text(`${d.label} (${d.value})`); .text(`${d.label} (${d.value})`);
}); });

View File

@ -111,19 +111,21 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) {
{/* 시계 콘텐츠 */} {/* 시계 콘텐츠 */}
{renderClockContent()} {renderClockContent()}
{/* 설정 버튼 - 우측 상단 */} {/* 설정 버튼 - 우측 상단 (디자이너 모드에서만 표시) */}
<div className="absolute top-2 right-2"> {onConfigUpdate && (
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}> <div className="absolute top-2 right-2">
<PopoverTrigger asChild> <Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white"> <PopoverTrigger asChild>
<Settings className="h-4 w-4" /> <Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
</Button> <Settings className="h-4 w-4" />
</PopoverTrigger> </Button>
<PopoverContent className="w-[500px] p-0" align="end"> </PopoverTrigger>
<ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} /> <PopoverContent className="w-[500px] p-0" align="end">
</PopoverContent> <ClockSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
</Popover> </PopoverContent>
</div> </Popover>
</div>
)}
</div> </div>
); );
} }

View File

@ -216,12 +216,7 @@ export function ListWidget({ element }: ListWidgetProps) {
const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows;
return ( return (
<div className="flex h-full w-full flex-col p-4"> <div className="flex h-full w-full flex-col gap-3 p-4">
{/* 제목 - 항상 표시 */}
<div className="mb-4">
<h3 className="text-sm font-semibold text-gray-700">{element.customTitle || element.title}</h3>
</div>
{/* 테이블 뷰 */} {/* 테이블 뷰 */}
{config.viewMode === "table" && ( {config.viewMode === "table" && (
<div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}> <div className={`flex-1 overflow-auto rounded-lg border ${config.compactMode ? "text-xs" : "text-sm"}`}>
@ -311,7 +306,7 @@ export function ListWidget({ element }: ListWidgetProps) {
{/* 페이지네이션 */} {/* 페이지네이션 */}
{config.enablePagination && totalPages > 1 && ( {config.enablePagination && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm"> <div className="flex shrink-0 items-center justify-between border-t pt-3 text-sm">
<div className="text-gray-600"> <div className="text-gray-600">
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length} {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}
</div> </div>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { Canvas, useThree } from "@react-three/fiber"; import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box } from "@react-three/drei"; import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect } from "react"; import { Suspense, useRef, useState, useEffect } from "react";
import * as THREE from "three"; import * as THREE from "three";
@ -29,6 +29,19 @@ interface Yard3DCanvasProps {
selectedPlacementId: number | null; selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement | null) => void; onPlacementClick: (placement: YardPlacement | null) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void; onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
gridSize?: number; // 그리드 크기 (기본값: 5)
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
}
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
function snapToGrid(value: number, gridSize: number): number {
// 가장 가까운 그리드 칸 찾기
const gridIndex = Math.round(value / gridSize);
// 그리드 칸의 중심점 반환
// gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5...
// 이렇게 하면 Box가 칸 안에 정확히 들어감
return gridIndex * gridSize + gridSize / 2;
} }
// 자재 박스 컴포넌트 (드래그 가능) // 자재 박스 컴포넌트 (드래그 가능)
@ -39,6 +52,9 @@ function MaterialBox({
onDrag, onDrag,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
gridSize = 5,
allPlacements = [],
onCollisionDetected,
}: { }: {
placement: YardPlacement; placement: YardPlacement;
isSelected: boolean; isSelected: boolean;
@ -46,6 +62,9 @@ function MaterialBox({
onDrag?: (position: { x: number; y: number; z: number }) => void; onDrag?: (position: { x: number; y: number; z: number }) => void;
onDragStart?: () => void; onDragStart?: () => void;
onDragEnd?: () => void; onDragEnd?: () => void;
gridSize?: number;
allPlacements?: YardPlacement[];
onCollisionDetected?: () => void;
}) { }) {
const meshRef = useRef<THREE.Mesh>(null); const meshRef = useRef<THREE.Mesh>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@ -53,10 +72,83 @@ function MaterialBox({
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { camera, gl } = useThree(); const { camera, gl } = useThree();
// 드래그 중이 아닐 때 위치 업데이트 // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
const palletHeight = 0.3; // 팔레트 높이
const palletGap = 0.05; // 팔레트와 박스 사이 간격
const mySize = placement.size_x || gridSize; // 내 크기 (5)
const myHalfSize = mySize / 2; // 2.5
const mySizeY = placement.size_y || gridSize; // 박스 높이 (5)
const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
for (const p of allPlacements) {
// 자기 자신은 제외
if (Number(p.id) === Number(placement.id)) {
continue;
}
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
const pHalfSize = pSize / 2; // 2.5
const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5)
const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이
// 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지)
const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛)
const isNearby =
Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접
Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접
if (isNearby) {
// 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정)
const isActuallyOverlapping =
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침
if (isActuallyOverlapping) {
// 실제로 겹침: 위에 배치
// 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산
const topOfOtherElement = p.position_y + pTotalHeight / 2;
// 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산
const myYOnTop = topOfOtherElement + myTotalHeight / 2;
if (myYOnTop > maxYBelow) {
maxYBelow = myYOnTop;
}
}
// 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지)
}
}
// 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
return {
hasCollision: needsAdjustment,
adjustedY: maxYBelow,
};
};
// 드래그 중이 아닐 때만 위치 동기화
useEffect(() => { useEffect(() => {
if (!isDragging && meshRef.current) { if (!isDragging && meshRef.current) {
meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z); const currentPos = meshRef.current.position;
const targetX = placement.position_x;
const targetY = placement.position_y;
const targetZ = placement.position_z;
// 현재 위치와 목표 위치가 다를 때만 업데이트 (0.01 이상 차이)
const threshold = 0.01;
const needsUpdate =
Math.abs(currentPos.x - targetX) > threshold ||
Math.abs(currentPos.y - targetY) > threshold ||
Math.abs(currentPos.z - targetZ) > threshold;
if (needsUpdate) {
meshRef.current.position.set(targetX, targetY, targetZ);
}
} }
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]); }, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
@ -98,20 +190,56 @@ function MaterialBox({
return; return;
} }
// 즉시 mesh 위치 업데이트 (부드러운 드래그) // 그리드에 스냅
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ); const snappedX = snapToGrid(finalX, gridSize);
const snappedZ = snapToGrid(finalZ, gridSize);
// 상태 업데이트 (저장용) // 충돌 체크 및 Y 위치 조정
onDrag({ const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
x: finalX,
y: dragStartPos.current.y, // 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
z: finalZ, meshRef.current.position.set(finalX, adjustedY, finalZ);
});
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
// 실제 저장은 handleGlobalMouseUp에서만 수행
} }
}; };
const handleGlobalMouseUp = () => { const handleGlobalMouseUp = () => {
if (isDragging) { if (isDragging && meshRef.current) {
const currentPos = meshRef.current.position;
// 실제로 이동했는지 확인 (최소 이동 거리: 0.1)
const minMovement = 0.1;
const deltaX = Math.abs(currentPos.x - dragStartPos.current.x);
const deltaZ = Math.abs(currentPos.z - dragStartPos.current.z);
const hasMoved = deltaX > minMovement || deltaZ > minMovement;
if (hasMoved) {
// 실제로 드래그한 경우: 그리드에 스냅
const snappedX = snapToGrid(currentPos.x, gridSize);
const snappedZ = snapToGrid(currentPos.z, gridSize);
// Y 위치 조정 (마인크래프트처럼 쌓기)
const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ);
// ✅ 항상 배치 가능 (위로 올라가므로)
console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ });
meshRef.current.position.set(snappedX, adjustedY, snappedZ);
// 최종 위치 저장 (조정된 Y 위치로)
if (onDrag) {
onDrag({
x: snappedX,
y: adjustedY,
z: snappedZ,
});
}
} else {
// 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함)
meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z);
}
setIsDragging(false); setIsDragging(false);
gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
if (onDragEnd) { if (onDragEnd) {
@ -141,11 +269,12 @@ function MaterialBox({
// 편집 모드에서 선택되었고 드래그 가능한 경우 // 편집 모드에서 선택되었고 드래그 가능한 경우
if (isSelected && meshRef.current) { if (isSelected && meshRef.current) {
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환) // 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
const currentPos = meshRef.current.position;
dragStartPos.current = { dragStartPos.current = {
x: Number(placement.position_x), x: currentPos.x,
y: Number(placement.position_y), y: currentPos.y,
z: Number(placement.position_z), z: currentPos.z,
}; };
// 마우스 시작 위치 저장 // 마우스 시작 위치 저장
@ -165,11 +294,19 @@ function MaterialBox({
// 요소가 설정되었는지 확인 // 요소가 설정되었는지 확인
const isConfigured = !!(placement.material_name && placement.quantity && placement.unit); const isConfigured = !!(placement.material_name && placement.quantity && placement.unit);
const boxHeight = placement.size_y || gridSize;
const boxWidth = placement.size_x || gridSize;
const boxDepth = placement.size_z || gridSize;
const palletHeight = 0.3; // 팔레트 높이
const palletGap = 0.05; // 팔레트와 박스 사이 간격 (매우 작게)
// 팔레트 위치 계산: 박스 하단부터 시작
const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap;
return ( return (
<Box <group
ref={meshRef} ref={meshRef}
position={[placement.position_x, placement.position_y, placement.position_z]} position={[placement.position_x, placement.position_y, placement.position_z]}
args={[placement.size_x, placement.size_y, placement.size_z]}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.nativeEvent?.stopPropagation(); e.nativeEvent?.stopPropagation();
@ -178,7 +315,6 @@ function MaterialBox({
}} }}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerOver={() => { onPointerOver={() => {
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
if (onDrag) { if (onDrag) {
gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
} else { } else {
@ -191,20 +327,154 @@ function MaterialBox({
} }
}} }}
> >
<meshStandardMaterial {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
color={placement.color} <group position={[0, palletYOffset, 0]}>
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5} {/* 상단 가로 판자들 (5개) */}
transparent {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
emissive={isSelected ? "#ffffff" : "#000000"} <Box
emissiveIntensity={isSelected ? 0.2 : 0} key={`top-${idx}`}
wireframe={!isConfigured} args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
/> position={[0, palletHeight * 0.35, zOffset]}
</Box> >
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]} />
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
</lineSegments>
</Box>
))}
{/* 중간 세로 받침대 (3개) */}
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
<Box
key={`middle-${idx}`}
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
position={[xOffset, 0, 0]}
>
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]} />
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
</lineSegments>
</Box>
))}
{/* 하단 가로 판자들 (3개) */}
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
<Box
key={`bottom-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
position={[0, -palletHeight * 0.35, zOffset]}
>
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]} />
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
</lineSegments>
</Box>
))}
</group>
{/* 메인 박스 */}
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
{/* 메인 재질 - 골판지 느낌 */}
<meshStandardMaterial
color={placement.color}
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
wireframe={!isConfigured}
roughness={0.95}
metalness={0.05}
/>
{/* 외곽선 - 더 진하게 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
<lineBasicMaterial color="#1a1a1a" opacity={0.8} transparent />
</lineSegments>
</Box>
{/* 포장 테이프 (가로) - 윗면 */}
{isConfigured && (
<>
{/* 테이프 세로 */}
<Box args={[boxWidth * 0.12, 0.02, boxDepth * 0.95]} position={[0, boxHeight / 2 + 0.01, 0]}>
<meshStandardMaterial color="#d4a574" opacity={0.7} transparent roughness={0.3} metalness={0.3} />
</Box>
</>
)}
{/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */}
{isConfigured && placement.material_name && (
<group position={[0, boxHeight * 0.1, boxDepth / 2 + 0.02]}>
{/* 라벨 배경 (흰색 스티커) */}
<Box args={[boxWidth * 0.7, boxHeight * 0.25, 0.01]}>
<meshStandardMaterial color="#ffffff" roughness={0.4} metalness={0.1} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.7, boxHeight * 0.25, 0.01)]} />
<lineBasicMaterial color="#cccccc" opacity={0.8} transparent />
</lineSegments>
</Box>
{/* 라벨 텍스트 */}
<Text
position={[0, 0, 0.02]}
fontSize={0.3}
color="#000000"
anchorX="center"
anchorY="middle"
fontWeight="bold"
>
{placement.material_name}
</Text>
</group>
)}
{/* 수량 라벨 (윗면) - 큰 글씨 */}
{isConfigured && placement.quantity && (
<Text
position={[0, boxHeight / 2 + 0.03, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={0.6}
color="#000000"
anchorX="center"
anchorY="middle"
outlineWidth={0.1}
outlineColor="#ffffff"
fontWeight="bold"
>
{placement.quantity} {placement.unit || ""}
</Text>
)}
{/* 디테일 표시 */}
{isConfigured && (
<>
{/* 화살표 표시 (이 쪽이 위) */}
<group position={[0, boxHeight * 0.35, boxDepth / 2 + 0.01]}>
<Text fontSize={0.6} color="#000000" anchorX="center" anchorY="middle">
</Text>
<Text position={[0, -0.4, 0]} fontSize={0.3} color="#666666" anchorX="center" anchorY="middle">
UP
</Text>
</group>
</>
)}
</group>
); );
} }
// 3D 씬 컴포넌트 // 3D 씬 컴포넌트
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) { function Scene({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
}: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false); const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null); const orbitControlsRef = useRef<any>(null);
@ -215,15 +485,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
<directionalLight position={[10, 10, 5]} intensity={1} /> <directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} /> <directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 */} {/* 바닥 그리드 (타일을 4등분) */}
<Grid <Grid
args={[100, 100]} args={[100, 100]}
cellSize={5} cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
cellThickness={0.5} cellThickness={0.6}
cellColor="#6b7280" cellColor="#1f2937" // 얇은 선 (서브 그리드) - 매우 어두운 회색
sectionSize={10} sectionSize={gridSize} // 타일 경계선 (5칸마다)
sectionThickness={1} sectionThickness={1.5}
sectionColor="#374151" sectionColor="#374151" // 타일 경계는 조금 밝게
fadeDistance={200} fadeDistance={200}
fadeStrength={1} fadeStrength={1}
followCamera={false} followCamera={false}
@ -250,6 +520,9 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
orbitControlsRef.current.enabled = true; orbitControlsRef.current.enabled = true;
} }
}} }}
gridSize={gridSize}
allPlacements={placements}
onCollisionDetected={onCollisionDetected}
/> />
))} ))}
@ -259,10 +532,13 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
enablePan={true} enablePan={true}
enableZoom={true} enableZoom={true}
enableRotate={true} enableRotate={true}
minDistance={10} minDistance={8}
maxDistance={200} maxDistance={200}
maxPolarAngle={Math.PI / 2} maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny} enabled={!isDraggingAny}
screenSpacePanning={true} // 화면 공간 패닝
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
rotateSpeed={0.5} // 회전 속도
/> />
</> </>
); );
@ -273,6 +549,8 @@ export default function Yard3DCanvas({
selectedPlacementId, selectedPlacementId,
onPlacementClick, onPlacementClick,
onPlacementDrag, onPlacementDrag,
gridSize = 5,
onCollisionDetected,
}: Yard3DCanvasProps) { }: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => { const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제 // Canvas의 빈 공간을 클릭했을 때만 선택 해제
@ -297,6 +575,8 @@ export default function Yard3DCanvas({
selectedPlacementId={selectedPlacementId} selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick} onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag} onPlacementDrag={onPlacementDrag}
gridSize={gridSize}
onCollisionDetected={onCollisionDetected}
/> />
</Suspense> </Suspense>
</Canvas> </Canvas>

View File

@ -7,10 +7,11 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { YardLayout, YardPlacement } from "./types"; import { YardLayout, YardPlacement } from "./types";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, CheckCircle } from "lucide-react"; import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false, ssr: false,
@ -33,6 +34,7 @@ interface YardEditorProps {
} }
export default function YardEditor({ layout, onBack }: YardEditorProps) { export default function YardEditor({ layout, onBack }: YardEditorProps) {
const { toast } = useToast();
const [placements, setPlacements] = useState<YardPlacement[]>([]); const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관 const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null); const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
@ -63,7 +65,17 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
setIsLoading(true); setIsLoading(true);
const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id);
if (response.success) { if (response.success) {
const loadedData = response.data as YardPlacement[]; const loadedData = (response.data as YardPlacement[]).map((p) => ({
...p,
// 문자열로 저장된 숫자 필드를 숫자로 변환
position_x: Number(p.position_x),
position_y: Number(p.position_y),
position_z: Number(p.position_z),
size_x: Number(p.size_x),
size_y: Number(p.size_y),
size_z: Number(p.size_z),
quantity: p.quantity !== null && p.quantity !== undefined ? Number(p.quantity) : null,
}));
setPlacements(loadedData); setPlacements(loadedData);
setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사
} }
@ -78,8 +90,89 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
loadPlacements(); loadPlacements();
}, [layout.id]); }, [layout.id]);
// 빈 공간 찾기 (그리드 기반)
const findEmptyGridPosition = (gridSize = 5) => {
// 이미 사용 중인 좌표 Set
const occupiedPositions = new Set(
placements.map((p) => {
const x = Math.round(p.position_x / gridSize) * gridSize;
const z = Math.round(p.position_z / gridSize) * gridSize;
return `${x},${z}`;
}),
);
// 나선형으로 빈 공간 찾기
let x = 0;
let z = 0;
let direction = 0; // 0: 우, 1: 하, 2: 좌, 3: 상
let steps = 1;
let stepsTaken = 0;
let stepsInDirection = 0;
for (let i = 0; i < 1000; i++) {
const key = `${x},${z}`;
if (!occupiedPositions.has(key)) {
return { x, z };
}
// 다음 위치로 이동
stepsInDirection++;
if (direction === 0)
x += gridSize; // 우
else if (direction === 1)
z += gridSize; // 하
else if (direction === 2)
x -= gridSize; // 좌
else z -= gridSize; // 상
if (stepsInDirection >= steps) {
stepsInDirection = 0;
direction = (direction + 1) % 4;
stepsTaken++;
if (stepsTaken === 2) {
stepsTaken = 0;
steps++;
}
}
}
return { x: 0, z: 0 };
};
// 특정 XZ 위치에 배치할 때 적절한 Y 위치 계산 (마인크래프트 쌓기)
const calculateYPosition = (x: number, z: number, existingPlacements: YardPlacement[]) => {
const gridSize = 5;
const halfSize = gridSize / 2;
let maxY = halfSize; // 기본 바닥 높이 (2.5)
for (const p of existingPlacements) {
// XZ가 겹치는지 확인
const isXZOverlapping = Math.abs(x - p.position_x) < gridSize && Math.abs(z - p.position_z) < gridSize;
if (isXZOverlapping) {
// 이 요소의 윗면 높이
const topY = p.position_y + (p.size_y || gridSize) / 2;
// 새 요소의 Y 위치 (윗면 + 새 요소 높이/2)
const newY = topY + gridSize / 2;
if (newY > maxY) {
maxY = newY;
}
}
}
return maxY;
};
// 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영) // 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영)
const handleAddElement = () => { const handleAddElement = () => {
const gridSize = 5;
const emptyPos = findEmptyGridPosition(gridSize);
const centerX = emptyPos.x + gridSize / 2;
const centerZ = emptyPos.z + gridSize / 2;
// 해당 위치에 적절한 Y 계산 (쌓기)
const appropriateY = calculateYPosition(centerX, centerZ, placements);
const newPlacement: YardPlacement = { const newPlacement: YardPlacement = {
id: nextPlacementId, // 임시 음수 ID id: nextPlacementId, // 임시 음수 ID
yard_layout_id: layout.id, yard_layout_id: layout.id,
@ -87,12 +180,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
material_name: null, material_name: null,
quantity: null, quantity: null,
unit: null, unit: null,
position_x: 0, // 그리드 칸의 중심에 배치 (Three.js Box position은 중심점)
position_y: 2.5, position_x: centerX, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
position_z: 0, position_y: appropriateY, // 쌓기 고려한 Y 위치
size_x: 5, position_z: centerZ, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
size_y: 5, size_x: gridSize,
size_z: 5, size_y: gridSize,
size_z: gridSize,
color: "#9ca3af", color: "#9ca3af",
data_source_type: null, data_source_type: null,
data_source_config: null, data_source_config: null,
@ -125,12 +219,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
setDeleteConfirmDialog({ open: true, placementId }); setDeleteConfirmDialog({ open: true, placementId });
}; };
// 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림
const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => {
const gridSize = 5;
const halfSize = gridSize / 2;
return remainingPlacements.map((p) => {
// 삭제된 요소와 XZ가 겹치는지 확인
const isXZOverlapping =
Math.abs(p.position_x - deletedPlacement.position_x) < gridSize &&
Math.abs(p.position_z - deletedPlacement.position_z) < gridSize;
// 삭제된 요소보다 위에 있는지 확인
const isAbove = p.position_y > deletedPlacement.position_y;
if (isXZOverlapping && isAbove) {
// 아래로 내림: 삭제된 요소의 크기만큼
const fallDistance = deletedPlacement.size_y || gridSize;
const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감
return {
...p,
position_y: newY,
};
}
return p;
});
};
// 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영) // 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
const confirmDeletePlacement = () => { const confirmDeletePlacement = () => {
const { placementId } = deleteConfirmDialog; const { placementId } = deleteConfirmDialog;
if (placementId === null) return; if (placementId === null) return;
setPlacements((prev) => prev.filter((p) => p.id !== placementId)); setPlacements((prev) => {
const deletedPlacement = prev.find((p) => p.id === placementId);
if (!deletedPlacement) return prev;
// 삭제 후 남은 요소들
const remaining = prev.filter((p) => p.id !== placementId);
// 중력 적용 (재귀적으로 계속 적용)
let result = remaining;
let hasChanges = true;
// 모든 요소가 안정될 때까지 반복
while (hasChanges) {
const before = JSON.stringify(result.map((p) => p.position_y));
result = applyGravity(deletedPlacement, result);
const after = JSON.stringify(result.map((p) => p.position_y));
hasChanges = before !== after;
}
return result;
});
if (selectedPlacement?.id === placementId) { if (selectedPlacement?.id === placementId) {
setSelectedPlacement(null); setSelectedPlacement(null);
setShowConfigPanel(false); setShowConfigPanel(false);
@ -358,6 +502,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
selectedPlacementId={selectedPlacement?.id || null} selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)} onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
onPlacementDrag={handlePlacementDrag} onPlacementDrag={handlePlacementDrag}
onCollisionDetected={() => {
toast({
title: "배치 불가",
description: "해당 위치에 이미 다른 요소가 있습니다.",
variant: "destructive",
});
}}
/> />
)} )}
</div> </div>

View File

@ -174,18 +174,6 @@ export function DashboardViewer({
}: DashboardViewerProps) { }: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({}); const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set()); const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
const [isMobile, setIsMobile] = useState(false);
// 화면 크기 감지
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024); // 1024px (lg) 미만은 모바일/태블릿
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// 캔버스 설정 계산 // 캔버스 설정 계산
const canvasConfig = useMemo(() => { const canvasConfig = useMemo(() => {
@ -287,10 +275,8 @@ export function DashboardViewer({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [refreshInterval, loadAllData]); }, [refreshInterval, loadAllData]);
// 모바일에서 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) // 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) - 태블릿 이하에서 세로 정렬 시 사용
const sortedElements = useMemo(() => { const sortedElements = useMemo(() => {
if (!isMobile) return elements;
return [...elements].sort((a, b) => { return [...elements].sort((a, b) => {
// Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함) // Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함)
const yDiff = a.position.y - b.position.y; const yDiff = a.position.y - b.position.y;
@ -300,7 +286,7 @@ export function DashboardViewer({
// 같은 행이면 X 좌표로 정렬 // 같은 행이면 X 좌표로 정렬
return a.position.x - b.position.x; return a.position.x - b.position.x;
}); });
}, [elements, isMobile]); }, [elements]);
// 요소가 없는 경우 // 요소가 없는 경우
if (elements.length === 0) { if (elements.length === 0) {
@ -317,10 +303,18 @@ export function DashboardViewer({
return ( return (
<DashboardProvider> <DashboardProvider>
{isMobile ? ( {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
// 모바일/태블릿: 세로 스택 레이아웃 <div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
<div className="min-h-screen bg-gray-100 p-4" style={{ backgroundColor }}> <div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
<div className="mx-auto max-w-3xl space-y-4"> <div
className="relative rounded-lg"
style={{
width: "100%",
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{sortedElements.map((element) => ( {sortedElements.map((element) => (
<ViewerElement <ViewerElement
key={element.id} key={element.id}
@ -328,38 +322,29 @@ export function DashboardViewer({
data={elementData[element.id]} data={elementData[element.id]}
isLoading={loadingElements.has(element.id)} isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)} onRefresh={() => loadElementData(element)}
isMobile={true} isMobile={false}
canvasWidth={canvasConfig.width}
/> />
))} ))}
</div> </div>
</div> </div>
) : ( </div>
// 데스크톱: 기존 고정 캔버스 레이아웃
<div className="min-h-screen bg-gray-100 py-8"> {/* 태블릿 이하: 반응형 세로 정렬 */}
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}> <div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
<div <div className="mx-auto max-w-3xl space-y-4">
className="relative rounded-lg" {sortedElements.map((element) => (
style={{ <ViewerElement
width: `${canvasConfig.width}px`, key={element.id}
minHeight: `${canvasConfig.height}px`, element={element}
height: `${canvasHeight}px`, data={elementData[element.id]}
backgroundColor: backgroundColor, isLoading={loadingElements.has(element.id)}
}} onRefresh={() => loadElementData(element)}
> isMobile={true}
{sortedElements.map((element) => ( />
<ViewerElement ))}
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={false}
/>
))}
</div>
</div>
</div> </div>
)} </div>
</DashboardProvider> </DashboardProvider>
); );
} }
@ -370,22 +355,28 @@ interface ViewerElementProps {
isLoading: boolean; isLoading: boolean;
onRefresh: () => void; onRefresh: () => void;
isMobile: boolean; isMobile: boolean;
canvasWidth?: number;
} }
/** /**
* *
* - (lg ): absolute positioning으로 ( )
* - 릿 이하: 세로
*/ */
function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) { function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) {
const [isHovered, setIsHovered] = useState(false); const [isMounted, setIsMounted] = useState(false);
// 마운트 확인 (Leaflet 지도 초기화 문제 해결)
useEffect(() => {
setIsMounted(true);
}, []);
if (isMobile) { if (isMobile) {
// 모바일/태블릿: 세로 스택 카드 스타일 // 태블릿 이하: 세로 스택 카드 스타일
return ( return (
<div <div
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm" className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{ minHeight: "300px" }} style={{ minHeight: "300px" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
> >
{element.showHeader !== false && ( {element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3"> <div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
@ -393,19 +384,31 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
<button <button
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50" className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
title="새로고침" title="새로고침"
> >
{isLoading ? ( <svg
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" /> className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
) : ( fill="none"
"🔄" viewBox="0 0 24 24"
)} stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button> </button>
</div> </div>
)} )}
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}> <div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
{element.type === "chart" ? ( {!isMounted ? (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={undefined} height={250} /> <ChartRenderer element={element} data={data} width={undefined} height={250} />
) : ( ) : (
renderWidget(element) renderWidget(element)
@ -423,18 +426,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
); );
} }
// 데스크톱: 기존 absolute positioning // 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning
// 단, 너비는 화면 크기에 따라 비율로 조정
const widthPercentage = (element.size.width / canvasWidth) * 100;
return ( return (
<div <div
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm" className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{ style={{
left: element.position.x, left: `${(element.position.x / canvasWidth) * 100}%`,
top: element.position.y, top: element.position.y,
width: element.size.width, width: `${widthPercentage}%`,
height: element.size.height, height: element.size.height,
}} }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
> >
{element.showHeader !== false && ( {element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3"> <div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
@ -442,22 +446,37 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
<button <button
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
className={`text-gray-400 transition-opacity hover:text-gray-600 disabled:opacity-50 ${ className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
isHovered ? "opacity-100" : "opacity-0"
}`}
title="새로고침" title="새로고침"
> >
{isLoading ? ( <svg
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" /> className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
) : ( fill="none"
"🔄" viewBox="0 0 24 24"
)} stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button> </button>
</div> </div>
)} )}
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}> <div className={element.showHeader !== false ? "h-[calc(100%-50px)] w-full" : "h-full w-full"}>
{element.type === "chart" ? ( {!isMounted ? (
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} /> <div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
</div>
) : element.type === "chart" ? (
<ChartRenderer
element={element}
data={data}
width={undefined}
height={element.showHeader !== false ? element.size.height - 50 : element.size.height}
/>
) : ( ) : (
renderWidget(element) renderWidget(element)
)} )}

View File

@ -56,6 +56,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
// 자동 새로고침 (30초마다) // 자동 새로고침 (30초마다)
const interval = setInterval(loadData, 30000); const interval = setInterval(loadData, 30000);
return () => clearInterval(interval); return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element]); }, [element]);
const loadData = async () => { const loadData = async () => {
@ -101,7 +102,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
body: JSON.stringify({ body: JSON.stringify({
query: groupByDS.query, query: groupByDS.query,
connectionType: groupByDS.connectionType || "current", connectionType: groupByDS.connectionType || "current",
connectionId: groupByDS.connectionId, connectionId: (groupByDS as any).connectionId,
}), }),
}); });
@ -116,7 +117,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
const labelColumn = columns[0]; const labelColumn = columns[0];
const valueColumn = columns[1]; const valueColumn = columns[1];
const cards = rows.map((row) => ({ const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""), label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0, value: parseFloat(row[valueColumn]) || 0,
})); }));
@ -137,12 +138,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
method: groupByDS.method || "GET", method: (groupByDS as any).method || "GET",
url: groupByDS.endpoint, url: groupByDS.endpoint,
headers: groupByDS.headers || {}, headers: (groupByDS as any).headers || {},
body: groupByDS.body, body: (groupByDS as any).body,
authType: groupByDS.authType, authType: (groupByDS as any).authType,
authConfig: groupByDS.authConfig, authConfig: (groupByDS as any).authConfig,
}), }),
}); });
@ -169,7 +170,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
const labelColumn = columns[0]; const labelColumn = columns[0];
const valueColumn = columns[1]; const valueColumn = columns[1];
const cards = rows.map((row) => ({ const cards = rows.map((row: any) => ({
label: String(row[labelColumn] || ""), label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0, value: parseFloat(row[valueColumn]) || 0,
})); }));
@ -201,7 +202,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
body: JSON.stringify({ body: JSON.stringify({
query: element.dataSource.query, query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current", connectionType: element.dataSource.connectionType || "current",
connectionId: element.dataSource.connectionId, connectionId: (element.dataSource as any).connectionId,
}), }),
}); });
@ -212,13 +213,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
if (result.success && result.data?.rows) { if (result.success && result.data?.rows) {
const rows = result.data.rows; const rows = result.data.rows;
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { const calculatedMetrics =
const value = calculateMetric(rows, metric.field, metric.aggregation); element.customMetricConfig?.metrics.map((metric) => {
return { const value = calculateMetric(rows, metric.field, metric.aggregation);
...metric, return {
calculatedValue: value, ...metric,
}; calculatedValue: value,
}); };
}) || [];
setMetrics(calculatedMetrics); setMetrics(calculatedMetrics);
} else { } else {
@ -240,12 +242,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
method: element.dataSource.method || "GET", method: (element.dataSource as any).method || "GET",
url: element.dataSource.endpoint, url: element.dataSource.endpoint,
headers: element.dataSource.headers || {}, headers: (element.dataSource as any).headers || {},
body: element.dataSource.body, body: (element.dataSource as any).body,
authType: element.dataSource.authType, authType: (element.dataSource as any).authType,
authConfig: element.dataSource.authConfig, authConfig: (element.dataSource as any).authConfig,
}), }),
}); });
@ -278,13 +280,14 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
rows = [result.data]; rows = [result.data];
} }
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => { const calculatedMetrics =
const value = calculateMetric(rows, metric.field, metric.aggregation); element.customMetricConfig?.metrics.map((metric) => {
return { const value = calculateMetric(rows, metric.field, metric.aggregation);
...metric, return {
calculatedValue: value, ...metric,
}; calculatedValue: value,
}); };
}) || [];
setMetrics(calculatedMetrics); setMetrics(calculatedMetrics);
} else { } else {
@ -351,7 +354,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
<li> </li> <li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li> <li> COUNT, SUM, AVG, MIN, MAX </li>
<li> </li> <li> </li>
<li> <strong> </strong> </li> <li>
<strong> </strong>
</li>
</ul> </ul>
</div> </div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700"> <div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
@ -361,11 +366,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
: "SQL 쿼리를 입력하고 지표를 추가하세요"} : "SQL 쿼리를 입력하고 지표를 추가하세요"}
</p> </p>
{isGroupByMode && ( {isGroupByMode && <p className="text-[9px]">💡 컬럼: 카드 , 컬럼: 카드 </p>}
<p className="text-[9px]">
💡 컬럼: 카드 , 컬럼: 카드
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@ -373,42 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
} }
return ( return (
<div className="flex h-full w-full flex-col overflow-hidden bg-white p-4"> <div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
{/* 스크롤 가능한 콘텐츠 영역 */} {/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */}
<div className="flex-1 overflow-y-auto"> <div className="grid h-full w-full gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}> {/* 그룹별 카드 (활성화 시) */}
{/* 그룹별 카드 (활성화 시) */} {isGroupByMode &&
{isGroupByMode && groupedCards.map((card, index) => {
groupedCards.map((card, index) => { // 색상 순환 (6가지 색상)
// 색상 순환 (6가지 색상) const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>; const colorKey = colorKeys[index % colorKeys.length];
const colorKey = colorKeys[index % colorKeys.length]; const colors = colorMap[colorKey];
const colors = colorMap[colorKey];
return (
<div key={`group-${index}`} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
<div className="text-sm text-gray-600">{card.label}</div>
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
</div>
);
})}
{/* 일반 지표 카드 (항상 표시) */}
{metrics.map((metric) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
return ( return (
<div key={metric.id} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}> <div
<div className="text-sm text-gray-600">{metric.label}</div> key={`group-${index}`}
<div className={`mt-2 text-3xl font-bold ${colors.text}`}> className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
{formattedValue} >
<span className="ml-1 text-lg">{metric.unit}</span> <div className="text-[10px] text-gray-600">{card.label}</div>
</div> <div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
</div> </div>
); );
})} })}
</div>
{/* 일반 지표 카드 (항상 표시) */}
{metrics.map((metric) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
return (
<div
key={metric.id}
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
>
<div className="text-[10px] text-gray-600">{metric.label}</div>
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
{formattedValue}
<span className="ml-0.5 text-sm">{metric.unit}</span>
</div>
</div>
);
})}
</div> </div>
</div> </div>
); );