Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node
This commit is contained in:
@@ -240,8 +240,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||
|
||||
// 현재 모드에 따라 표시할 메뉴 결정
|
||||
// 관리자 모드에서는 관리자 메뉴 + 사용자 메뉴(툴 생성 메뉴 포함)를 모두 표시
|
||||
const currentMenus = isAdminMode ? [...adminMenus, ...userMenus] : userMenus;
|
||||
// 관리자 모드에서는 관리자 메뉴만, 사용자 모드에서는 사용자 메뉴만 표시
|
||||
const currentMenus = isAdminMode ? adminMenus : userMenus;
|
||||
|
||||
// 메뉴 토글 함수
|
||||
const toggleMenu = (menuId: string) => {
|
||||
@@ -324,7 +324,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<div
|
||||
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
|
||||
pathname === menu.url
|
||||
? "border-l-4 border-primary bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
: isExpanded
|
||||
? "bg-slate-100 text-slate-900"
|
||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||
@@ -352,7 +352,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
key={child.id}
|
||||
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
||||
pathname === child.url
|
||||
? "border-l-4 border-primary bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||
}`}
|
||||
onClick={() => handleMenuClick(child)}
|
||||
@@ -376,7 +376,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
<p>로딩중...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 hover:cursor-pointer ${
|
||||
isAdminMode
|
||||
? "border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100"
|
||||
: "border border-primary/20 bg-accent text-blue-700 hover:bg-primary/20"
|
||||
: "border-primary/20 bg-accent hover:bg-primary/20 border text-blue-700"
|
||||
}`}
|
||||
>
|
||||
{isAdminMode ? (
|
||||
@@ -486,7 +486,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
fallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<div className="border-primary mb-4 h-8 w-8 animate-spin rounded-full border-4 border-t-transparent"></div>
|
||||
<p>로딩중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { GridLayer } from "./GridLayer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
@@ -33,7 +32,6 @@ export function ReportDesignerCanvas() {
|
||||
undo,
|
||||
redo,
|
||||
showRuler,
|
||||
gridConfig,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
@@ -333,16 +331,16 @@ export function ReportDesignerCanvas() {
|
||||
style={{
|
||||
width: `${canvasWidth}mm`,
|
||||
minHeight: `${canvasHeight}mm`,
|
||||
backgroundImage: showGrid
|
||||
? `
|
||||
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)
|
||||
`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined,
|
||||
}}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
{/* 그리드 레이어 */}
|
||||
<GridLayer
|
||||
gridConfig={gridConfig}
|
||||
pageWidth={canvasWidth * 3.7795} // mm to px
|
||||
pageHeight={canvasHeight * 3.7795}
|
||||
/>
|
||||
|
||||
{/* 페이지 여백 가이드 */}
|
||||
{currentPage && (
|
||||
<div
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { QueryManager } from "./QueryManager";
|
||||
import { SignaturePad } from "./SignaturePad";
|
||||
import { SignatureGenerator } from "./SignatureGenerator";
|
||||
import { GridSettingsPanel } from "./GridSettingsPanel";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
@@ -103,7 +102,7 @@ export function ReportDesignerRightPanel() {
|
||||
<div className="w-[450px] border-l bg-white">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="page" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
페이지
|
||||
@@ -112,10 +111,6 @@ export function ReportDesignerRightPanel() {
|
||||
<Settings className="h-3 w-3" />
|
||||
속성
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="grid" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
그리드
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="queries" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
쿼리
|
||||
@@ -1401,15 +1396,6 @@ export function ReportDesignerRightPanel() {
|
||||
</TabsContent>
|
||||
|
||||
{/* 쿼리 탭 */}
|
||||
{/* 그리드 탭 */}
|
||||
<TabsContent value="grid" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<GridSettingsPanel />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="queries" className="mt-0 h-[calc(100vh-120px)]">
|
||||
<QueryManager />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -24,6 +26,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
const [description, setDescription] = useState("");
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// 화면 코드 자동 생성
|
||||
const generateCode = async () => {
|
||||
try {
|
||||
@@ -65,6 +69,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
||||
}, [screenName, screenCode, tableName]);
|
||||
|
||||
// 테이블 필터링
|
||||
const filteredTables = useMemo(() => {
|
||||
if (!tableSearchTerm) return tables;
|
||||
const searchLower = tableSearchTerm.toLowerCase();
|
||||
return tables.filter(
|
||||
(table) =>
|
||||
table.displayName.toLowerCase().includes(searchLower) || table.tableName.toLowerCase().includes(searchLower),
|
||||
);
|
||||
}, [tables, tableSearchTerm]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid || submitting) return;
|
||||
try {
|
||||
@@ -124,19 +138,82 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">테이블</Label>
|
||||
<select
|
||||
id="tableName"
|
||||
className="w-full rounded-md border px-3 py-2 text-sm"
|
||||
<Select
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
onValueChange={setTableName}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
// Select가 열릴 때 검색창에 포커스
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">테이블 선택...</option>
|
||||
{tables.map((t) => (
|
||||
<option key={t.tableName} value={t.tableName}>
|
||||
{t.displayName} ({t.tableName})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-80">
|
||||
{/* 검색 입력 필드 */}
|
||||
<div
|
||||
className="sticky top-0 z-10 border-b bg-white p-2"
|
||||
onKeyDown={(e) => {
|
||||
// 이 div 내에서 발생하는 모든 키 이벤트를 차단
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="테이블명으로 검색..."
|
||||
value={tableSearchTerm}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setTableSearchTerm(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 이벤트가 Select로 전파되지 않도록 완전 차단
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 pr-8 pl-10 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{tableSearchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setTableSearchTerm("");
|
||||
}}
|
||||
className="hover:text-muted-foreground absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 옵션들 */}
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredTables.length === 0 ? (
|
||||
<div className="px-2 py-6 text-center text-sm text-gray-500">
|
||||
{tableSearchTerm ? `"${tableSearchTerm}"에 대한 검색 결과가 없습니다` : "테이블이 없습니다"}
|
||||
</div>
|
||||
) : (
|
||||
filteredTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName} ({table.tableName})
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
|
||||
269
frontend/components/screen/GridLayoutBuilder.tsx
Normal file
269
frontend/components/screen/GridLayoutBuilder.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GridLayout, LayoutRow, RowComponent, CreateRowOptions } from "@/types/grid-system";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { LayoutRowRenderer } from "./LayoutRowRenderer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Grid3x3 } from "lucide-react";
|
||||
import { GAP_PRESETS } from "@/lib/constants/columnSpans";
|
||||
|
||||
interface GridLayoutBuilderProps {
|
||||
layout: GridLayout;
|
||||
onUpdateLayout: (layout: GridLayout) => void;
|
||||
selectedRowId?: string;
|
||||
selectedComponentId?: string;
|
||||
onSelectRow?: (rowId: string) => void;
|
||||
onSelectComponent?: (componentId: string) => void;
|
||||
showGridGuides?: boolean;
|
||||
}
|
||||
|
||||
export const GridLayoutBuilder: React.FC<GridLayoutBuilderProps> = ({
|
||||
layout,
|
||||
onUpdateLayout,
|
||||
selectedRowId,
|
||||
selectedComponentId,
|
||||
onSelectRow,
|
||||
onSelectComponent,
|
||||
showGridGuides = true,
|
||||
}) => {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
|
||||
// 새 행 추가
|
||||
const addNewRow = useCallback(
|
||||
(options?: CreateRowOptions) => {
|
||||
const newRow: LayoutRow = {
|
||||
id: `row-${Date.now()}`,
|
||||
rowIndex: layout.rows.length,
|
||||
height: options?.height || "auto",
|
||||
fixedHeight: options?.fixedHeight,
|
||||
gap: options?.gap || "sm",
|
||||
padding: options?.padding || "sm",
|
||||
alignment: options?.alignment || "start",
|
||||
verticalAlignment: "middle",
|
||||
components: [],
|
||||
};
|
||||
|
||||
onUpdateLayout({
|
||||
...layout,
|
||||
rows: [...layout.rows, newRow],
|
||||
});
|
||||
|
||||
// 새로 추가된 행 선택
|
||||
if (onSelectRow) {
|
||||
onSelectRow(newRow.id);
|
||||
}
|
||||
},
|
||||
[layout, onUpdateLayout, onSelectRow],
|
||||
);
|
||||
|
||||
// 행 삭제
|
||||
const deleteRow = useCallback(
|
||||
(rowId: string) => {
|
||||
const updatedRows = layout.rows
|
||||
.filter((row) => row.id !== rowId)
|
||||
.map((row, index) => ({
|
||||
...row,
|
||||
rowIndex: index,
|
||||
}));
|
||||
|
||||
onUpdateLayout({
|
||||
...layout,
|
||||
rows: updatedRows,
|
||||
});
|
||||
},
|
||||
[layout, onUpdateLayout],
|
||||
);
|
||||
|
||||
// 행 순서 변경
|
||||
const moveRow = useCallback(
|
||||
(rowId: string, direction: "up" | "down") => {
|
||||
const rowIndex = layout.rows.findIndex((row) => row.id === rowId);
|
||||
if (rowIndex === -1) return;
|
||||
|
||||
const newIndex = direction === "up" ? rowIndex - 1 : rowIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= layout.rows.length) return;
|
||||
|
||||
const updatedRows = [...layout.rows];
|
||||
[updatedRows[rowIndex], updatedRows[newIndex]] = [updatedRows[newIndex], updatedRows[rowIndex]];
|
||||
|
||||
// 인덱스 재정렬
|
||||
updatedRows.forEach((row, index) => {
|
||||
row.rowIndex = index;
|
||||
});
|
||||
|
||||
onUpdateLayout({
|
||||
...layout,
|
||||
rows: updatedRows,
|
||||
});
|
||||
},
|
||||
[layout, onUpdateLayout],
|
||||
);
|
||||
|
||||
// 행 업데이트
|
||||
const updateRow = useCallback(
|
||||
(rowId: string, updates: Partial<LayoutRow>) => {
|
||||
const updatedRows = layout.rows.map((row) => (row.id === rowId ? { ...row, ...updates } : row));
|
||||
|
||||
onUpdateLayout({
|
||||
...layout,
|
||||
rows: updatedRows,
|
||||
});
|
||||
},
|
||||
[layout, onUpdateLayout],
|
||||
);
|
||||
|
||||
// 컴포넌트 선택
|
||||
const handleSelectComponent = useCallback(
|
||||
(componentId: string) => {
|
||||
if (onSelectComponent) {
|
||||
onSelectComponent(componentId);
|
||||
}
|
||||
},
|
||||
[onSelectComponent],
|
||||
);
|
||||
|
||||
// 행 선택
|
||||
const handleSelectRow = useCallback(
|
||||
(rowId: string) => {
|
||||
if (onSelectRow) {
|
||||
onSelectRow(rowId);
|
||||
}
|
||||
},
|
||||
[onSelectRow],
|
||||
);
|
||||
|
||||
// 컨테이너 클래스
|
||||
const containerClasses = cn("w-full h-full overflow-auto bg-gray-50 relative", isDraggingOver && "bg-blue-50");
|
||||
|
||||
// 글로벌 컨테이너 클래스
|
||||
const globalContainerClasses = cn(
|
||||
"mx-auto relative",
|
||||
layout.globalSettings.containerMaxWidth === "full" ? "w-full" : `max-w-${layout.globalSettings.containerMaxWidth}`,
|
||||
GAP_PRESETS[layout.globalSettings.containerPadding].class.replace("gap-", "px-"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* 그리드 가이드라인 */}
|
||||
{showGridGuides && (
|
||||
<div className="pointer-events-none absolute inset-0 z-0">
|
||||
<div className={globalContainerClasses}>
|
||||
<div className="grid h-full grid-cols-12">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i} className="h-full border-l border-dashed border-gray-300 opacity-30" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메인 컨테이너 */}
|
||||
<div className={cn(globalContainerClasses, "relative z-10 py-8")}>
|
||||
{layout.rows.length === 0 ? (
|
||||
// 빈 레이아웃
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white">
|
||||
<Grid3x3 className="mb-4 h-16 w-16 text-gray-300" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-600">레이아웃이 비어있습니다</h3>
|
||||
<p className="mb-6 text-sm text-gray-500">첫 번째 행을 추가하여 시작하세요</p>
|
||||
<Button onClick={() => addNewRow()} size="lg">
|
||||
<Plus className="mr-2 h-5 w-5" />첫 행 추가
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// 행 목록
|
||||
<div className="space-y-4">
|
||||
{layout.rows.map((row) => (
|
||||
<LayoutRowRenderer
|
||||
key={row.id}
|
||||
row={row}
|
||||
components={layout.components}
|
||||
isSelected={selectedRowId === row.id}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onSelectRow={() => handleSelectRow(row.id)}
|
||||
onSelectComponent={handleSelectComponent}
|
||||
onUpdateRow={(updatedRow) => updateRow(row.id, updatedRow)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새 행 추가 버튼 */}
|
||||
{layout.rows.length > 0 && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<div className="inline-flex flex-col gap-2">
|
||||
<Button onClick={() => addNewRow()} variant="outline" size="lg" className="w-full">
|
||||
<Plus className="mr-2 h-5 w-5" />새 행 추가
|
||||
</Button>
|
||||
|
||||
{/* 빠른 추가 버튼들 */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
addNewRow({
|
||||
gap: "sm",
|
||||
padding: "sm",
|
||||
alignment: "start",
|
||||
})
|
||||
}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
폼 행
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
addNewRow({
|
||||
gap: "md",
|
||||
padding: "md",
|
||||
alignment: "stretch",
|
||||
})
|
||||
}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
2분할
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
addNewRow({
|
||||
gap: "none",
|
||||
padding: "md",
|
||||
alignment: "stretch",
|
||||
})
|
||||
}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
전체
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이아웃 정보 */}
|
||||
<div className="mt-8 rounded-lg border bg-white p-4">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>행: {layout.rows.length}</span>
|
||||
<span>컴포넌트: {layout.components.size}</span>
|
||||
<span>
|
||||
컨테이너:{" "}
|
||||
{layout.globalSettings.containerMaxWidth === "full" ? "전체" : layout.globalSettings.containerMaxWidth}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400">12컬럼 그리드</span>
|
||||
{showGridGuides && <span className="text-xs text-green-600">가이드 표시됨</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -43,6 +43,8 @@ import { FormValidationIndicator } from "@/components/common/FormValidationIndic
|
||||
import { useFormValidation } from "@/hooks/useFormValidation";
|
||||
import { UnifiedColumnInfo as ColumnInfo } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InteractiveScreenViewerProps {
|
||||
component: ComponentData;
|
||||
|
||||
181
frontend/components/screen/LayoutRowRenderer.tsx
Normal file
181
frontend/components/screen/LayoutRowRenderer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LayoutRow } from "@/types/grid-system";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { GAP_PRESETS, buildGridClasses } from "@/lib/constants/columnSpans";
|
||||
import { RealtimePreviewDynamic } from "./RealtimePreviewDynamic";
|
||||
|
||||
interface LayoutRowRendererProps {
|
||||
row: LayoutRow;
|
||||
components: Map<string, ComponentData>;
|
||||
isSelected: boolean;
|
||||
selectedComponentId?: string;
|
||||
onSelectRow: () => void;
|
||||
onSelectComponent: (componentId: string) => void;
|
||||
onUpdateRow?: (row: LayoutRow) => void;
|
||||
}
|
||||
|
||||
export const LayoutRowRenderer: React.FC<LayoutRowRendererProps> = ({
|
||||
row,
|
||||
components,
|
||||
isSelected,
|
||||
selectedComponentId,
|
||||
onSelectRow,
|
||||
onSelectComponent,
|
||||
onUpdateRow,
|
||||
}) => {
|
||||
// 행 클래스 생성
|
||||
const rowClasses = cn(
|
||||
// 그리드 기본
|
||||
"grid grid-cols-12 w-full relative",
|
||||
|
||||
// Gap (컴포넌트 간격)
|
||||
GAP_PRESETS[row.gap].class,
|
||||
|
||||
// Padding
|
||||
GAP_PRESETS[row.padding].class.replace("gap-", "p-"),
|
||||
|
||||
// 높이
|
||||
row.height === "auto" && "h-auto",
|
||||
row.height === "fixed" && row.fixedHeight && `h-[${row.fixedHeight}px]`,
|
||||
row.height === "min" && row.minHeight && `min-h-[${row.minHeight}px]`,
|
||||
row.height === "max" && row.maxHeight && `max-h-[${row.maxHeight}px]`,
|
||||
|
||||
// 수평 정렬
|
||||
row.alignment === "start" && "justify-items-start",
|
||||
row.alignment === "center" && "justify-items-center",
|
||||
row.alignment === "end" && "justify-items-end",
|
||||
row.alignment === "stretch" && "justify-items-stretch",
|
||||
row.alignment === "baseline" && "justify-items-baseline",
|
||||
|
||||
// 수직 정렬
|
||||
row.verticalAlignment === "top" && "items-start",
|
||||
row.verticalAlignment === "middle" && "items-center",
|
||||
row.verticalAlignment === "bottom" && "items-end",
|
||||
row.verticalAlignment === "stretch" && "items-stretch",
|
||||
|
||||
// 선택 상태
|
||||
isSelected && "ring-2 ring-blue-500 ring-inset",
|
||||
|
||||
// 호버 효과
|
||||
"hover:bg-gray-50 transition-colors cursor-pointer border-2 border-dashed border-transparent hover:border-gray-300",
|
||||
);
|
||||
|
||||
// 배경색 스타일
|
||||
const rowStyle: React.CSSProperties = {
|
||||
...(row.backgroundColor && { backgroundColor: row.backgroundColor }),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={rowClasses} style={rowStyle} onClick={onSelectRow} data-row-id={row.id}>
|
||||
{/* 행 인덱스 표시 */}
|
||||
<div className="absolute top-1/2 -left-8 -translate-y-1/2 font-mono text-xs text-gray-400">
|
||||
{row.rowIndex + 1}
|
||||
</div>
|
||||
|
||||
{row.components.length === 0 ? (
|
||||
// 빈 행
|
||||
<div className="col-span-12 flex items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8">
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-sm text-gray-400">컴포넌트를 여기에 드래그하세요</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<button className="rounded border bg-white px-2 py-1 text-xs hover:bg-gray-50">폼 행 추가</button>
|
||||
<button className="rounded border bg-white px-2 py-1 text-xs hover:bg-gray-50">2분할</button>
|
||||
<button className="rounded border bg-white px-2 py-1 text-xs hover:bg-gray-50">전체</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 컴포넌트 렌더링
|
||||
row.components.map((rowComponent) => {
|
||||
const component = components.get(rowComponent.componentId);
|
||||
if (!component) return null;
|
||||
|
||||
// 그리드 클래스 생성
|
||||
const componentClasses = cn(
|
||||
// 컬럼 스팬
|
||||
buildGridClasses(rowComponent.columnSpan, rowComponent.columnStart),
|
||||
|
||||
// 정렬 순서
|
||||
rowComponent.order && `order-${rowComponent.order}`,
|
||||
|
||||
// 선택 상태
|
||||
selectedComponentId === component.id && "ring-2 ring-green-500 ring-inset",
|
||||
);
|
||||
|
||||
// 오프셋 스타일 (여백)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
...(rowComponent.offset && {
|
||||
marginLeft: `${(GAP_PRESETS[rowComponent.offset as any]?.value || 0) * 4}px`,
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rowComponent.id}
|
||||
className={componentClasses}
|
||||
style={componentStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectComponent(component.id);
|
||||
}}
|
||||
>
|
||||
<RealtimePreviewDynamic
|
||||
component={component}
|
||||
isSelected={selectedComponentId === component.id}
|
||||
isDesignMode={true}
|
||||
onClick={(e) => {
|
||||
e?.stopPropagation();
|
||||
onSelectComponent(component.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 선택 시 행 설정 버튼 */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-1/2 -right-8 flex -translate-y-1/2 flex-col gap-1">
|
||||
<button
|
||||
className="rounded border bg-white p-1 shadow-sm hover:bg-gray-50"
|
||||
title="행 설정"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 행 설정 패널 열기
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="rounded border bg-white p-1 shadow-sm hover:bg-red-50 hover:text-red-600"
|
||||
title="행 삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 행 삭제
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Search, Monitor, Settings, X, Plus } from "lucide-react";
|
||||
@@ -46,36 +45,51 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
const [showReplaceDialog, setShowReplaceDialog] = useState(false);
|
||||
const [assignmentSuccess, setAssignmentSuccess] = useState(false);
|
||||
const [assignmentMessage, setAssignmentMessage] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 메뉴 목록 로드 (관리자 메뉴만)
|
||||
// 메뉴 목록 로드 (관리자 메뉴 + 사용자 메뉴)
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 화면관리는 관리자 전용 기능이므로 관리자 메뉴만 가져오기
|
||||
// 관리자 메뉴 가져오기
|
||||
const adminResponse = await apiClient.get("/admin/menus", { params: { menuType: "0" } });
|
||||
const adminMenus = adminResponse.data?.data || [];
|
||||
|
||||
// 관리자 메뉴 정규화
|
||||
const normalizedAdminMenus = adminMenus.map((menu: any) => ({
|
||||
// 사용자 메뉴 가져오기
|
||||
const userResponse = await apiClient.get("/admin/menus", { params: { menuType: "1" } });
|
||||
const userMenus = userResponse.data?.data || [];
|
||||
|
||||
// 메뉴 정규화 함수
|
||||
const normalizeMenu = (menu: any) => ({
|
||||
objid: menu.objid || menu.OBJID,
|
||||
parent_obj_id: menu.parent_obj_id || menu.PARENT_OBJ_ID,
|
||||
menu_name_kor: menu.menu_name_kor || menu.MENU_NAME_KOR,
|
||||
menu_url: menu.menu_url || menu.MENU_URL,
|
||||
menu_desc: menu.menu_desc || menu.MENU_DESC,
|
||||
seq: menu.seq || menu.SEQ,
|
||||
menu_type: "0", // 관리자 메뉴
|
||||
menu_type: menu.menu_type || menu.MENU_TYPE,
|
||||
status: menu.status || menu.STATUS,
|
||||
lev: menu.lev || menu.LEV,
|
||||
company_code: menu.company_code || menu.COMPANY_CODE,
|
||||
company_name: menu.company_name || menu.COMPANY_NAME,
|
||||
}));
|
||||
});
|
||||
|
||||
// console.log("로드된 관리자 메뉴 목록:", {
|
||||
// total: normalizedAdminMenus.length,
|
||||
// sample: normalizedAdminMenus.slice(0, 3),
|
||||
// 관리자 메뉴 정규화
|
||||
const normalizedAdminMenus = adminMenus.map((menu: any) => normalizeMenu(menu));
|
||||
|
||||
// 사용자 메뉴 정규화
|
||||
const normalizedUserMenus = userMenus.map((menu: any) => normalizeMenu(menu));
|
||||
|
||||
// 모든 메뉴 합치기
|
||||
const allMenus = [...normalizedAdminMenus, ...normalizedUserMenus];
|
||||
|
||||
// console.log("로드된 전체 메뉴 목록:", {
|
||||
// totalAdmin: normalizedAdminMenus.length,
|
||||
// totalUser: normalizedUserMenus.length,
|
||||
// total: allMenus.length,
|
||||
// });
|
||||
setMenus(normalizedAdminMenus);
|
||||
setMenus(allMenus);
|
||||
} catch (error) {
|
||||
// console.error("메뉴 목록 로드 실패:", error);
|
||||
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
|
||||
@@ -244,8 +258,8 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
);
|
||||
});
|
||||
|
||||
// 메뉴 옵션 생성 (계층 구조 표시)
|
||||
const getMenuOptions = (): JSX.Element[] => {
|
||||
// 메뉴 옵션 생성 (계층 구조 표시, 타입별 그룹화)
|
||||
const getMenuOptions = (): React.ReactNode[] => {
|
||||
if (loading) {
|
||||
return [
|
||||
<SelectItem key="loading" value="loading" disabled>
|
||||
@@ -262,19 +276,58 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
];
|
||||
}
|
||||
|
||||
return filteredMenus
|
||||
.filter((menu) => menu.objid && menu.objid.toString().trim() !== "") // objid가 유효한 메뉴만 필터링
|
||||
.map((menu) => {
|
||||
const indent = " ".repeat(Math.max(0, menu.lev || 0));
|
||||
const menuId = menu.objid!.toString(); // 이미 필터링했으므로 non-null assertion 사용
|
||||
// 관리자 메뉴와 사용자 메뉴 분리
|
||||
const adminMenus = filteredMenus.filter(
|
||||
(menu) => menu.menu_type === "0" && menu.objid && menu.objid.toString().trim() !== "",
|
||||
);
|
||||
const userMenus = filteredMenus.filter(
|
||||
(menu) => menu.menu_type === "1" && menu.objid && menu.objid.toString().trim() !== "",
|
||||
);
|
||||
|
||||
return (
|
||||
const options: React.ReactNode[] = [];
|
||||
|
||||
// 관리자 메뉴 섹션
|
||||
if (adminMenus.length > 0) {
|
||||
options.push(
|
||||
<div key="admin-header" className="bg-blue-50 px-2 py-1.5 text-xs font-semibold text-blue-600">
|
||||
👤 관리자 메뉴
|
||||
</div>,
|
||||
);
|
||||
adminMenus.forEach((menu) => {
|
||||
const indent = " ".repeat(Math.max(0, menu.lev || 0));
|
||||
const menuId = menu.objid!.toString();
|
||||
options.push(
|
||||
<SelectItem key={menuId} value={menuId}>
|
||||
{indent}
|
||||
{menu.menu_name_kor}
|
||||
</SelectItem>
|
||||
</SelectItem>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 메뉴 섹션
|
||||
if (userMenus.length > 0) {
|
||||
if (adminMenus.length > 0) {
|
||||
options.push(<div key="separator" className="my-1 border-t" />);
|
||||
}
|
||||
options.push(
|
||||
<div key="user-header" className="bg-green-50 px-2 py-1.5 text-xs font-semibold text-green-600">
|
||||
👥 사용자 메뉴
|
||||
</div>,
|
||||
);
|
||||
userMenus.forEach((menu) => {
|
||||
const indent = " ".repeat(Math.max(0, menu.lev || 0));
|
||||
const menuId = menu.objid!.toString();
|
||||
options.push(
|
||||
<SelectItem key={menuId} value={menuId}>
|
||||
{indent}
|
||||
{menu.menu_name_kor}
|
||||
</SelectItem>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -348,9 +401,9 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
저장된 화면을 메뉴에 할당하여 사용자가 접근할 수 있도록 설정합니다.
|
||||
</DialogDescription>
|
||||
{screenInfo && (
|
||||
<div className="mt-2 rounded-lg border bg-accent p-3">
|
||||
<div className="bg-accent mt-2 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-primary" />
|
||||
<Monitor className="text-primary h-4 w-4" />
|
||||
<span className="font-medium text-blue-900">{screenInfo.screenName}</span>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{screenInfo.screenCode}
|
||||
@@ -365,29 +418,51 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
{/* 메뉴 선택 (검색 기능 포함) */}
|
||||
<div>
|
||||
<Label htmlFor="menu-select">할당할 메뉴 선택</Label>
|
||||
<Select value={selectedMenuId} onValueChange={handleMenuSelect} disabled={loading}>
|
||||
<Select
|
||||
value={selectedMenuId}
|
||||
onValueChange={handleMenuSelect}
|
||||
disabled={loading}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
// Select가 열릴 때 검색창에 포커스
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "메뉴 로딩 중..." : "메뉴를 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
{/* 검색 입력 필드 */}
|
||||
<div className="sticky top-0 z-10 border-b bg-white p-2">
|
||||
<div
|
||||
className="sticky top-0 z-10 border-b bg-white p-2"
|
||||
onKeyDown={(e) => {
|
||||
// 이 div 내에서 발생하는 모든 키 이벤트를 차단
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="메뉴명, URL, 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
e.stopPropagation(); // 이벤트 전파 방지
|
||||
e.stopPropagation();
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation(); // 키보드 이벤트 전파 방지
|
||||
// 이벤트가 Select로 전파되지 않도록 완전 차단
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 클릭 이벤트 전파 방지
|
||||
}}
|
||||
className="h-8 pr-8 pl-10 text-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-8 w-full rounded-md border px-3 py-2 pr-8 pl-10 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
@@ -396,7 +471,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
e.stopPropagation();
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400 hover:text-muted-foreground"
|
||||
className="hover:text-muted-foreground absolute top-1/2 right-2 h-4 w-4 -translate-y-1/2 transform text-gray-400"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -416,12 +491,14 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{selectedMenu.menu_name_kor}</h4>
|
||||
<Badge variant="default">관리자</Badge>
|
||||
<Badge variant={selectedMenu.menu_type === "0" ? "default" : "secondary"}>
|
||||
{selectedMenu.menu_type === "0" ? "관리자" : "사용자"}
|
||||
</Badge>
|
||||
<Badge variant={selectedMenu.status === "active" ? "default" : "outline"}>
|
||||
{selectedMenu.status === "active" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-1 space-y-1 text-sm">
|
||||
{selectedMenu.menu_url && <p>URL: {selectedMenu.menu_url}</p>}
|
||||
{selectedMenu.menu_desc && <p>설명: {selectedMenu.menu_desc}</p>}
|
||||
{selectedMenu.company_name && <p>회사: {selectedMenu.company_name}</p>}
|
||||
@@ -494,7 +571,7 @@ export const MenuAssignmentModal: React.FC<MenuAssignmentModalProps> = ({
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 기존 화면 목록 */}
|
||||
<div className="rounded-lg border bg-destructive/10 p-3">
|
||||
<div className="bg-destructive/10 rounded-lg border p-3">
|
||||
<p className="mb-2 text-sm font-medium text-red-800">제거될 화면 ({existingScreens.length}개):</p>
|
||||
<div className="space-y-1">
|
||||
{existingScreens.map((screen) => (
|
||||
|
||||
@@ -42,6 +42,7 @@ import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||||
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
|
||||
|
||||
import StyleEditor from "./StyleEditor";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
@@ -198,83 +199,88 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
|
||||
|
||||
// 파일 컴포넌트 데이터 복원 함수 (실제 DB에서 조회)
|
||||
const restoreFileComponentsData = useCallback(async (components: ComponentData[]) => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
// console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
|
||||
|
||||
try {
|
||||
// 실제 DB에서 화면의 모든 파일 정보 조회
|
||||
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||
|
||||
if (!fileResponse.success) {
|
||||
// console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
|
||||
return;
|
||||
}
|
||||
const restoreFileComponentsData = useCallback(
|
||||
async (components: ComponentData[]) => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
const { componentFiles } = fileResponse;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// 전역 파일 상태 초기화
|
||||
const globalFileState: {[key: string]: any[]} = {};
|
||||
let restoredCount = 0;
|
||||
// console.log("🔄 파일 컴포넌트 데이터 복원 시작:", components.length);
|
||||
|
||||
// DB에서 조회한 파일 정보를 전역 상태로 복원
|
||||
Object.keys(componentFiles).forEach(componentId => {
|
||||
const files = componentFiles[componentId];
|
||||
if (files && files.length > 0) {
|
||||
globalFileState[componentId] = files;
|
||||
restoredCount++;
|
||||
|
||||
// localStorage에도 백업
|
||||
const backupKey = `fileComponent_${componentId}_files`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||
|
||||
console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", {
|
||||
componentId: componentId,
|
||||
fileCount: files.length,
|
||||
files: files.map(f => ({ objid: f.objid, name: f.realFileName }))
|
||||
});
|
||||
}
|
||||
});
|
||||
try {
|
||||
// 실제 DB에서 화면의 모든 파일 정보 조회
|
||||
const fileResponse = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||
|
||||
// 전역 상태 업데이트
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
// 모든 파일 컴포넌트에 복원 완료 이벤트 발생
|
||||
Object.keys(globalFileState).forEach(componentId => {
|
||||
const files = globalFileState[componentId];
|
||||
const syncEvent = new CustomEvent('globalFileStateChanged', {
|
||||
detail: {
|
||||
componentId: componentId,
|
||||
files: files,
|
||||
fileCount: files.length,
|
||||
timestamp: Date.now(),
|
||||
isRestore: true
|
||||
if (!fileResponse.success) {
|
||||
// console.warn("⚠️ 파일 정보 조회 실패:", fileResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const { componentFiles } = fileResponse;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
// 전역 파일 상태 초기화
|
||||
const globalFileState: { [key: string]: any[] } = {};
|
||||
let restoredCount = 0;
|
||||
|
||||
// DB에서 조회한 파일 정보를 전역 상태로 복원
|
||||
Object.keys(componentFiles).forEach((componentId) => {
|
||||
const files = componentFiles[componentId];
|
||||
if (files && files.length > 0) {
|
||||
globalFileState[componentId] = files;
|
||||
restoredCount++;
|
||||
|
||||
// localStorage에도 백업
|
||||
const backupKey = `fileComponent_${componentId}_files`;
|
||||
localStorage.setItem(backupKey, JSON.stringify(files));
|
||||
|
||||
console.log("📁 DB에서 파일 컴포넌트 데이터 복원:", {
|
||||
componentId: componentId,
|
||||
fileCount: files.length,
|
||||
files: files.map((f) => ({ objid: f.objid, name: f.realFileName })),
|
||||
});
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
});
|
||||
|
||||
console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", {
|
||||
totalComponents: components.length,
|
||||
restoredFileComponents: restoredCount,
|
||||
totalFiles: fileResponse.totalFiles,
|
||||
globalFileState: Object.keys(globalFileState).map(id => ({
|
||||
id,
|
||||
fileCount: globalFileState[id]?.length || 0
|
||||
}))
|
||||
});
|
||||
// 전역 상태 업데이트
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
if (restoredCount > 0) {
|
||||
toast.success(`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`);
|
||||
// 모든 파일 컴포넌트에 복원 완료 이벤트 발생
|
||||
Object.keys(globalFileState).forEach((componentId) => {
|
||||
const files = globalFileState[componentId];
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: componentId,
|
||||
files: files,
|
||||
fileCount: files.length,
|
||||
timestamp: Date.now(),
|
||||
isRestore: true,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
});
|
||||
|
||||
console.log("✅ DB 파일 컴포넌트 데이터 복원 완료:", {
|
||||
totalComponents: components.length,
|
||||
restoredFileComponents: restoredCount,
|
||||
totalFiles: fileResponse.totalFiles,
|
||||
globalFileState: Object.keys(globalFileState).map((id) => ({
|
||||
id,
|
||||
fileCount: globalFileState[id]?.length || 0,
|
||||
})),
|
||||
});
|
||||
|
||||
if (restoredCount > 0) {
|
||||
toast.success(
|
||||
`${restoredCount}개 파일 컴포넌트 데이터가 DB에서 복원되었습니다. (총 ${fileResponse.totalFiles}개 파일)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
|
||||
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("❌ 파일 컴포넌트 데이터 복원 실패:", error);
|
||||
toast.error("파일 데이터 복원 중 오류가 발생했습니다.");
|
||||
}
|
||||
}, [selectedScreen?.screenId]);
|
||||
},
|
||||
[selectedScreen?.screenId],
|
||||
);
|
||||
|
||||
// 드래그 선택 상태
|
||||
const [selectionDrag, setSelectionDrag] = useState({
|
||||
@@ -747,22 +753,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
|
||||
try {
|
||||
// console.log("🔄 화면 파일 복원 시작:", selectedScreen.screenId);
|
||||
|
||||
|
||||
// 해당 화면의 모든 파일 조회
|
||||
const response = await ScreenFileAPI.getScreenComponentFiles(selectedScreen.screenId);
|
||||
|
||||
|
||||
if (response.success && response.componentFiles) {
|
||||
// console.log("📁 복원할 파일 데이터:", response.componentFiles);
|
||||
|
||||
|
||||
// 각 컴포넌트별로 파일 복원 (전역 상태와 localStorage 우선 적용)
|
||||
Object.entries(response.componentFiles).forEach(([componentId, serverFiles]) => {
|
||||
if (Array.isArray(serverFiles) && serverFiles.length > 0) {
|
||||
// 🎯 전역 상태와 localStorage에서 현재 파일 상태 확인
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const currentGlobalFiles = globalFileState[componentId] || [];
|
||||
|
||||
|
||||
let currentLocalStorageFiles: any[] = [];
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const storedFiles = localStorage.getItem(`fileComponent_${componentId}_files`);
|
||||
if (storedFiles) {
|
||||
@@ -772,7 +778,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
// console.warn("localStorage 파일 파싱 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 🎯 우선순위: 전역 상태 > localStorage > 서버 데이터
|
||||
let finalFiles = serverFiles;
|
||||
if (currentGlobalFiles.length > 0) {
|
||||
@@ -784,43 +790,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
} else {
|
||||
// console.log(`📂 컴포넌트 ${componentId} 서버 데이터 적용:`, finalFiles.length, "개");
|
||||
}
|
||||
|
||||
|
||||
// 전역 상태에 파일 저장
|
||||
globalFileState[componentId] = finalFiles;
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).globalFileState = globalFileState;
|
||||
}
|
||||
|
||||
|
||||
// localStorage에도 백업
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(`fileComponent_${componentId}_files`, JSON.stringify(finalFiles));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 레이아웃의 컴포넌트들에 파일 정보 적용 (전역 상태 우선)
|
||||
setLayout(prevLayout => {
|
||||
const updatedComponents = prevLayout.components.map(comp => {
|
||||
setLayout((prevLayout) => {
|
||||
const updatedComponents = prevLayout.components.map((comp) => {
|
||||
// 🎯 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const finalFiles = globalFileState[comp.id] || [];
|
||||
|
||||
|
||||
if (finalFiles.length > 0) {
|
||||
return {
|
||||
...comp,
|
||||
uploadedFiles: finalFiles,
|
||||
lastFileUpdate: Date.now()
|
||||
lastFileUpdate: Date.now(),
|
||||
};
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
components: updatedComponents
|
||||
components: updatedComponents,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// console.log("✅ 화면 파일 복원 완료");
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -832,14 +838,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
// console.log("🔄 ScreenDesigner: 전역 파일 상태 변경 감지", event.detail);
|
||||
setForceRenderTrigger(prev => prev + 1);
|
||||
setForceRenderTrigger((prev) => prev + 1);
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
@@ -897,17 +903,35 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
useEffect(() => {
|
||||
if (selectedScreen?.screenId) {
|
||||
// 현재 화면 ID를 전역 변수로 설정 (파일 업로드 시 사용)
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).__CURRENT_SCREEN_ID__ = selectedScreen.screenId;
|
||||
}
|
||||
|
||||
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
const response = await screenApi.getLayout(selectedScreen.screenId);
|
||||
if (response) {
|
||||
// 🔄 마이그레이션 필요 여부 확인
|
||||
let layoutToUse = response;
|
||||
|
||||
if (needsMigration(response)) {
|
||||
console.log("🔄 픽셀 기반 레이아웃 감지 - 그리드 시스템으로 마이그레이션 시작...");
|
||||
|
||||
const canvasWidth = response.screenResolution?.width || 1920;
|
||||
layoutToUse = safeMigrateLayout(response, canvasWidth);
|
||||
|
||||
console.log("✅ 마이그레이션 완료:", {
|
||||
originalComponents: response.components.length,
|
||||
migratedComponents: layoutToUse.components.length,
|
||||
sampleComponent: layoutToUse.components[0],
|
||||
});
|
||||
|
||||
toast.success("레이아웃이 새로운 그리드 시스템으로 자동 변환되었습니다.");
|
||||
}
|
||||
|
||||
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
||||
const layoutWithDefaultGrid = {
|
||||
...response,
|
||||
...layoutToUse,
|
||||
gridSettings: {
|
||||
columns: 12,
|
||||
gap: 16,
|
||||
@@ -916,14 +940,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
showGrid: true,
|
||||
gridColor: "#d1d5db",
|
||||
gridOpacity: 0.5,
|
||||
...response.gridSettings, // 기존 설정이 있으면 덮어쓰기
|
||||
...layoutToUse.gridSettings, // 기존 설정이 있으면 덮어쓰기
|
||||
},
|
||||
};
|
||||
|
||||
// 저장된 해상도 정보가 있으면 적용, 없으면 기본값 사용
|
||||
if (response.screenResolution) {
|
||||
setScreenResolution(response.screenResolution);
|
||||
// console.log("💾 저장된 해상도 불러옴:", response.screenResolution);
|
||||
if (layoutToUse.screenResolution) {
|
||||
setScreenResolution(layoutToUse.screenResolution);
|
||||
// console.log("💾 저장된 해상도 불러옴:", layoutToUse.screenResolution);
|
||||
} else {
|
||||
// 기본 해상도 (Full HD)
|
||||
const defaultResolution =
|
||||
@@ -1728,7 +1752,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
let componentSize = component.defaultSize;
|
||||
const isCardDisplay = component.id === "card-display";
|
||||
const isTableList = component.id === "table-list";
|
||||
|
||||
|
||||
// 컴포넌트별 기본 그리드 컬럼 수 설정
|
||||
const gridColumns = isCardDisplay ? 8 : isTableList ? 1 : 1;
|
||||
|
||||
@@ -1742,7 +1766,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
|
||||
// 컴포넌트별 최소 크기 보장
|
||||
const minWidth = isTableList ? 120 : isCardDisplay ? 400 : 100;
|
||||
|
||||
|
||||
componentSize = {
|
||||
...component.defaultSize,
|
||||
width: Math.max(calculatedWidth, minWidth),
|
||||
@@ -2197,22 +2221,20 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
(updates: Partial<ComponentData>) => {
|
||||
if (!selectedFileComponent) return;
|
||||
|
||||
const updatedComponents = layout.components.map(comp =>
|
||||
comp.id === selectedFileComponent.id
|
||||
? { ...comp, ...updates }
|
||||
: comp
|
||||
const updatedComponents = layout.components.map((comp) =>
|
||||
comp.id === selectedFileComponent.id ? { ...comp, ...updates } : comp,
|
||||
);
|
||||
|
||||
const newLayout = { ...layout, components: updatedComponents };
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
|
||||
// selectedFileComponent도 업데이트
|
||||
setSelectedFileComponent(prev => prev ? { ...prev, ...updates } : null);
|
||||
|
||||
setSelectedFileComponent((prev) => (prev ? { ...prev, ...updates } : null));
|
||||
|
||||
// selectedComponent가 같은 컴포넌트라면 업데이트
|
||||
if (selectedComponent?.id === selectedFileComponent.id) {
|
||||
setSelectedComponent(prev => prev ? { ...prev, ...updates } : null);
|
||||
setSelectedComponent((prev) => (prev ? { ...prev, ...updates } : null));
|
||||
}
|
||||
},
|
||||
[selectedFileComponent, layout, saveToHistory, selectedComponent],
|
||||
@@ -2225,22 +2247,19 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 더블클릭 처리
|
||||
const handleComponentDoubleClick = useCallback(
|
||||
(component: ComponentData, event?: React.MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
const handleComponentDoubleClick = useCallback((component: ComponentData, event?: React.MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
|
||||
// 파일 컴포넌트인 경우 상세 모달 열기
|
||||
if (component.type === "file") {
|
||||
setSelectedFileComponent(component);
|
||||
setShowFileAttachmentModal(true);
|
||||
return;
|
||||
}
|
||||
// 파일 컴포넌트인 경우 상세 모달 열기
|
||||
if (component.type === "file") {
|
||||
setSelectedFileComponent(component);
|
||||
setShowFileAttachmentModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
|
||||
// console.log("더블클릭된 컴포넌트:", component.type, component.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// 다른 컴포넌트 타입의 더블클릭 처리는 여기에 추가
|
||||
// console.log("더블클릭된 컴포넌트:", component.type, component.id);
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 클릭 처리 (다중선택 지원)
|
||||
const handleComponentClick = useCallback(
|
||||
@@ -3429,10 +3448,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
{/* 실제 작업 캔버스 (해상도 크기) - 반응형 개선 */}
|
||||
<div
|
||||
className="mx-auto bg-white shadow-lg"
|
||||
style={{
|
||||
style={{
|
||||
width: screenResolution.width,
|
||||
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
|
||||
minHeight: screenResolution.height
|
||||
minHeight: screenResolution.height,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -3533,7 +3552,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
}
|
||||
|
||||
// 전역 파일 상태도 key에 포함하여 실시간 리렌더링
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const globalFiles = globalFileState[component.id] || [];
|
||||
const componentFiles = (component as any).uploadedFiles || [];
|
||||
const fileStateKey = `${globalFiles.length}-${JSON.stringify(globalFiles.map((f: any) => f.objid) || [])}-${componentFiles.length}`;
|
||||
@@ -3556,16 +3575,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
|
||||
onConfigChange={(config) => {
|
||||
// console.log("📤 테이블 설정 변경을 상세설정에 반영:", config);
|
||||
|
||||
|
||||
// 컴포넌트의 componentConfig 업데이트
|
||||
const updatedComponents = layout.components.map(comp => {
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.id === component.id) {
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...comp.componentConfig,
|
||||
...config
|
||||
}
|
||||
...config,
|
||||
},
|
||||
};
|
||||
}
|
||||
return comp;
|
||||
@@ -3573,15 +3592,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: updatedComponents
|
||||
components: updatedComponents,
|
||||
};
|
||||
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
|
||||
|
||||
console.log("✅ 컴포넌트 설정 업데이트 완료:", {
|
||||
componentId: component.id,
|
||||
updatedConfig: config
|
||||
updatedConfig: config,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -3858,36 +3877,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
<StyleEditor
|
||||
style={selectedComponent.style || {}}
|
||||
onStyleChange={(newStyle) => {
|
||||
console.log("🔧 StyleEditor 크기 변경:", {
|
||||
console.log("🔧 StyleEditor 스타일 변경:", {
|
||||
componentId: selectedComponent.id,
|
||||
newStyle,
|
||||
currentSize: selectedComponent.size,
|
||||
hasWidth: !!newStyle.width,
|
||||
hasHeight: !!newStyle.height,
|
||||
});
|
||||
|
||||
// 스타일 업데이트
|
||||
updateComponentProperty(selectedComponent.id, "style", newStyle);
|
||||
|
||||
// 크기가 변경된 경우 component.size도 업데이트
|
||||
if (newStyle.width || newStyle.height) {
|
||||
const width = newStyle.width
|
||||
? parseInt(newStyle.width.replace("px", ""))
|
||||
: selectedComponent.size.width;
|
||||
const height = newStyle.height
|
||||
? parseInt(newStyle.height.replace("px", ""))
|
||||
: selectedComponent.size.height;
|
||||
// ✅ 높이만 업데이트 (너비는 gridColumnSpan으로 제어)
|
||||
if (newStyle.height) {
|
||||
const height = parseInt(newStyle.height.replace("px", ""));
|
||||
|
||||
console.log("📏 크기 업데이트:", {
|
||||
originalWidth: selectedComponent.size.width,
|
||||
console.log("📏 높이 업데이트:", {
|
||||
originalHeight: selectedComponent.size.height,
|
||||
newWidth: width,
|
||||
newHeight: height,
|
||||
styleWidth: newStyle.width,
|
||||
styleHeight: newStyle.height,
|
||||
});
|
||||
|
||||
updateComponentProperty(selectedComponent.id, "size.width", width);
|
||||
updateComponentProperty(selectedComponent.id, "size.height", height);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ContainerComponent as ContainerComponentType } from "@/types/screen";
|
||||
import { buildGridClasses, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans";
|
||||
|
||||
interface ContainerComponentProps {
|
||||
component: ContainerComponentType;
|
||||
@@ -22,12 +23,20 @@ export default function ContainerComponent({
|
||||
onMouseDown,
|
||||
isMoving,
|
||||
}: ContainerComponentProps) {
|
||||
// 그리드 클래스 생성
|
||||
const gridClasses = component.gridColumnSpan
|
||||
? buildGridClasses(component.gridColumnSpan, component.gridColumnStart)
|
||||
: "";
|
||||
|
||||
// 스타일 객체 생성
|
||||
const style: React.CSSProperties = {
|
||||
gridColumn: `span ${component.size.width}`,
|
||||
// 🔄 레거시 호환: gridColumnSpan이 없으면 기존 width 사용
|
||||
...(!component.gridColumnSpan && {
|
||||
gridColumn: `span ${component.size.width}`,
|
||||
}),
|
||||
minHeight: `${component.size.height}px`,
|
||||
...(component.style && {
|
||||
width: component.style.width,
|
||||
// ❌ width는 제거 (그리드 클래스로 제어)
|
||||
height: component.style.height,
|
||||
margin: component.style.margin,
|
||||
padding: component.style.padding,
|
||||
@@ -63,6 +72,7 @@ export default function ContainerComponent({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4",
|
||||
gridClasses, // 🆕 그리드 클래스 추가
|
||||
isSelected && "border-primary bg-accent",
|
||||
isMoving && "cursor-move",
|
||||
className,
|
||||
|
||||
@@ -6,8 +6,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // 임시 비활성화
|
||||
// import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
|
||||
import {
|
||||
ComponentData,
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
AreaLayoutType,
|
||||
TableInfo,
|
||||
} from "@/types/screen";
|
||||
import { ColumnSpanPreset, COLUMN_SPAN_PRESETS, COLUMN_SPAN_VALUES } from "@/lib/constants/columnSpans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
|
||||
@@ -124,16 +128,16 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
}) => {
|
||||
// 🔍 디버깅: PropertiesPanel 렌더링 및 dragState 전달 확인
|
||||
// console.log("📍 PropertiesPanel 렌더링:", {
|
||||
// renderTime: Date.now(),
|
||||
// selectedComponentId: selectedComponent?.id,
|
||||
// dragState: dragState
|
||||
// ? {
|
||||
// isDragging: dragState.isDragging,
|
||||
// draggedComponentId: dragState.draggedComponent?.id,
|
||||
// currentPosition: dragState.currentPosition,
|
||||
// dragStateRef: dragState, // 객체 참조 확인
|
||||
// }
|
||||
// : "null",
|
||||
// renderTime: Date.now(),
|
||||
// selectedComponentId: selectedComponent?.id,
|
||||
// dragState: dragState
|
||||
// ? {
|
||||
// isDragging: dragState.isDragging,
|
||||
// draggedComponentId: dragState.draggedComponent?.id,
|
||||
// currentPosition: dragState.currentPosition,
|
||||
// dragStateRef: dragState, // 객체 참조 확인
|
||||
// }
|
||||
// : "null",
|
||||
// });
|
||||
|
||||
// 동적 웹타입 목록 가져오기 - API에서 직접 조회
|
||||
@@ -161,9 +165,9 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
const getCurrentPosition = () => {
|
||||
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
||||
// console.log("🎯 드래그 중 실시간 위치:", {
|
||||
// draggedId: dragState.draggedComponent?.id,
|
||||
// selectedId: selectedComponent?.id,
|
||||
// currentPosition: dragState.currentPosition,
|
||||
// draggedId: dragState.draggedComponent?.id,
|
||||
// selectedId: selectedComponent?.id,
|
||||
// currentPosition: dragState.currentPosition,
|
||||
// });
|
||||
return {
|
||||
x: Math.round(dragState.currentPosition.x),
|
||||
@@ -226,20 +230,20 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
const area = selectedComponent.type === "area" ? (selectedComponent as AreaComponent) : null;
|
||||
|
||||
// console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
|
||||
// componentId: selectedComponent.id,
|
||||
// componentType: selectedComponent.type,
|
||||
// isDragging: dragState?.isDragging,
|
||||
// justFinishedDrag: dragState?.justFinishedDrag,
|
||||
// currentValues: {
|
||||
// placeholder: widget?.placeholder,
|
||||
// title: group?.title || area?.title,
|
||||
// description: area?.description,
|
||||
// actualPositionX: selectedComponent.position.x,
|
||||
// actualPositionY: selectedComponent.position.y,
|
||||
// dragPositionX: dragState?.currentPosition.x,
|
||||
// dragPositionY: dragState?.currentPosition.y,
|
||||
// },
|
||||
// getCurrentPosResult: getCurrentPosition(),
|
||||
// componentId: selectedComponent.id,
|
||||
// componentType: selectedComponent.type,
|
||||
// isDragging: dragState?.isDragging,
|
||||
// justFinishedDrag: dragState?.justFinishedDrag,
|
||||
// currentValues: {
|
||||
// placeholder: widget?.placeholder,
|
||||
// title: group?.title || area?.title,
|
||||
// description: area?.description,
|
||||
// actualPositionX: selectedComponent.position.x,
|
||||
// actualPositionY: selectedComponent.position.y,
|
||||
// dragPositionX: dragState?.currentPosition.x,
|
||||
// dragPositionY: dragState?.currentPosition.y,
|
||||
// },
|
||||
// getCurrentPosResult: getCurrentPosition(),
|
||||
// });
|
||||
|
||||
// 드래그 중이 아닐 때만 localInputs 업데이트 (드래그 완료 후 최종 위치 반영)
|
||||
@@ -271,8 +275,8 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
});
|
||||
|
||||
// console.log("✅ localInputs 업데이트 완료:", {
|
||||
// positionX: currentPos.x.toString(),
|
||||
// positionY: currentPos.y.toString(),
|
||||
// positionX: currentPos.x.toString(),
|
||||
// positionY: currentPos.y.toString(),
|
||||
// });
|
||||
}
|
||||
}
|
||||
@@ -290,65 +294,66 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
if (selectedComponent && selectedComponent.type === "component") {
|
||||
// 삭제 액션 감지 로직 (실제 필드명 사용)
|
||||
const isDeleteAction = () => {
|
||||
const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del'];
|
||||
const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"];
|
||||
return (
|
||||
selectedComponent.componentConfig?.action?.type === 'delete' ||
|
||||
selectedComponent.config?.action?.type === 'delete' ||
|
||||
selectedComponent.webTypeConfig?.actionType === 'delete' ||
|
||||
selectedComponent.text?.toLowerCase().includes('삭제') ||
|
||||
selectedComponent.text?.toLowerCase().includes('delete') ||
|
||||
selectedComponent.label?.toLowerCase().includes('삭제') ||
|
||||
selectedComponent.label?.toLowerCase().includes('delete') ||
|
||||
deleteKeywords.some(keyword =>
|
||||
selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) ||
|
||||
selectedComponent.config?.text?.toLowerCase().includes(keyword)
|
||||
selectedComponent.componentConfig?.action?.type === "delete" ||
|
||||
selectedComponent.config?.action?.type === "delete" ||
|
||||
selectedComponent.webTypeConfig?.actionType === "delete" ||
|
||||
selectedComponent.text?.toLowerCase().includes("삭제") ||
|
||||
selectedComponent.text?.toLowerCase().includes("delete") ||
|
||||
selectedComponent.label?.toLowerCase().includes("삭제") ||
|
||||
selectedComponent.label?.toLowerCase().includes("delete") ||
|
||||
deleteKeywords.some(
|
||||
(keyword) =>
|
||||
selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) ||
|
||||
selectedComponent.config?.text?.toLowerCase().includes(keyword),
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 🔍 디버깅: 컴포넌트 구조 확인
|
||||
// console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", {
|
||||
// componentType: selectedComponent.type,
|
||||
// componentId: selectedComponent.id,
|
||||
// componentConfig: selectedComponent.componentConfig,
|
||||
// config: selectedComponent.config,
|
||||
// webTypeConfig: selectedComponent.webTypeConfig,
|
||||
// actionType1: selectedComponent.componentConfig?.action?.type,
|
||||
// actionType2: selectedComponent.config?.action?.type,
|
||||
// actionType3: selectedComponent.webTypeConfig?.actionType,
|
||||
// isDeleteAction: isDeleteAction(),
|
||||
// currentLabelColor: selectedComponent.style?.labelColor,
|
||||
// componentType: selectedComponent.type,
|
||||
// componentId: selectedComponent.id,
|
||||
// componentConfig: selectedComponent.componentConfig,
|
||||
// config: selectedComponent.config,
|
||||
// webTypeConfig: selectedComponent.webTypeConfig,
|
||||
// actionType1: selectedComponent.componentConfig?.action?.type,
|
||||
// actionType2: selectedComponent.config?.action?.type,
|
||||
// actionType3: selectedComponent.webTypeConfig?.actionType,
|
||||
// isDeleteAction: isDeleteAction(),
|
||||
// currentLabelColor: selectedComponent.style?.labelColor,
|
||||
// });
|
||||
|
||||
// 액션에 따른 라벨 색상 자동 설정
|
||||
if (isDeleteAction()) {
|
||||
// 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만)
|
||||
if (selectedComponent.style?.labelColor !== '#ef4444') {
|
||||
if (selectedComponent.style?.labelColor !== "#ef4444") {
|
||||
// console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정");
|
||||
onUpdateProperty("style", {
|
||||
...selectedComponent.style,
|
||||
labelColor: '#ef4444'
|
||||
labelColor: "#ef4444",
|
||||
});
|
||||
|
||||
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
labelColor: '#ef4444'
|
||||
labelColor: "#ef4444",
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
// 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만)
|
||||
if (selectedComponent.style?.labelColor === '#ef4444') {
|
||||
if (selectedComponent.style?.labelColor === "#ef4444") {
|
||||
// console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋");
|
||||
onUpdateProperty("style", {
|
||||
...selectedComponent.style,
|
||||
labelColor: '#212121'
|
||||
labelColor: "#212121",
|
||||
});
|
||||
|
||||
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
setLocalInputs((prev) => ({
|
||||
...prev,
|
||||
labelColor: '#212121'
|
||||
labelColor: "#212121",
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -360,16 +365,16 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
selectedComponent?.id,
|
||||
selectedComponent?.style?.labelColor, // 라벨 색상 변경도 감지
|
||||
JSON.stringify(selectedComponent?.componentConfig), // 전체 componentConfig 변경 감지
|
||||
onUpdateProperty
|
||||
onUpdateProperty,
|
||||
]);
|
||||
|
||||
// 렌더링 시마다 실행되는 직접적인 드래그 상태 체크
|
||||
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
|
||||
// console.log("🎯 렌더링 중 드래그 상태 감지:", {
|
||||
// isDragging: dragState.isDragging,
|
||||
// draggedId: dragState.draggedComponent?.id,
|
||||
// selectedId: selectedComponent?.id,
|
||||
// currentPosition: dragState.currentPosition,
|
||||
// isDragging: dragState.isDragging,
|
||||
// draggedId: dragState.draggedComponent?.id,
|
||||
// selectedId: selectedComponent?.id,
|
||||
// currentPosition: dragState.currentPosition,
|
||||
// });
|
||||
|
||||
const newPosition = {
|
||||
@@ -380,8 +385,8 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
// 위치가 변경되었는지 확인
|
||||
if (lastDragPosition.x !== newPosition.x || lastDragPosition.y !== newPosition.y) {
|
||||
// console.log("🔄 위치 변경 감지됨:", {
|
||||
// oldPosition: lastDragPosition,
|
||||
// newPosition: newPosition,
|
||||
// oldPosition: lastDragPosition,
|
||||
// newPosition: newPosition,
|
||||
// });
|
||||
// 다음 렌더링 사이클에서 업데이트
|
||||
setTimeout(() => {
|
||||
@@ -409,7 +414,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-5 w-5" />
|
||||
<span className="text-lg font-semibold">데이터 테이블 설정</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -450,7 +455,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
<div className="border-b border-gray-200 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="font-medium text-gray-900">속성 편집</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -491,7 +496,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Type className="h-4 w-4 text-muted-foreground" />
|
||||
<Type className="text-muted-foreground h-4 w-4" />
|
||||
<h4 className="font-medium text-gray-900">기본 정보</h4>
|
||||
</div>
|
||||
|
||||
@@ -507,7 +512,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
value={selectedComponent.columnName || ""}
|
||||
readOnly
|
||||
placeholder="데이터베이스 컬럼명"
|
||||
className="mt-1 bg-gray-50 text-muted-foreground"
|
||||
className="text-muted-foreground mt-1 bg-gray-50"
|
||||
title="컬럼명은 변경할 수 없습니다"
|
||||
/>
|
||||
</div>
|
||||
@@ -517,7 +522,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
위젯 타입
|
||||
</Label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={localInputs.widgetType}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as WebType;
|
||||
@@ -594,7 +599,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
{/* 위치 및 크기 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Move className="h-4 w-4 text-muted-foreground" />
|
||||
<Move className="text-muted-foreground h-4 w-4" />
|
||||
<h4 className="font-medium text-gray-900">위치 및 크기</h4>
|
||||
</div>
|
||||
|
||||
@@ -622,7 +627,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
}}
|
||||
className={`mt-1 ${
|
||||
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
||||
? "border-blue-300 bg-accent text-blue-700"
|
||||
? "bg-accent border-blue-300 text-blue-700"
|
||||
: ""
|
||||
}`}
|
||||
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
|
||||
@@ -652,7 +657,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
}}
|
||||
className={`mt-1 ${
|
||||
dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id
|
||||
? "border-blue-300 bg-accent text-blue-700"
|
||||
? "bg-accent border-blue-300 text-blue-700"
|
||||
: ""
|
||||
}`}
|
||||
readOnly={dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id}
|
||||
@@ -662,26 +667,94 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
{/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */}
|
||||
{selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="width" className="text-sm font-medium">
|
||||
너비
|
||||
</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
value={localInputs.width}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalInputs((prev) => ({ ...prev, width: newValue }));
|
||||
onUpdateProperty("size.width", Number(newValue));
|
||||
{/* 🆕 컬럼 스팬 선택 (width 대체) */}
|
||||
<div className="col-span-2">
|
||||
<Label className="text-sm font-medium">컴포넌트 너비</Label>
|
||||
<Select
|
||||
value={selectedComponent.gridColumnSpan || "half"}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("gridColumnSpan", value as ColumnSpanPreset);
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(COLUMN_SPAN_PRESETS)
|
||||
.filter(([key]) => key !== "auto")
|
||||
.map(([key, info]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<span>{info.label}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{info.value}/12 ({info.percentage})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 시각적 프리뷰 */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label className="text-xs text-gray-500">미리보기</Label>
|
||||
<div className="grid h-6 grid-cols-12 gap-0.5 overflow-hidden rounded border">
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const spanValue = COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"];
|
||||
const startCol = selectedComponent.gridColumnStart || 1;
|
||||
const isActive = i + 1 >= startCol && i + 1 < startCol + spanValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn("h-full transition-colors", isActive ? "bg-blue-500" : "bg-gray-100")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
{COLUMN_SPAN_VALUES[selectedComponent.gridColumnSpan || "half"]} / 12 컬럼
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 고급 설정 */}
|
||||
<Collapsible className="mt-3">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-between">
|
||||
<span className="text-xs">고급 설정</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">시작 컬럼 위치</Label>
|
||||
<Select
|
||||
value={selectedComponent.gridColumnStart?.toString() || "auto"}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("gridColumnStart", value === "auto" ? undefined : parseInt(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동</SelectItem>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<SelectItem key={i + 1} value={(i + 1).toString()}>
|
||||
{i + 1}번 컬럼부터
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">"자동"을 선택하면 이전 컴포넌트 다음에 배치됩니다</p>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="height" className="text-sm font-medium">
|
||||
높이
|
||||
높이 (px)
|
||||
</Label>
|
||||
<Input
|
||||
id="height"
|
||||
@@ -697,8 +770,8 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 rounded-lg bg-accent p-3 text-center">
|
||||
<p className="text-sm text-primary">카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
||||
<div className="bg-accent col-span-2 rounded-lg p-3 text-center">
|
||||
<p className="text-primary text-sm">카드 레이아웃은 자동으로 크기가 계산됩니다</p>
|
||||
<p className="mt-1 text-xs text-blue-500">카드 개수와 간격 설정은 상세설정에서 조정하세요</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -756,7 +829,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
{/* 라벨 스타일 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Type className="h-4 w-4 text-muted-foreground" />
|
||||
<Type className="text-muted-foreground h-4 w-4" />
|
||||
<h4 className="font-medium text-gray-900">라벨 설정</h4>
|
||||
</div>
|
||||
|
||||
@@ -840,7 +913,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
폰트 굵기
|
||||
</Label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onChange={(e) => onUpdateProperty("style.labelFontWeight", e.target.value)}
|
||||
>
|
||||
@@ -863,7 +936,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
텍스트 정렬
|
||||
</Label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={selectedComponent.style?.labelTextAlign || "left"}
|
||||
onChange={(e) => onUpdateProperty("style.labelTextAlign", e.target.value)}
|
||||
>
|
||||
@@ -900,7 +973,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
{/* 그룹 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Group className="h-4 w-4 text-muted-foreground" />
|
||||
<Group className="text-muted-foreground h-4 w-4" />
|
||||
<h4 className="font-medium text-gray-900">그룹 설정</h4>
|
||||
</div>
|
||||
|
||||
@@ -931,7 +1004,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
{/* 영역 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground h-4 w-4" />
|
||||
<h4 className="font-medium text-gray-900">영역 설정</h4>
|
||||
</div>
|
||||
|
||||
@@ -974,7 +1047,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
레이아웃 타입
|
||||
</Label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={(selectedComponent as AreaComponent).layoutType}
|
||||
onChange={(e) => onUpdateProperty("layoutType", e.target.value as AreaLayoutType)}
|
||||
>
|
||||
@@ -1035,7 +1108,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
<div>
|
||||
<Label className="text-xs">정렬 방식</Label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={(selectedComponent as AreaComponent).layoutConfig?.justifyContent || "flex-start"}
|
||||
onChange={(e) => onUpdateProperty("layoutConfig.justifyContent", e.target.value)}
|
||||
>
|
||||
@@ -1069,7 +1142,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
|
||||
<div>
|
||||
<Label className="text-xs">사이드바 위치</Label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-primary focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
className="focus:border-primary mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||
value={(selectedComponent as AreaComponent).layoutConfig?.sidebarPosition || "left"}
|
||||
onChange={(e) => onUpdateProperty("layoutConfig.sidebarPosition", e.target.value)}
|
||||
>
|
||||
|
||||
247
frontend/components/screen/panels/RowSettingsPanel.tsx
Normal file
247
frontend/components/screen/panels/RowSettingsPanel.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { LayoutRow } from "@/types/grid-system";
|
||||
import { GapPreset, GAP_PRESETS } from "@/lib/constants/columnSpans";
|
||||
import { Rows, AlignHorizontalJustifyCenter, AlignVerticalJustifyCenter } from "lucide-react";
|
||||
|
||||
interface RowSettingsPanelProps {
|
||||
row: LayoutRow;
|
||||
onUpdateRow: (updates: Partial<LayoutRow>) => void;
|
||||
}
|
||||
|
||||
export const RowSettingsPanel: React.FC<RowSettingsPanelProps> = ({ row, onUpdateRow }) => {
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Rows className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">행 설정</h3>
|
||||
<span className="text-sm text-gray-500">#{row.rowIndex + 1}</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 높이 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">행 높이</Label>
|
||||
<Select
|
||||
value={row.height}
|
||||
onValueChange={(value: "auto" | "fixed" | "min" | "max") => onUpdateRow({ height: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (컨텐츠에 맞춤)</SelectItem>
|
||||
<SelectItem value="fixed">고정 높이</SelectItem>
|
||||
<SelectItem value="min">최소 높이</SelectItem>
|
||||
<SelectItem value="max">최대 높이</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 고정 높이 입력 */}
|
||||
{row.height === "fixed" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={row.fixedHeight || 100}
|
||||
onChange={(e) => onUpdateRow({ fixedHeight: parseInt(e.target.value) })}
|
||||
className="mt-1"
|
||||
placeholder="100"
|
||||
min={50}
|
||||
max={1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최소 높이 입력 */}
|
||||
{row.height === "min" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">최소 높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={row.minHeight || 50}
|
||||
onChange={(e) => onUpdateRow({ minHeight: parseInt(e.target.value) })}
|
||||
className="mt-1"
|
||||
placeholder="50"
|
||||
min={0}
|
||||
max={1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최대 높이 입력 */}
|
||||
{row.height === "max" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">최대 높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={row.maxHeight || 500}
|
||||
onChange={(e) => onUpdateRow({ maxHeight: parseInt(e.target.value) })}
|
||||
className="mt-1"
|
||||
placeholder="500"
|
||||
min={0}
|
||||
max={2000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 간격 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">컴포넌트 간격</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant={row.gap === preset ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ gap: preset })}
|
||||
className="text-xs"
|
||||
>
|
||||
{GAP_PRESETS[preset].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">현재: {GAP_PRESETS[row.gap].pixels}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 패딩 설정 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">행 패딩</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant={row.padding === preset ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ padding: preset })}
|
||||
className="text-xs"
|
||||
>
|
||||
{GAP_PRESETS[preset].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">현재: {GAP_PRESETS[row.padding].pixels}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 수평 정렬 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlignHorizontalJustifyCenter className="h-4 w-4 text-gray-600" />
|
||||
<Label className="text-sm font-medium">수평 정렬</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={row.alignment === "start" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ alignment: "start" })}
|
||||
>
|
||||
왼쪽
|
||||
</Button>
|
||||
<Button
|
||||
variant={row.alignment === "center" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ alignment: "center" })}
|
||||
>
|
||||
중앙
|
||||
</Button>
|
||||
<Button
|
||||
variant={row.alignment === "end" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ alignment: "end" })}
|
||||
>
|
||||
오른쪽
|
||||
</Button>
|
||||
<Button
|
||||
variant={row.alignment === "stretch" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ alignment: "stretch" })}
|
||||
>
|
||||
늘림
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 수직 정렬 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlignVerticalJustifyCenter className="h-4 w-4 text-gray-600" />
|
||||
<Label className="text-sm font-medium">수직 정렬</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={row.verticalAlignment === "top" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ verticalAlignment: "top" })}
|
||||
>
|
||||
위
|
||||
</Button>
|
||||
<Button
|
||||
variant={row.verticalAlignment === "middle" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ verticalAlignment: "middle" })}
|
||||
>
|
||||
중앙
|
||||
</Button>
|
||||
<Button
|
||||
variant={row.verticalAlignment === "bottom" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ verticalAlignment: "bottom" })}
|
||||
>
|
||||
아래
|
||||
</Button>
|
||||
<Button
|
||||
variant={row.verticalAlignment === "stretch" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onUpdateRow({ verticalAlignment: "stretch" })}
|
||||
>
|
||||
늘림
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">배경색</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={row.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => onUpdateRow({ backgroundColor: e.target.value })}
|
||||
className="h-10 w-20 cursor-pointer p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={row.backgroundColor || ""}
|
||||
onChange={(e) => onUpdateRow({ backgroundColor: e.target.value })}
|
||||
placeholder="#ffffff"
|
||||
className="flex-1"
|
||||
/>
|
||||
{row.backgroundColor && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onUpdateRow({ backgroundColor: undefined })}>
|
||||
초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user