Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
@@ -380,8 +380,8 @@ export function CreateTableModal({
|
||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||
</div>
|
||||
|
||||
{/* 로그 테이블 생성 옵션 */}
|
||||
<div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||
{/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */}
|
||||
{/* <div className="flex items-start space-x-3 rounded-lg border p-4">
|
||||
<Checkbox
|
||||
id="useLogTable"
|
||||
checked={useLogTable}
|
||||
@@ -401,7 +401,7 @@ export function CreateTableModal({
|
||||
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* 자동 추가 컬럼 안내 */}
|
||||
<Alert>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { BarcodeLabelMaster } from "@/types/barcode";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { barcodeApi } from "@/lib/api/barcodeApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface BarcodeListTableProps {
|
||||
labels: BarcodeLabelMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
isLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function BarcodeListTable({
|
||||
labels,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
}: BarcodeListTableProps) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const handleEdit = (labelId: string) => {
|
||||
router.push(`/admin/screenMng/barcodeList/designer/${labelId}`);
|
||||
};
|
||||
|
||||
const handleCopy = async (labelId: string) => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await barcodeApi.copyLabel(labelId);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "바코드 라벨이 복사되었습니다.",
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "바코드 라벨 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (labelId: string) => {
|
||||
setDeleteTarget(labelId);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await barcodeApi.deleteLabel(deleteTarget);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "바코드 라벨이 삭제되었습니다.",
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "바코드 라벨 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy-MM-dd");
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (labels.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
|
||||
<p>등록된 바코드 라벨이 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">No</TableHead>
|
||||
<TableHead>라벨명</TableHead>
|
||||
<TableHead className="w-[120px]">템플릿 유형</TableHead>
|
||||
<TableHead className="w-[120px]">작성자</TableHead>
|
||||
<TableHead className="w-[120px]">수정일</TableHead>
|
||||
<TableHead className="w-[200px]">액션</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{labels.map((label, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={label.label_id}
|
||||
onClick={() => handleEdit(label.label_id)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium">{rowNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{label.label_name_kor}</div>
|
||||
{label.label_name_eng && (
|
||||
<div className="text-muted-foreground text-sm">{label.label_name_eng}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{label.width_mm != null && label.height_mm != null
|
||||
? `${label.width_mm}×${label.height_mm}mm`
|
||||
: label.template_type || "-"}
|
||||
</TableCell>
|
||||
<TableCell>{label.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(label.updated_at || label.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(label.label_id)}
|
||||
disabled={isCopying}
|
||||
className="h-8 w-8"
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(label.label_id)}
|
||||
className="h-8 w-8"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>바코드 라벨 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 바코드 라벨을 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 라벨은 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
"삭제"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Barcode, Image, Minus, Square } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
const ITEMS: { type: BarcodeLabelComponent["type"]; label: string; icon: React.ReactNode }[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "바코드", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||
{ type: "line", label: "선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "rectangle", label: "사각형", icon: <Square className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
function defaultComponent(type: BarcodeLabelComponent["type"]): BarcodeLabelComponent {
|
||||
const id = `comp_${uuidv4()}`;
|
||||
const base = { id, type, x: 10 * MM_TO_PX, y: 10 * MM_TO_PX, width: 80, height: 24, zIndex: 0 };
|
||||
|
||||
switch (type) {
|
||||
case "text":
|
||||
return { ...base, content: "텍스트", fontSize: 10, fontColor: "#000000" };
|
||||
case "barcode":
|
||||
return {
|
||||
...base,
|
||||
width: 120,
|
||||
height: 40,
|
||||
barcodeType: "CODE128",
|
||||
barcodeValue: "123456789",
|
||||
showBarcodeText: true,
|
||||
};
|
||||
case "image":
|
||||
return { ...base, width: 60, height: 60, imageUrl: "", objectFit: "contain" };
|
||||
case "line":
|
||||
return { ...base, width: 100, height: 2, lineColor: "#000", lineWidth: 1 };
|
||||
case "rectangle":
|
||||
return { ...base, width: 80, height: 40, backgroundColor: "transparent", lineColor: "#000", lineWidth: 1 };
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function DraggableItem({
|
||||
type,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
type: BarcodeLabelComponent["type"];
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: "barcode-component",
|
||||
item: { component: defaultComponent(type) },
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm hover:border-blue-500 hover:bg-blue-50 ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeComponentPalette() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">요소 추가</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{ITEMS.map((item) => (
|
||||
<DraggableItem key={item.type} type={item.type} label={item.label} icon={item.icon} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useBarcodeDesigner, MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
export function BarcodeDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
widthMm,
|
||||
heightMm,
|
||||
components,
|
||||
addComponent,
|
||||
selectComponent,
|
||||
showGrid,
|
||||
snapValueToGrid,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const widthPx = widthMm * MM_TO_PX;
|
||||
const heightPx = heightMm * MM_TO_PX;
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
accept: "barcode-component",
|
||||
drop: (item: { component: BarcodeLabelComponent }, monitor) => {
|
||||
if (!canvasRef.current) return;
|
||||
const offset = monitor.getClientOffset();
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
if (!offset) return;
|
||||
|
||||
let x = offset.x - rect.left;
|
||||
let y = offset.y - rect.top;
|
||||
// 드롭 시 요소 중앙이 커서에 오도록 보정
|
||||
x -= item.component.width / 2;
|
||||
y -= item.component.height / 2;
|
||||
x = Math.max(0, Math.min(x, widthPx - item.component.width));
|
||||
y = Math.max(0, Math.min(y, heightPx - item.component.height));
|
||||
|
||||
const newComp: BarcodeLabelComponent = {
|
||||
...item.component,
|
||||
id: `comp_${uuidv4()}`,
|
||||
x: snapValueToGrid(x),
|
||||
y: snapValueToGrid(y),
|
||||
zIndex: components.length,
|
||||
};
|
||||
addComponent(newComp);
|
||||
},
|
||||
collect: (m) => ({ isOver: m.isOver() }),
|
||||
}), [widthPx, heightPx, components.length, addComponent, snapValueToGrid]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto bg-gray-100 p-6">
|
||||
<div
|
||||
key={`canvas-${widthMm}-${heightMm}`}
|
||||
ref={(r) => {
|
||||
(canvasRef as any).current = r;
|
||||
drop(r);
|
||||
}}
|
||||
className="relative bg-white shadow-lg"
|
||||
style={{
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
minWidth: widthPx,
|
||||
minHeight: heightPx,
|
||||
backgroundImage: showGrid
|
||||
? `linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`
|
||||
: undefined,
|
||||
backgroundSize: showGrid ? `${MM_TO_PX * 5}px ${MM_TO_PX * 5}px` : undefined,
|
||||
outline: isOver ? "2px dashed #2563eb" : "1px solid #d1d5db",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) selectComponent(null);
|
||||
}}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { BarcodeTemplatePalette } from "./BarcodeTemplatePalette";
|
||||
import { BarcodeComponentPalette } from "./BarcodeComponentPalette";
|
||||
|
||||
export function BarcodeDesignerLeftPanel() {
|
||||
return (
|
||||
<div className="flex min-h-0 w-64 shrink-0 flex-col overflow-hidden border-r bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
<BarcodeTemplatePalette />
|
||||
<BarcodeComponentPalette />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
|
||||
export function BarcodeDesignerRightPanel() {
|
||||
const {
|
||||
components,
|
||||
selectedComponentId,
|
||||
updateComponent,
|
||||
removeComponent,
|
||||
selectComponent,
|
||||
widthMm,
|
||||
heightMm,
|
||||
setWidthMm,
|
||||
setHeightMm,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const selected = components.find((c) => c.id === selectedComponentId);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<div className="w-72 border-l bg-white p-4">
|
||||
<p className="text-muted-foreground text-sm">요소를 선택하면 속성을 편집할 수 있습니다.</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-xs">라벨 크기 (mm)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={200}
|
||||
value={widthMm}
|
||||
onChange={(e) => setWidthMm(Number(e.target.value) || 50)}
|
||||
/>
|
||||
<span className="py-2">×</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={200}
|
||||
value={heightMm}
|
||||
onChange={(e) => setHeightMm(Number(e.target.value) || 30)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const update = (updates: Partial<BarcodeLabelComponent>) =>
|
||||
updateComponent(selected.id, updates);
|
||||
|
||||
return (
|
||||
<div className="w-72 border-l bg-white">
|
||||
<div className="border-b p-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">속성</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive"
|
||||
onClick={() => {
|
||||
removeComponent(selected.id);
|
||||
selectComponent(null);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">X (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selected.x)}
|
||||
onChange={(e) => update({ x: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Y (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(selected.y)}
|
||||
onChange={(e) => update({ y: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">너비</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={4}
|
||||
value={Math.round(selected.width)}
|
||||
onChange={(e) => update({ width: Number(e.target.value) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">높이</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={4}
|
||||
value={Math.round(selected.height)}
|
||||
onChange={(e) => update({ height: Number(e.target.value) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.type === "text" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">내용</Label>
|
||||
<Input
|
||||
value={selected.content || ""}
|
||||
onChange={(e) => update({ content: e.target.value })}
|
||||
placeholder="텍스트"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">글자 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={6}
|
||||
max={72}
|
||||
value={selected.fontSize || 10}
|
||||
onChange={(e) => update({ fontSize: Number(e.target.value) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">글자 색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.fontColor || "#000000"}
|
||||
onChange={(e) => update({ fontColor: e.target.value })}
|
||||
className="h-9 w-20 p-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected.type === "barcode" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">바코드 유형</Label>
|
||||
<Select
|
||||
value={selected.barcodeType || "CODE128"}
|
||||
onValueChange={(v) => update({ barcodeType: v })}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CODE128">CODE128</SelectItem>
|
||||
<SelectItem value="CODE39">CODE39</SelectItem>
|
||||
<SelectItem value="EAN13">EAN13</SelectItem>
|
||||
<SelectItem value="EAN8">EAN8</SelectItem>
|
||||
<SelectItem value="QR">QR 코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
<Input
|
||||
value={selected.barcodeValue || ""}
|
||||
onChange={(e) => update({ barcodeValue: e.target.value })}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={selected.showBarcodeText !== false}
|
||||
onCheckedChange={(v) => update({ showBarcodeText: v })}
|
||||
/>
|
||||
<Label className="text-xs">숫자 표시 (1D)</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected.type === "line" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">선 두께</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={selected.lineWidth || 1}
|
||||
onChange={(e) => update({ lineWidth: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.lineColor || "#000000"}
|
||||
onChange={(e) => update({ lineColor: e.target.value })}
|
||||
className="h-9 w-20 p-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected.type === "rectangle" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 두께</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={selected.lineWidth ?? 1}
|
||||
onChange={(e) => update({ lineWidth: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.lineColor || "#000000"}
|
||||
onChange={(e) => update({ lineColor: e.target.value })}
|
||||
className="h-9 w-20 p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경 색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => update({ backgroundColor: e.target.value })}
|
||||
className="h-9 w-20 p-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selected.type === "image" && (
|
||||
<div>
|
||||
<Label className="text-xs">이미지 URL</Label>
|
||||
<Input
|
||||
value={selected.imageUrl || ""}
|
||||
onChange={(e) => update({ imageUrl: e.target.value })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">또는 나중에 업로드 기능 연동</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ArrowLeft, Save, Loader2, Download, Printer } from "lucide-react";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { barcodeApi } from "@/lib/api/barcodeApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { generateZPL } from "@/lib/zplGenerator";
|
||||
import { BarcodePrintPreviewModal } from "./BarcodePrintPreviewModal";
|
||||
|
||||
export function BarcodeDesignerToolbar() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const {
|
||||
labelId,
|
||||
labelMaster,
|
||||
widthMm,
|
||||
heightMm,
|
||||
components,
|
||||
saveLayout,
|
||||
isSaving,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const handleDownloadZPL = () => {
|
||||
const layout = { width_mm: widthMm, height_mm: heightMm, components };
|
||||
const zpl = generateZPL(layout);
|
||||
const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = (labelMaster?.label_name_kor || "label") + ".zpl";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({ title: "다운로드", description: "ZPL 파일이 다운로드되었습니다. Zebra 프린터/유틸에서 사용하세요." });
|
||||
};
|
||||
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
const [printPreviewOpen, setPrintPreviewOpen] = useState(false);
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (labelId !== "new") {
|
||||
await saveLayout();
|
||||
return;
|
||||
}
|
||||
setSaveDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateAndSave = async () => {
|
||||
const name = newLabelName.trim();
|
||||
if (!name) {
|
||||
toast({
|
||||
title: "입력 필요",
|
||||
description: "라벨명을 입력하세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const createRes = await barcodeApi.createLabel({
|
||||
labelNameKor: name,
|
||||
});
|
||||
if (!createRes.success || !createRes.data?.labelId) throw new Error(createRes.message || "생성 실패");
|
||||
const newId = createRes.data.labelId;
|
||||
|
||||
await barcodeApi.saveLayout(newId, {
|
||||
width_mm: widthMm,
|
||||
height_mm: heightMm,
|
||||
components: components.map((c, i) => ({ ...c, zIndex: i })),
|
||||
});
|
||||
|
||||
toast({ title: "저장됨", description: "라벨이 생성되었습니다." });
|
||||
setSaveDialogOpen(false);
|
||||
setNewLabelName("");
|
||||
router.push(`/admin/screenMng/barcodeList/designer/${newId}`);
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "저장 실패",
|
||||
description: e.message || "라벨 생성에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => router.push("/admin/screenMng/barcodeList")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{labelId === "new" ? "새 라벨" : labelMaster?.label_name_kor || "바코드 라벨 디자이너"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1"
|
||||
onClick={() => setPrintPreviewOpen(true)}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
인쇄 미리보기
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="gap-1" onClick={handleDownloadZPL}>
|
||||
<Download className="h-4 w-4" />
|
||||
ZPL 다운로드
|
||||
</Button>
|
||||
<Button size="sm" className="gap-1" onClick={handleSave} disabled={isSaving || creating}>
|
||||
{(isSaving || creating) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BarcodePrintPreviewModal
|
||||
open={printPreviewOpen}
|
||||
onOpenChange={setPrintPreviewOpen}
|
||||
layout={{
|
||||
width_mm: widthMm,
|
||||
height_mm: heightMm,
|
||||
components: components.map((c, i) => ({ ...c, zIndex: i })),
|
||||
}}
|
||||
labelName={labelMaster?.label_name_kor || "라벨"}
|
||||
/>
|
||||
|
||||
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 라벨 저장</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label>라벨명 (한글)</Label>
|
||||
<Input
|
||||
value={newLabelName}
|
||||
onChange={(e) => setNewLabelName(e.target.value)}
|
||||
placeholder="예: 품목 바코드 라벨"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSaveDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleCreateAndSave} disabled={creating}>
|
||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { BarcodeLabelComponent } from "@/types/barcode";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
|
||||
interface Props {
|
||||
component: BarcodeLabelComponent;
|
||||
}
|
||||
|
||||
// 1D 바코드 렌더
|
||||
function Barcode1DRender({
|
||||
value,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
showText,
|
||||
}: {
|
||||
value: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
showText: boolean;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !value.trim()) return;
|
||||
try {
|
||||
JsBarcode(svgRef.current, value.trim(), {
|
||||
format: format.toLowerCase(),
|
||||
width: 2,
|
||||
height: Math.max(20, height - (showText ? 14 : 4)),
|
||||
displayValue: showText,
|
||||
margin: 2,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [value, format, height, showText]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<svg ref={svgRef} className="max-h-full max-w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QR 렌더
|
||||
function QRRender({ value, size }: { value: string; size: number }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !value.trim()) return;
|
||||
QRCode.toCanvas(canvasRef.current, value.trim(), {
|
||||
width: Math.max(40, size),
|
||||
margin: 1,
|
||||
});
|
||||
}, [value, size]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeLabelCanvasComponent({ component }: Props) {
|
||||
const {
|
||||
updateComponent,
|
||||
removeComponent,
|
||||
selectComponent,
|
||||
selectedComponentId,
|
||||
snapValueToGrid,
|
||||
} = useBarcodeDesigner();
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, compX: 0, compY: 0 });
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, w: 0, h: 0 });
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const selected = selectedComponentId === component.id;
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
selectComponent(component.id);
|
||||
if ((e.target as HTMLElement).closest("[data-resize-handle]")) {
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
w: component.width,
|
||||
h: component.height,
|
||||
});
|
||||
} else {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX, y: e.clientY, compX: component.x, compY: component.y });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging && !isResizing) return;
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const dx = e.clientX - dragStart.x;
|
||||
const dy = e.clientY - dragStart.y;
|
||||
updateComponent(component.id, {
|
||||
x: Math.max(0, snapValueToGrid(dragStart.compX + dx)),
|
||||
y: Math.max(0, snapValueToGrid(dragStart.compY + dy)),
|
||||
});
|
||||
} else if (isResizing) {
|
||||
const dx = e.clientX - resizeStart.x;
|
||||
const dy = e.clientY - resizeStart.y;
|
||||
updateComponent(component.id, {
|
||||
width: Math.max(20, resizeStart.w + dx),
|
||||
height: Math.max(10, resizeStart.h + dy),
|
||||
});
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
}, [
|
||||
isDragging,
|
||||
isResizing,
|
||||
dragStart,
|
||||
resizeStart,
|
||||
component.id,
|
||||
updateComponent,
|
||||
snapValueToGrid,
|
||||
]);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: component.x,
|
||||
top: component.y,
|
||||
width: component.width,
|
||||
height: component.height,
|
||||
zIndex: component.zIndex,
|
||||
};
|
||||
|
||||
const border = selected ? "2px solid #2563eb" : "1px solid transparent";
|
||||
const isBarcode = component.type === "barcode";
|
||||
const isQR = component.barcodeType === "QR";
|
||||
|
||||
const content = () => {
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontSize: component.fontSize || 10,
|
||||
color: component.fontColor || "#000",
|
||||
fontWeight: component.fontWeight || "normal",
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-all",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{component.content || "텍스트"}
|
||||
</div>
|
||||
);
|
||||
case "barcode":
|
||||
if (isQR) {
|
||||
return (
|
||||
<QRRender
|
||||
value={component.barcodeValue || ""}
|
||||
size={Math.min(component.width, component.height)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Barcode1DRender
|
||||
value={component.barcodeValue || "123456789"}
|
||||
format={component.barcodeType || "CODE128"}
|
||||
width={component.width}
|
||||
height={component.height}
|
||||
showText={component.showBarcodeText !== false}
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return component.imageUrl ? (
|
||||
<img
|
||||
src={getFullImageUrl(component.imageUrl)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: (component.objectFit as "contain") || "contain",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-100 text-xs text-gray-400">
|
||||
이미지
|
||||
</div>
|
||||
);
|
||||
case "line":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: component.lineWidth || 1,
|
||||
backgroundColor: component.lineColor || "#000",
|
||||
marginTop: (component.height - (component.lineWidth || 1)) / 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "rectangle":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: component.backgroundColor || "transparent",
|
||||
border: `${component.lineWidth || 1}px solid ${component.lineColor || "#000"}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ ...style, border }}
|
||||
className="cursor-move overflow-hidden bg-white"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{content()}
|
||||
{selected && component.type !== "line" && (
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 right-0 h-2 w-2 cursor-se-resize bg-blue-500"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
w: component.width,
|
||||
h: component.height,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download, Printer, Loader2, AlertCircle } from "lucide-react";
|
||||
import { BarcodeLabelLayout } from "@/types/barcode";
|
||||
import { generateZPL } from "@/lib/zplGenerator";
|
||||
import {
|
||||
printZPLToZebraBLE,
|
||||
isWebBluetoothSupported,
|
||||
getUnsupportedMessage,
|
||||
} from "@/lib/zebraBluetooth";
|
||||
import {
|
||||
printZPLToBrowserPrint,
|
||||
getBrowserPrintHelpMessage,
|
||||
} from "@/lib/zebraBrowserPrint";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BarcodeLabelCanvasComponent } from "./BarcodeLabelCanvasComponent";
|
||||
import { MM_TO_PX } from "@/contexts/BarcodeDesignerContext";
|
||||
|
||||
const PREVIEW_MAX_PX = 320;
|
||||
|
||||
interface BarcodePrintPreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
layout: BarcodeLabelLayout;
|
||||
labelName?: string;
|
||||
}
|
||||
|
||||
export function BarcodePrintPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
layout,
|
||||
labelName = "라벨",
|
||||
}: BarcodePrintPreviewModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
const { width_mm, height_mm, components } = layout;
|
||||
const widthPx = width_mm * MM_TO_PX;
|
||||
const heightPx = height_mm * MM_TO_PX;
|
||||
const scale =
|
||||
widthPx > PREVIEW_MAX_PX || heightPx > PREVIEW_MAX_PX
|
||||
? Math.min(PREVIEW_MAX_PX / widthPx, PREVIEW_MAX_PX / heightPx)
|
||||
: 1;
|
||||
const previewW = Math.round(widthPx * scale);
|
||||
const previewH = Math.round(heightPx * scale);
|
||||
|
||||
const zpl = generateZPL(layout);
|
||||
const bleSupported = isWebBluetoothSupported();
|
||||
const unsupportedMsg = getUnsupportedMessage();
|
||||
|
||||
const handleDownloadZPL = () => {
|
||||
const blob = new Blob([zpl], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${labelName}.zpl`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({ title: "다운로드", description: "ZPL 파일이 저장되었습니다." });
|
||||
};
|
||||
|
||||
const handlePrintToZebra = async () => {
|
||||
const canUseBle = bleSupported;
|
||||
if (!canUseBle) {
|
||||
// Browser Print만 시도 (스크립트 로드 후 기본 프린터로 전송)
|
||||
setPrinting(true);
|
||||
try {
|
||||
const result = await printZPLToBrowserPrint(zpl);
|
||||
if (result.success) {
|
||||
toast({ title: "전송 완료", description: result.message });
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast({
|
||||
title: "출력 실패",
|
||||
description: result.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({
|
||||
title: "안내",
|
||||
description: getBrowserPrintHelpMessage(),
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Web Bluetooth 지원 시: Browser Print 먼저 시도, 실패하면 BLE로 폴백
|
||||
setPrinting(true);
|
||||
try {
|
||||
const bpResult = await printZPLToBrowserPrint(zpl);
|
||||
if (bpResult.success) {
|
||||
toast({ title: "전송 완료", description: bpResult.message });
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const bleResult = await printZPLToZebraBLE(zpl);
|
||||
if (bleResult.success) {
|
||||
toast({ title: "전송 완료", description: bleResult.message });
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast({
|
||||
title: "출력 실패",
|
||||
description: bleResult.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
toast({
|
||||
title: "안내",
|
||||
description: getBrowserPrintHelpMessage(),
|
||||
variant: "default",
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: (e as Error).message || "Zebra 출력 중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>인쇄 미리보기</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{width_mm}×{height_mm}mm · {components.length}개 요소
|
||||
</p>
|
||||
|
||||
{/* 미리보기 캔버스 (축소) */}
|
||||
<div className="flex justify-center rounded border bg-gray-100 p-4">
|
||||
<div
|
||||
className="relative bg-white shadow"
|
||||
style={{
|
||||
width: previewW,
|
||||
height: previewH,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "0 0",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
}}
|
||||
>
|
||||
{components.map((c) => (
|
||||
<BarcodeLabelCanvasComponent key={c.id} component={c} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!bleSupported && (
|
||||
<div className="flex gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Web Bluetooth 미지원 브라우저입니다. Zebra Browser Print 앱을 설치하면 출력할 수 있습니다.
|
||||
{unsupportedMsg && ` ${unsupportedMsg}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{bleSupported ? (
|
||||
<>
|
||||
Zebra 프린터를 Bluetooth LE로 켜 두고, 출력 시 기기 선택에서 프린터를 선택하세요.
|
||||
(Chrome/Edge 권장)
|
||||
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
|
||||
<> Android에서는 목록에 인근 BLE 기기가 모두 표시되므로, 'ZD421' 등 프린터 이름을 골라 주세요.</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{typeof navigator !== "undefined" && /Android/i.test(navigator.userAgent) && (
|
||||
<>
|
||||
{" "}
|
||||
목록에 프린터가 안 나오면 지브라 공식 'Zebra Browser Print' 앱을 설치한 뒤, 앱에서 프린터 검색·기본 설정 후 이 사이트를 허용하면 출력할 수 있습니다.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadZPL} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
ZPL 다운로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={handlePrintToZebra}
|
||||
disabled={printing}
|
||||
>
|
||||
{printing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Printer className="h-4 w-4" />
|
||||
)}
|
||||
Zebra 프린터로 출력
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useBarcodeDesigner } from "@/contexts/BarcodeDesignerContext";
|
||||
import { barcodeApi, BarcodeLabelTemplate } from "@/lib/api/barcodeApi";
|
||||
|
||||
type Category = "all" | "basic" | "zebra";
|
||||
|
||||
export function BarcodeTemplatePalette() {
|
||||
const { applyTemplate } = useBarcodeDesigner();
|
||||
const [templates, setTemplates] = useState<BarcodeLabelTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [category, setCategory] = useState<Category>("all");
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await barcodeApi.getTemplates();
|
||||
if (res.success && res.data) setTemplates(res.data);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = templates;
|
||||
if (category === "basic") {
|
||||
list = list.filter((t) => t.template_id.startsWith("TMPL_"));
|
||||
} else if (category === "zebra") {
|
||||
list = list.filter((t) => t.template_id.startsWith("ZJ"));
|
||||
}
|
||||
const q = searchText.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(t) =>
|
||||
t.template_id.toLowerCase().includes(q) ||
|
||||
(t.template_name_kor && t.template_name_kor.toLowerCase().includes(q)) ||
|
||||
(t.template_name_eng && t.template_name_eng.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [templates, category, searchText]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">라벨 규격</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">라벨 규격</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="코드·이름으로 찾기"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={category === "all" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setCategory("all")}
|
||||
>
|
||||
전체
|
||||
</Button>
|
||||
<Button
|
||||
variant={category === "basic" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setCategory("basic")}
|
||||
>
|
||||
기본
|
||||
</Button>
|
||||
<Button
|
||||
variant={category === "zebra" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setCategory("zebra")}
|
||||
>
|
||||
제트라벨
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="h-[280px] pr-2">
|
||||
<div className="space-y-1">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">검색 결과 없음</p>
|
||||
) : (
|
||||
filtered.map((t) => (
|
||||
<Button
|
||||
key={t.template_id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto w-full justify-start py-1.5 text-left"
|
||||
onClick={() => applyTemplate(t.template_id)}
|
||||
>
|
||||
<span className="truncate">{t.template_name_kor}</span>
|
||||
<span className="text-muted-foreground ml-1 shrink-0 text-xs">
|
||||
{t.width_mm}×{t.height_mm}
|
||||
</span>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
@@ -63,6 +63,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||
icon: Search,
|
||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||
},
|
||||
{
|
||||
type: "pop-field",
|
||||
label: "입력 필드",
|
||||
icon: TextCursorInput,
|
||||
description: "저장용 값 입력 (섹션별 멀티필드)",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
||||
@@ -36,6 +36,15 @@ interface ConnectionEditorProps {
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
|
||||
// ========================================
|
||||
|
||||
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
|
||||
if (!meta?.sendable) return false;
|
||||
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ConnectionEditor
|
||||
// ========================================
|
||||
@@ -75,6 +84,8 @@ export default function ConnectionEditor({
|
||||
);
|
||||
}
|
||||
|
||||
const isFilterSource = hasFilterSendable(meta);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
@@ -83,6 +94,7 @@ export default function ConnectionEditor({
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
isFilterSource={isFilterSource}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
@@ -92,7 +104,6 @@ export default function ConnectionEditor({
|
||||
{hasReceivable && (
|
||||
<ReceiveSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
incoming={incoming}
|
||||
/>
|
||||
@@ -105,7 +116,6 @@ export default function ConnectionEditor({
|
||||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
/** 화면에 표시 중인 컬럼만 추출 */
|
||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||
if (!comp?.config) return [];
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
@@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri
|
||||
return cols;
|
||||
}
|
||||
|
||||
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
@@ -143,6 +152,7 @@ interface SendSectionProps {
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
outgoing: PopDataConnection[];
|
||||
isFilterSource: boolean;
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
@@ -153,6 +163,7 @@ function SendSection({
|
||||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
isFilterSource,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
@@ -163,29 +174,42 @@ function SendSection({
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||
이때 (보내기)
|
||||
보내기
|
||||
</Label>
|
||||
|
||||
{/* 기존 연결 목록 */}
|
||||
{outgoing.map((conn) => (
|
||||
<div key={conn.id}>
|
||||
{editingId === conn.id ? (
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
|
||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingId(conn.id)}
|
||||
@@ -206,23 +230,131 @@ function SendSection({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새 연결 추가 */}
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
{isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 폼 (추가/수정 공용)
|
||||
// 단순 연결 폼 (이벤트 타입: "어디로" 1개만)
|
||||
// ========================================
|
||||
|
||||
interface ConnectionFormProps {
|
||||
interface SimpleConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function SimpleConnectionForm({
|
||||
component,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: SimpleConnectionFormProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
initial?.targetComponent || ""
|
||||
);
|
||||
|
||||
const targetCandidates = allComponents.filter((c) => {
|
||||
if (c.id === component.id) return false;
|
||||
const reg = PopComponentRegistry.getComponent(c.type);
|
||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTargetId) return;
|
||||
|
||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||
const srcLabel = component.label || component.id;
|
||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: "_auto",
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: "_auto",
|
||||
label: `${srcLabel} → ${tgtLabel}`,
|
||||
});
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-dashed p-3">
|
||||
{onCancel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!onCancel && (
|
||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||
<Select
|
||||
value={selectedTargetId}
|
||||
onValueChange={setSelectedTargetId}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetCandidates.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.label || c.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedTargetId}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
@@ -232,7 +364,7 @@ interface ConnectionFormProps {
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function ConnectionForm({
|
||||
function FilterConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
@@ -240,7 +372,7 @@ function ConnectionForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: ConnectionFormProps) {
|
||||
}: FilterConnectionFormProps) {
|
||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||
);
|
||||
@@ -272,32 +404,26 @@ function ConnectionForm({
|
||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||
: null;
|
||||
|
||||
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
|
||||
React.useEffect(() => {
|
||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||
// 이미 선택된 값이 있으면 건드리지 않음
|
||||
if (selectedTargetInput) return;
|
||||
|
||||
const receivables = targetMeta.receivable;
|
||||
// 1) 같은 key가 있으면 자동 매칭
|
||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||
if (exactMatch) {
|
||||
setSelectedTargetInput(exactMatch.key);
|
||||
return;
|
||||
}
|
||||
// 2) receivable이 1개뿐이면 자동 선택
|
||||
if (receivables.length === 1) {
|
||||
setSelectedTargetInput(receivables[0].key);
|
||||
}
|
||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||
|
||||
// 화면에 표시 중인 컬럼
|
||||
const displayColumns = React.useMemo(
|
||||
() => extractDisplayColumns(targetComp || undefined),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
@@ -324,7 +450,6 @@ function ConnectionForm({
|
||||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
@@ -388,7 +513,6 @@ function ConnectionForm({
|
||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
{/* 보내는 값 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||
@@ -405,7 +529,6 @@ function ConnectionForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 컴포넌트 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||
<Select
|
||||
@@ -429,7 +552,6 @@ function ConnectionForm({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 방식 */}
|
||||
{targetMeta && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||
@@ -448,7 +570,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
@@ -460,7 +581,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
) : hasAnyColumns ? (
|
||||
<div className="space-y-2">
|
||||
{/* 표시 컬럼 그룹 */}
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||
@@ -482,7 +602,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전용 컬럼 그룹 */}
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
@@ -522,7 +641,6 @@ function ConnectionForm({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 필터 방식 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||
@@ -540,7 +658,6 @@ function ConnectionForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -556,19 +673,17 @@ function ConnectionForm({
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용)
|
||||
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||
// ========================================
|
||||
|
||||
interface ReceiveSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
incoming: PopDataConnection[];
|
||||
}
|
||||
|
||||
function ReceiveSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
incoming,
|
||||
}: ReceiveSectionProps) {
|
||||
@@ -576,28 +691,11 @@ function ReceiveSection({
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||
이렇게 (받기)
|
||||
받기
|
||||
</Label>
|
||||
|
||||
<div className="space-y-1">
|
||||
{meta.receivable.map((r) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
|
||||
>
|
||||
<span className="font-medium">{r.label}</span>
|
||||
{r.description && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{incoming.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
||||
<div className="space-y-1">
|
||||
{incoming.map((conn) => {
|
||||
const sourceComp = allComponents.find(
|
||||
(c) => c.id === conn.sourceComponent
|
||||
@@ -605,9 +703,9 @@ function ReceiveSection({
|
||||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
className="flex items-center gap-2 rounded border bg-green-50/50 px-3 py-2 text-xs"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<ArrowRight className="h-3 w-3 text-green-500" />
|
||||
<span className="truncate">
|
||||
{sourceComp?.label || conn.sourceComponent}
|
||||
</span>
|
||||
@@ -617,7 +715,7 @@ function ReceiveSection({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||
연결된 소스가 없습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -651,5 +749,5 @@ function buildConnectionLabel(
|
||||
const colInfo = columns && columns.length > 0
|
||||
? ` [${columns.join(", ")}]`
|
||||
: "";
|
||||
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
||||
return `${srcLabel} → ${tgtLabel}${colInfo}`;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-field": "입력",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search";
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
@@ -361,6 +361,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-search": { colSpan: 4, rowSpan: 2 },
|
||||
"pop-field": { colSpan: 6, rowSpan: 2 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Search,
|
||||
Settings,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -68,11 +70,27 @@ import {
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
export interface GroupCopyInfo {
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
children: Array<{
|
||||
sourceGroupId: number;
|
||||
groupName: string;
|
||||
groupCode: string;
|
||||
screenIds: number[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PopCategoryTreeProps {
|
||||
screens: ScreenDefinition[]; // POP 레이아웃이 있는 화면 목록
|
||||
selectedScreen: ScreenDefinition | null;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onScreenSettings?: (screen: ScreenDefinition) => void;
|
||||
onScreenCopy?: (screen: ScreenDefinition) => void;
|
||||
onGroupCopy?: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||
onGroupSelect?: (group: PopScreenGroup | null) => void;
|
||||
searchTerm?: string;
|
||||
}
|
||||
@@ -87,6 +105,8 @@ interface TreeNodeProps {
|
||||
onGroupSelect: (group: PopScreenGroup) => void;
|
||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||
onScreenDesign: (screen: ScreenDefinition) => void;
|
||||
onScreenSettings: (screen: ScreenDefinition) => void;
|
||||
onScreenCopy: (screen: ScreenDefinition) => void;
|
||||
onEditGroup: (group: PopScreenGroup) => void;
|
||||
onDeleteGroup: (group: PopScreenGroup) => void;
|
||||
onAddSubGroup: (parentGroup: PopScreenGroup) => void;
|
||||
@@ -101,6 +121,7 @@ interface TreeNodeProps {
|
||||
onMoveScreenUp: (screen: ScreenDefinition, groupId: number) => void;
|
||||
onMoveScreenDown: (screen: ScreenDefinition, groupId: number) => void;
|
||||
onDeleteScreen: (screen: ScreenDefinition) => void;
|
||||
onGroupCopy: (screens: ScreenDefinition[], groupName: string, groupInfo?: GroupCopyInfo) => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -118,6 +139,7 @@ function TreeNode({
|
||||
onMoveScreenUp,
|
||||
onMoveScreenDown,
|
||||
onDeleteScreen,
|
||||
onGroupCopy,
|
||||
expandedGroups,
|
||||
onToggle,
|
||||
selectedGroupId,
|
||||
@@ -125,6 +147,8 @@ function TreeNode({
|
||||
onGroupSelect,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onScreenSettings,
|
||||
onScreenCopy,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onAddSubGroup,
|
||||
@@ -134,7 +158,7 @@ function TreeNode({
|
||||
const hasChildren = (group.children && group.children.length > 0) || (group.screens && group.screens.length > 0);
|
||||
const isSelected = selectedGroupId === group.id;
|
||||
|
||||
// 그룹에 연결된 화면 목록
|
||||
// 그룹에 직접 연결된 화면 목록
|
||||
const groupScreens = useMemo(() => {
|
||||
if (!group.screens) return [];
|
||||
return group.screens
|
||||
@@ -142,6 +166,20 @@ function TreeNode({
|
||||
.filter((s): s is ScreenDefinition => s !== undefined);
|
||||
}, [group.screens, screensMap]);
|
||||
|
||||
// 하위 그룹 포함 전체 화면 (복사용)
|
||||
const allDescendantScreens = useMemo(() => {
|
||||
const collected = new Map<number, ScreenDefinition>();
|
||||
const collectRecursive = (g: PopScreenGroup) => {
|
||||
g.screens?.forEach((gs) => {
|
||||
const screen = screensMap.get(gs.screen_id);
|
||||
if (screen) collected.set(screen.screenId, screen);
|
||||
});
|
||||
g.children?.forEach(collectRecursive);
|
||||
};
|
||||
collectRecursive(group);
|
||||
return Array.from(collected.values());
|
||||
}, [group, screensMap]);
|
||||
|
||||
// 루트 레벨(POP 화면)인지 확인
|
||||
const isRootLevel = level === 0;
|
||||
|
||||
@@ -193,8 +231,15 @@ function TreeNode({
|
||||
<Folder className={cn("h-4 w-4 shrink-0", isRootLevel ? "text-orange-500" : "text-amber-500")} />
|
||||
)}
|
||||
|
||||
{/* 그룹명 - 루트는 볼드체 */}
|
||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>{group.group_name}</span>
|
||||
{/* 그룹명 - 루트는 볼드체 + 회사코드 표시 */}
|
||||
<span className={cn("flex-1 text-sm truncate", isRootLevel && "font-semibold")}>
|
||||
{group.group_name}
|
||||
{isRootLevel && group.company_code && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground font-normal">
|
||||
{group.company_code === "*" ? "(전체)" : `(${group.company_code})`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 화면 수 배지 */}
|
||||
{group.screen_count && group.screen_count > 0 && (
|
||||
@@ -224,6 +269,34 @@ function TreeNode({
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
수정
|
||||
</DropdownMenuItem>
|
||||
{allDescendantScreens.length > 0 && (
|
||||
<DropdownMenuItem onClick={() => {
|
||||
const buildGroupInfo = (g: PopScreenGroup): GroupCopyInfo => {
|
||||
const directScreenIds = (g.screens || [])
|
||||
.map((gs) => gs.screen_id)
|
||||
.filter((id) => screensMap.has(id));
|
||||
const children = (g.children || []).map((child) => ({
|
||||
sourceGroupId: child.id,
|
||||
groupName: child.group_name,
|
||||
groupCode: child.group_code,
|
||||
screenIds: (child.screens || [])
|
||||
.map((gs) => gs.screen_id)
|
||||
.filter((id) => screensMap.has(id)),
|
||||
}));
|
||||
return {
|
||||
sourceGroupId: g.id,
|
||||
groupName: g.group_name,
|
||||
groupCode: g.group_code,
|
||||
screenIds: directScreenIds,
|
||||
children,
|
||||
};
|
||||
};
|
||||
onGroupCopy(allDescendantScreens, group.group_name, buildGroupInfo(group));
|
||||
}}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
카테고리 복사 ({allDescendantScreens.length}개 화면)
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMoveGroupUp(group)}
|
||||
@@ -267,6 +340,8 @@ function TreeNode({
|
||||
onGroupSelect={onGroupSelect}
|
||||
onScreenSelect={onScreenSelect}
|
||||
onScreenDesign={onScreenDesign}
|
||||
onScreenSettings={onScreenSettings}
|
||||
onScreenCopy={onScreenCopy}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
@@ -279,6 +354,7 @@ function TreeNode({
|
||||
onMoveScreenUp={onMoveScreenUp}
|
||||
onMoveScreenDown={onMoveScreenDown}
|
||||
onDeleteScreen={onDeleteScreen}
|
||||
onGroupCopy={onGroupCopy}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -324,6 +400,14 @@ function TreeNode({
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenSettings(screen)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
설정 (이름 변경)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenCopy(screen)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMoveScreenUp(screen, group.id)}
|
||||
@@ -378,6 +462,9 @@ export function PopCategoryTree({
|
||||
selectedScreen,
|
||||
onScreenSelect,
|
||||
onScreenDesign,
|
||||
onScreenSettings,
|
||||
onScreenCopy,
|
||||
onGroupCopy,
|
||||
onGroupSelect,
|
||||
searchTerm = "",
|
||||
}: PopCategoryTreeProps) {
|
||||
@@ -412,6 +499,9 @@ export function PopCategoryTree({
|
||||
const [movingFromGroupId, setMovingFromGroupId] = useState<number | null>(null);
|
||||
const [moveSearchTerm, setMoveSearchTerm] = useState("");
|
||||
|
||||
// 미분류 회사코드별 접기/펼치기
|
||||
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// 화면 맵 생성 (screen_id로 빠르게 조회)
|
||||
const screensMap = useMemo(() => {
|
||||
const map = new Map<number, ScreenDefinition>();
|
||||
@@ -430,14 +520,6 @@ export function PopCategoryTree({
|
||||
// 그룹 목록 조회
|
||||
const data = await getPopScreenGroups(searchTerm);
|
||||
setGroups(data);
|
||||
|
||||
// 첫 로드 시 루트 그룹들 자동 확장
|
||||
if (expandedGroups.size === 0 && data.length > 0) {
|
||||
const rootIds = data
|
||||
.filter((g) => g.hierarchy_path === "POP" || g.hierarchy_path?.split("/").length === 2)
|
||||
.map((g) => g.id);
|
||||
setExpandedGroups(new Set(rootIds));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("POP 그룹 로드 실패:", error);
|
||||
toast.error("그룹 목록 로드에 실패했습니다.");
|
||||
@@ -847,7 +929,7 @@ export function PopCategoryTree({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="shrink-0 p-3 border-b flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">POP 카테고리</h3>
|
||||
@@ -862,7 +944,7 @@ export function PopCategoryTree({
|
||||
</div>
|
||||
|
||||
{/* 트리 영역 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="p-2">
|
||||
{treeData.length === 0 && ungroupedScreens.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
@@ -887,6 +969,8 @@ export function PopCategoryTree({
|
||||
onGroupSelect={handleGroupSelect}
|
||||
onScreenSelect={onScreenSelect}
|
||||
onScreenDesign={onScreenDesign}
|
||||
onScreenSettings={onScreenSettings || (() => {})}
|
||||
onScreenCopy={onScreenCopy || (() => {})}
|
||||
onEditGroup={(g) => openGroupModal(undefined, g)}
|
||||
onDeleteGroup={(g) => {
|
||||
setDeletingGroup(g);
|
||||
@@ -902,66 +986,122 @@ export function PopCategoryTree({
|
||||
onMoveScreenUp={handleMoveScreenUp}
|
||||
onMoveScreenDown={handleMoveScreenDown}
|
||||
onDeleteScreen={handleDeleteScreen}
|
||||
onGroupCopy={onGroupCopy || (() => {})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 미분류 화면 */}
|
||||
{/* 미분류 화면 - 회사코드별 그룹핑 */}
|
||||
{ungroupedScreens.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="text-xs text-muted-foreground px-2 mb-2">
|
||||
미분류 ({ungroupedScreens.length})
|
||||
</div>
|
||||
{ungroupedScreens.map((screen) => (
|
||||
<div
|
||||
key={`ungrouped-${screen.screenId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||
selectedScreen?.screenId === screen.screenId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
"group"
|
||||
)}
|
||||
onClick={() => onScreenSelect(screen)}
|
||||
onDoubleClick={() => onScreenDesign(screen)}
|
||||
>
|
||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||
|
||||
{/* 더보기 메뉴 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{(() => {
|
||||
const grouped = ungroupedScreens.reduce<Record<string, typeof ungroupedScreens>>((acc, screen) => {
|
||||
const code = screen.companyCode || "unknown";
|
||||
if (!acc[code]) acc[code] = [];
|
||||
acc[code].push(screen);
|
||||
return acc;
|
||||
}, {});
|
||||
const companyKeys = Object.keys(grouped).sort();
|
||||
|
||||
const toggleCompanyCode = (code: string) => {
|
||||
setExpandedCompanyCodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return companyKeys.map((companyCode) => {
|
||||
const isExpanded = expandedCompanyCodes.has(companyCode);
|
||||
const label = companyCode === "*" ? "최고관리자" : companyCode;
|
||||
|
||||
return (
|
||||
<div key={`ungrouped-company-${companyCode}`}>
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 mt-1 bg-muted/50 rounded cursor-pointer select-none hover:bg-muted transition-colors"
|
||||
onClick={() => toggleCompanyCode(companyCode)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Badge variant="outline" className="ml-auto h-4 text-[9px] px-1">
|
||||
{grouped[companyCode].length}
|
||||
</Badge>
|
||||
</div>
|
||||
{isExpanded && grouped[companyCode].map((screen) => (
|
||||
<div
|
||||
key={`ungrouped-${screen.screenId}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md cursor-pointer transition-colors",
|
||||
selectedScreen?.screenId === screen.screenId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted",
|
||||
"group",
|
||||
"pl-4"
|
||||
)}
|
||||
onClick={() => onScreenSelect(screen)}
|
||||
onDoubleClick={() => onScreenDesign(screen)}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||
<MoveRight className="h-4 w-4 mr-2" />
|
||||
카테고리로 이동
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteScreen(screen)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
화면 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
<Monitor className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
<span className="flex-1 text-sm truncate">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">#{screen.screenId}</span>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => onScreenDesign(screen)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
설계
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenSettings?.(screen)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
설정 (이름 변경)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onScreenCopy?.(screen)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => openMoveModal(screen, null)}>
|
||||
<MoveRight className="h-4 w-4 mr-2" />
|
||||
카테고리로 이동
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => handleDeleteScreen(screen)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
화면 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Loader2, Link2, Monitor, Folder, ChevronRight } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { GroupCopyInfo } from "./PopCategoryTree";
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { Company } from "@/types/company";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LinkedScreenInfo {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
references: Array<{
|
||||
componentId: string;
|
||||
referenceType: string;
|
||||
}>;
|
||||
deploy: boolean;
|
||||
newScreenName: string;
|
||||
newScreenCode: string;
|
||||
}
|
||||
|
||||
interface ScreenEntry {
|
||||
screenId: number;
|
||||
screenName: string;
|
||||
newScreenName: string;
|
||||
newScreenCode: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
interface PopDeployModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
screen: ScreenDefinition | null;
|
||||
groupScreens?: ScreenDefinition[];
|
||||
groupName?: string;
|
||||
groupInfo?: GroupCopyInfo;
|
||||
allScreens: ScreenDefinition[];
|
||||
onDeployed?: () => void;
|
||||
}
|
||||
|
||||
export function PopDeployModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
screen,
|
||||
groupScreens,
|
||||
groupName,
|
||||
groupInfo,
|
||||
allScreens,
|
||||
onDeployed,
|
||||
}: PopDeployModalProps) {
|
||||
const isGroupMode = !!(groupScreens && groupScreens.length > 0);
|
||||
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [targetCompanyCode, setTargetCompanyCode] = useState("");
|
||||
|
||||
// 단일 화면 모드
|
||||
const [screenName, setScreenName] = useState("");
|
||||
const [screenCode, setScreenCode] = useState("");
|
||||
const [linkedScreens, setLinkedScreens] = useState<LinkedScreenInfo[]>([]);
|
||||
|
||||
// 그룹 모드
|
||||
const [groupEntries, setGroupEntries] = useState<ScreenEntry[]>([]);
|
||||
|
||||
const [analyzing, setAnalyzing] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
|
||||
// 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getCompanyList({ status: "active" })
|
||||
.then((list) => {
|
||||
setCompanies(list.filter((c) => c.company_code !== "*"));
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setTargetCompanyCode("");
|
||||
setLinkedScreens([]);
|
||||
|
||||
if (isGroupMode && groupScreens) {
|
||||
setGroupEntries(
|
||||
groupScreens.map((s) => ({
|
||||
screenId: s.screenId,
|
||||
screenName: s.screenName,
|
||||
newScreenName: s.screenName,
|
||||
newScreenCode: "",
|
||||
included: true,
|
||||
})),
|
||||
);
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
} else if (screen) {
|
||||
setScreenName(screen.screenName);
|
||||
setScreenCode("");
|
||||
setGroupEntries([]);
|
||||
analyzeLinks(screen.screenId);
|
||||
}
|
||||
}, [open, screen, groupScreens, isGroupMode]);
|
||||
|
||||
// 회사 선택 시 화면 코드 자동 생성
|
||||
useEffect(() => {
|
||||
if (!targetCompanyCode) return;
|
||||
|
||||
if (isGroupMode) {
|
||||
const count = groupEntries.filter((e) => e.included).length;
|
||||
if (count > 0) {
|
||||
screenApi
|
||||
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||
.then((codes) => {
|
||||
let codeIdx = 0;
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.included
|
||||
? { ...e, newScreenCode: codes[codeIdx++] || "" }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
} else {
|
||||
const count = 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||
screenApi
|
||||
.generateMultipleScreenCodes(targetCompanyCode, count)
|
||||
.then((codes) => {
|
||||
setScreenCode(codes[0] || "");
|
||||
setLinkedScreens((prev) =>
|
||||
prev.map((ls, idx) => ({
|
||||
...ls,
|
||||
newScreenCode: codes[idx + 1] || "",
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [targetCompanyCode]);
|
||||
|
||||
const analyzeLinks = async (screenId: number) => {
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
const result = await screenApi.analyzePopScreenLinks(screenId);
|
||||
const linked: LinkedScreenInfo[] = result.linkedScreenIds.map(
|
||||
(linkedId) => {
|
||||
const linkedScreen = allScreens.find(
|
||||
(s) => s.screenId === linkedId,
|
||||
);
|
||||
const refs = result.references.filter(
|
||||
(r) => r.targetScreenId === linkedId,
|
||||
);
|
||||
return {
|
||||
screenId: linkedId,
|
||||
screenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||
screenCode: linkedScreen?.screenCode || "",
|
||||
references: refs.map((r) => ({
|
||||
componentId: r.componentId,
|
||||
referenceType: r.referenceType,
|
||||
})),
|
||||
deploy: true,
|
||||
newScreenName: linkedScreen?.screenName || `화면 ${linkedId}`,
|
||||
newScreenCode: "",
|
||||
};
|
||||
},
|
||||
);
|
||||
setLinkedScreens(linked);
|
||||
} catch (error) {
|
||||
console.error("연결 분석 실패:", error);
|
||||
} finally {
|
||||
setAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async () => {
|
||||
if (!targetCompanyCode) return;
|
||||
|
||||
setDeploying(true);
|
||||
try {
|
||||
let screensToSend: Array<{
|
||||
sourceScreenId: number;
|
||||
screenName: string;
|
||||
screenCode: string;
|
||||
}>;
|
||||
|
||||
if (isGroupMode) {
|
||||
screensToSend = groupEntries
|
||||
.filter((e) => e.included && e.newScreenCode)
|
||||
.map((e) => ({
|
||||
sourceScreenId: e.screenId,
|
||||
screenName: e.newScreenName,
|
||||
screenCode: e.newScreenCode,
|
||||
}));
|
||||
} else {
|
||||
if (!screen || !screenName || !screenCode) return;
|
||||
screensToSend = [
|
||||
{
|
||||
sourceScreenId: screen.screenId,
|
||||
screenName,
|
||||
screenCode,
|
||||
},
|
||||
...linkedScreens
|
||||
.filter((ls) => ls.deploy)
|
||||
.map((ls) => ({
|
||||
sourceScreenId: ls.screenId,
|
||||
screenName: ls.newScreenName,
|
||||
screenCode: ls.newScreenCode,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
if (screensToSend.length === 0) {
|
||||
toast.error("복사할 화면이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deployPayload: Parameters<typeof screenApi.deployPopScreens>[0] = {
|
||||
screens: screensToSend,
|
||||
targetCompanyCode,
|
||||
};
|
||||
|
||||
if (isGroupMode && groupInfo) {
|
||||
deployPayload.groupStructure = groupInfo;
|
||||
}
|
||||
|
||||
const result = await screenApi.deployPopScreens(deployPayload);
|
||||
|
||||
const groupMsg = result.createdGroups
|
||||
? ` (카테고리 ${result.createdGroups}개 생성)`
|
||||
: "";
|
||||
toast.success(
|
||||
`POP 화면 ${result.deployedScreens.length}개가 복사되었습니다.${groupMsg}`,
|
||||
);
|
||||
onOpenChange(false);
|
||||
onDeployed?.();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "복사에 실패했습니다.");
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalCount = isGroupMode
|
||||
? groupEntries.filter((e) => e.included).length
|
||||
: 1 + linkedScreens.filter((ls) => ls.deploy).length;
|
||||
|
||||
const canDeploy = isGroupMode
|
||||
? !deploying && targetCompanyCode && groupEntries.some((e) => e.included)
|
||||
: !deploying && targetCompanyCode && screenName && screenCode;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
POP 화면 복사
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{isGroupMode
|
||||
? `"${groupName}" 카테고리의 화면 ${groupScreens!.length}개를 다른 회사로 복사합니다.`
|
||||
: screen
|
||||
? `"${screen.screenName}" (ID: ${screen.screenId}) 화면을 다른 회사로 복사합니다.`
|
||||
: "화면을 선택해주세요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* 대상 회사 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
대상 회사 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={targetCompanyCode}
|
||||
onValueChange={setTargetCompanyCode}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="회사를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{companies.map((c) => (
|
||||
<SelectItem
|
||||
key={c.company_code}
|
||||
value={c.company_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{c.company_name} ({c.company_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ===== 그룹 모드: 카테고리 구조 + 화면 목록 ===== */}
|
||||
{isGroupMode ? (
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
복사 구조 ({groupEntries.filter((e) => e.included).length}개
|
||||
화면
|
||||
{groupInfo
|
||||
? ` + ${1 + (groupInfo.children?.length || 0)}개 카테고리`
|
||||
: ""}
|
||||
)
|
||||
</Label>
|
||||
<div className="mt-1 max-h-[280px] overflow-y-auto rounded-md border p-2">
|
||||
{groupInfo ? (
|
||||
<div className="space-y-0.5">
|
||||
{/* 메인 카테고리 */}
|
||||
<div className="flex items-center gap-1.5 rounded bg-muted/50 p-1.5 text-xs font-medium">
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<span>{groupInfo.groupName}</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
새 카테고리 생성
|
||||
</span>
|
||||
</div>
|
||||
{/* 메인 카테고리의 직접 화면 */}
|
||||
{groupEntries
|
||||
.filter((e) => groupInfo.screenIds.includes(e.screenId))
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 pl-6 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{/* 하위 카테고리들 */}
|
||||
{groupInfo.children?.map((child) => (
|
||||
<div key={child.sourceGroupId}>
|
||||
<div className="mt-1 flex items-center gap-1.5 rounded bg-muted/30 p-1.5 pl-4 text-xs font-medium">
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
<span>{child.groupName}</span>
|
||||
</div>
|
||||
{groupEntries
|
||||
.filter((e) =>
|
||||
child.screenIds.includes(e.screenId),
|
||||
)
|
||||
.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 pl-10 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-blue-500" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groupEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.screenId}
|
||||
className="flex items-center gap-2 rounded p-1.5 text-xs hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={entry.included}
|
||||
onCheckedChange={(checked) => {
|
||||
setGroupEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.screenId === entry.screenId
|
||||
? { ...e, included: !!checked }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Monitor className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">
|
||||
{entry.screenName}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
#{entry.screenId}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
카테고리 구조와 화면 간 연결(cartScreenId 등)이 자동으로
|
||||
복사됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* ===== 단일 모드: 화면명 + 코드 ===== */}
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
새 화면명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">
|
||||
화면 코드 (자동생성)
|
||||
</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm"
|
||||
value={screenCode}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 연결 화면 감지 */}
|
||||
{analyzing ? (
|
||||
<div className="flex items-center gap-2 rounded-md border p-3 text-xs text-muted-foreground sm:text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
연결된 화면을 분석 중입니다...
|
||||
</div>
|
||||
) : linkedScreens.length > 0 ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-800 dark:text-amber-300 sm:text-sm">
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
연결된 POP 화면 {linkedScreens.length}개 감지됨
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{linkedScreens.map((ls) => (
|
||||
<div
|
||||
key={ls.screenId}
|
||||
className="flex items-center justify-between rounded bg-background p-2 text-xs"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{ls.screenName}</div>
|
||||
<div className="text-muted-foreground">
|
||||
ID: {ls.screenId} |{" "}
|
||||
{ls.references
|
||||
.map((r) => r.referenceType)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={ls.deploy}
|
||||
onCheckedChange={(checked) => {
|
||||
setLinkedScreens((prev) =>
|
||||
prev.map((item) =>
|
||||
item.screenId === ls.screenId
|
||||
? { ...item, deploy: !!checked }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">함께 복사</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
|
||||
함께 복사하면 화면 간 연결(cartScreenId 등)이 새 ID로 자동
|
||||
치환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
!analyzing && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs text-muted-foreground sm:text-sm">
|
||||
연결된 POP 화면이 없습니다. 이 화면만 복사됩니다.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deploying}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeploy}
|
||||
disabled={!canDeploy}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{deploying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
`${totalCount}개 화면 복사`
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -166,19 +166,26 @@ export function PopScreenSettingModal({
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// 화면 기본 정보 업데이트
|
||||
const screenUpdate: Partial<ScreenDefinition> = {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
};
|
||||
|
||||
// screen_definitions 테이블에 화면명/설명 업데이트
|
||||
if (screenName !== screen.screenName || screenDescription !== (screen.description || "")) {
|
||||
await screenApi.updateScreenInfo(screen.screenId, {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
isActive: "Y",
|
||||
});
|
||||
}
|
||||
|
||||
// 레이아웃에 하위 화면 정보 저장
|
||||
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
||||
const updatedLayout = {
|
||||
...currentLayout,
|
||||
version: "pop-1.0",
|
||||
subScreens: subScreens,
|
||||
// flow 배열 자동 생성 (메인 → 각 서브)
|
||||
flow: subScreens.map((sub) => ({
|
||||
from: sub.triggerFrom || "main",
|
||||
to: sub.id,
|
||||
@@ -202,11 +209,11 @@ export function PopScreenSettingModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="p-4 pb-0 shrink-0">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{screen.screenName} ({screen.screenCode})
|
||||
{screen.screenName} [{screen.screenCode}]
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -215,57 +222,57 @@ export function PopScreenSettingModal({
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsList className="shrink-0 mx-6 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
개요
|
||||
<FileText className="h-3.5 w-3.5 mr-1.5" />
|
||||
기본 정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="subscreens"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
<Layers className="h-3.5 w-3.5 mr-1.5" />
|
||||
하위 화면
|
||||
{subScreens.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1.5">
|
||||
{subScreens.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="flow"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-3 py-2 text-xs sm:text-sm"
|
||||
>
|
||||
<GitBranch className="h-4 w-4 mr-2" />
|
||||
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
|
||||
화면 흐름
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
||||
{/* 기본 정보 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-[500px]">
|
||||
<div>
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||||
화면명 *
|
||||
화면명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="screenName"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
placeholder="화면 이름을 입력하세요"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||
카테고리
|
||||
</Label>
|
||||
@@ -283,7 +290,7 @@ export function PopScreenSettingModal({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
@@ -291,13 +298,13 @@ export function PopScreenSettingModal({
|
||||
id="description"
|
||||
value={screenDescription}
|
||||
onChange={(e) => setScreenDescription(e.target.value)}
|
||||
placeholder="화면에 대한 설명"
|
||||
placeholder="화면에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
className="text-xs sm:text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||||
아이콘
|
||||
</Label>
|
||||
@@ -308,7 +315,7 @@ export function PopScreenSettingModal({
|
||||
placeholder="lucide 아이콘 이름 (예: Package)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||
lucide-react 아이콘 이름을 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
@@ -317,19 +324,19 @@ export function PopScreenSettingModal({
|
||||
</TabsContent>
|
||||
|
||||
{/* 하위 화면 탭 */}
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 overflow-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
모달, 드로어 등 하위 화면을 관리합니다.
|
||||
</p>
|
||||
<Button size="sm" onClick={addSubScreen}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Button size="sm" className="h-8 text-xs" onClick={addSubScreen}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
<ScrollArea className="h-[280px]">
|
||||
{subScreens.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
@@ -340,12 +347,12 @@ export function PopScreenSettingModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{subScreens.map((subScreen, index) => (
|
||||
{subScreens.map((subScreen) => (
|
||||
<div
|
||||
key={subScreen.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
className="flex items-start gap-2 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0 mt-1.5 cursor-grab" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -363,7 +370,7 @@ export function PopScreenSettingModal({
|
||||
updateSubScreen(subScreen.id, "type", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectTrigger className="h-8 text-xs w-[90px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -375,7 +382,7 @@ export function PopScreenSettingModal({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
트리거:
|
||||
</span>
|
||||
<Select
|
||||
@@ -404,10 +411,10 @@ export function PopScreenSettingModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeSubScreen(subScreen.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -424,11 +431,19 @@ export function PopScreenSettingModal({
|
||||
</Tabs>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<div className="shrink-0 px-6 py-4 border-t flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
|
||||
export { PopCategoryTree } from "./PopCategoryTree";
|
||||
export type { GroupCopyInfo } from "./PopCategoryTree";
|
||||
export { PopScreenPreview } from "./PopScreenPreview";
|
||||
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||
export { PopDeployModal } from "./PopDeployModal";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -61,6 +62,7 @@ export default function PopViewerWithModals({
|
||||
overrideGap,
|
||||
overridePadding,
|
||||
}: PopViewerWithModalsProps) {
|
||||
const router = useRouter();
|
||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||
const { subscribe, publish } = usePopEvent(screenId);
|
||||
|
||||
@@ -69,9 +71,21 @@ export default function PopViewerWithModals({
|
||||
() => layout.dataFlow?.connections ?? [],
|
||||
[layout.dataFlow?.connections]
|
||||
);
|
||||
|
||||
const componentTypes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (layout.components) {
|
||||
for (const comp of Object.values(layout.components)) {
|
||||
map.set(comp.id, comp.type);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [layout.components]);
|
||||
|
||||
useConnectionResolver({
|
||||
screenId,
|
||||
connections: stableConnections,
|
||||
componentTypes,
|
||||
});
|
||||
|
||||
// 모달 열기/닫기 이벤트 구독
|
||||
@@ -114,11 +128,30 @@ export default function PopViewerWithModals({
|
||||
});
|
||||
});
|
||||
|
||||
const unsubNavigate = subscribe("__pop_navigate__", (payload: unknown) => {
|
||||
const data = payload as {
|
||||
screenId?: string;
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
if (!data?.screenId) return;
|
||||
|
||||
if (data.screenId === "back") {
|
||||
router.back();
|
||||
} else {
|
||||
const query = data.params
|
||||
? "?" + new URLSearchParams(data.params).toString()
|
||||
: "";
|
||||
window.location.href = `/pop/screens/${data.screenId}${query}`;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubOpen();
|
||||
unsubClose();
|
||||
unsubNavigate();
|
||||
};
|
||||
}, [subscribe, publish, layout.modals]);
|
||||
}, [subscribe, publish, layout.modals, router]);
|
||||
|
||||
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
|
||||
const handleCloseTopModal = useCallback(() => {
|
||||
|
||||
@@ -2092,24 +2092,25 @@ export default function ScreenDesigner({
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
// POP 모드: screen_layouts_pop 테이블에 저장
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
|
||||
mainTableName: currentMainTableName,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
||||
// console.log("✅ 저장 성공!");
|
||||
// 테이블이 변경된 경우 전용 API로 명시적으로 업데이트
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) {
|
||||
await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName);
|
||||
}
|
||||
|
||||
toast.success("화면이 저장되었습니다.");
|
||||
|
||||
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
|
||||
if (onScreenUpdate && currentMainTableName) {
|
||||
onScreenUpdate({ tableName: currentMainTableName });
|
||||
}
|
||||
@@ -5625,33 +5626,38 @@ export default function ScreenDesigner({
|
||||
if (layout.components.length > 0 && selectedScreen?.screenId) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 해상도 정보를 포함한 레이아웃 데이터 생성
|
||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
screenResolution: screenResolution,
|
||||
mainTableName: currentMainTableName,
|
||||
};
|
||||
console.log("⚡ 자동 저장할 레이아웃 데이터:", {
|
||||
componentsCount: layoutWithResolution.components.length,
|
||||
gridSettings: layoutWithResolution.gridSettings,
|
||||
screenResolution: layoutWithResolution.screenResolution,
|
||||
});
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
||||
if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) {
|
||||
await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName);
|
||||
}
|
||||
|
||||
toast.success("레이아웃이 저장되었습니다.");
|
||||
|
||||
if (onScreenUpdate && currentMainTableName) {
|
||||
onScreenUpdate({ tableName: currentMainTableName });
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("레이아웃 저장 실패:", error);
|
||||
toast.error("레이아웃 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -5783,6 +5789,8 @@ export default function ScreenDesigner({
|
||||
handleGroupDistribute,
|
||||
handleMatchSize,
|
||||
handleToggleAllLabels,
|
||||
tables,
|
||||
onScreenUpdate,
|
||||
]);
|
||||
|
||||
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
||||
|
||||
@@ -135,6 +135,7 @@ export function ScreenGroupTreeView({
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
|
||||
const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스
|
||||
const [deleteNumberingRules, setDeleteNumberingRules] = useState(false); // 채번 규칙도 함께 삭제 체크박스
|
||||
const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태
|
||||
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태
|
||||
|
||||
@@ -439,7 +440,8 @@ export function ScreenGroupTreeView({
|
||||
const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setDeletingGroup(group);
|
||||
setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함
|
||||
setDeleteScreensWithGroup(false);
|
||||
setDeleteNumberingRules(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -572,11 +574,17 @@ export function ScreenGroupTreeView({
|
||||
// 최종적으로 대상 그룹 삭제
|
||||
currentStep++;
|
||||
setDeleteProgress({ current: currentStep, total: totalSteps, message: "그룹 삭제 완료 중..." });
|
||||
const response = await deleteScreenGroup(deletingGroup.id);
|
||||
const isRootGroup = !deletingGroup.parent_group_id;
|
||||
const response = await deleteScreenGroup(deletingGroup.id, {
|
||||
deleteNumberingRules: isRootGroup && deleteNumberingRules,
|
||||
});
|
||||
if (response.success) {
|
||||
const messages = [];
|
||||
if (deleteScreensWithGroup) messages.push(`화면 ${totalScreensToDelete}개`);
|
||||
if (isRootGroup && deleteNumberingRules) messages.push("채번 규칙");
|
||||
toast.success(
|
||||
deleteScreensWithGroup
|
||||
? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다`
|
||||
messages.length > 0
|
||||
? `그룹과 ${messages.join(", ")}이(가) 삭제되었습니다`
|
||||
: "그룹이 삭제되었습니다"
|
||||
);
|
||||
await loadGroupsData();
|
||||
@@ -593,6 +601,7 @@ export function ScreenGroupTreeView({
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroup(null);
|
||||
setDeleteScreensWithGroup(false);
|
||||
setDeleteNumberingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1012,7 +1021,7 @@ export function ScreenGroupTreeView({
|
||||
const loadGroupsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getScreenGroups({ size: 1000 }); // 모든 그룹 가져오기
|
||||
const response = await getScreenGroups({ size: 1000, excludePop: true });
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
|
||||
@@ -1479,7 +1488,7 @@ export function ScreenGroupTreeView({
|
||||
</p>
|
||||
<p className="mt-2 text-destructive/80">
|
||||
{deleteScreensWithGroup
|
||||
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
? "그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||||
}
|
||||
</p>
|
||||
@@ -1520,6 +1529,43 @@ export function ScreenGroupTreeView({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최상위 그룹일 때 채번 삭제 경고 */}
|
||||
{deletingGroup && !deletingGroup.parent_group_id && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border-2 border-destructive bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-6 w-6 shrink-0 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
최상위 그룹 삭제 - 채번 규칙 경고
|
||||
</p>
|
||||
<p className="text-xs text-destructive/90 leading-relaxed">
|
||||
이 그룹은 최상위 그룹입니다.
|
||||
아래 체크박스를 선택하면 해당 회사의 <span className="font-bold underline">모든 채번 규칙과 채번 파트가 영구적으로 삭제</span>됩니다.
|
||||
삭제된 채번 데이터는 복구할 수 없으며, 채번이 필요한 모든 기능이 중단됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteNumberingRules"
|
||||
checked={deleteNumberingRules}
|
||||
onChange={(e) => setDeleteNumberingRules(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-destructive text-destructive focus:ring-destructive"
|
||||
/>
|
||||
<label
|
||||
htmlFor="deleteNumberingRules"
|
||||
className="cursor-pointer text-sm font-semibold text-destructive"
|
||||
>
|
||||
채번 규칙도 함께 삭제 (위험)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 오버레이 */}
|
||||
{isDeleting && (
|
||||
@@ -1551,7 +1597,7 @@ export function ScreenGroupTreeView({
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // 자동 닫힘 방지
|
||||
e.preventDefault();
|
||||
confirmDeleteGroup();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
setLoadingGroups(true);
|
||||
const response = await getScreenGroups();
|
||||
const response = await getScreenGroups({ excludePop: true });
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
createCategoryValue,
|
||||
updateCategoryValue,
|
||||
deleteCategoryValue,
|
||||
checkCanDeleteCategoryValue,
|
||||
CreateCategoryValueInput,
|
||||
} from "@/lib/api/categoryTree";
|
||||
import {
|
||||
@@ -310,53 +311,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
// 하위 항목 개수만 계산 (자기 자신 제외)
|
||||
const countAllDescendants = useCallback(
|
||||
(node: CategoryValue): number => {
|
||||
if (!node.children || node.children.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return countAllValues(node.children);
|
||||
},
|
||||
[countAllValues],
|
||||
);
|
||||
|
||||
// 노드와 모든 하위 항목의 ID 수집
|
||||
const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => {
|
||||
const ids: number[] = [node.valueId];
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
ids.push(...collectNodeAndDescendantIds(child));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}, []);
|
||||
|
||||
// 트리에서 valueId로 노드 찾기
|
||||
const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.valueId === valueId) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeById(node.children, valueId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함)
|
||||
const totalDeleteCount = useMemo(() => {
|
||||
const allIds = new Set<number>();
|
||||
checkedIds.forEach((id) => {
|
||||
const node = findNodeById(tree, id);
|
||||
if (node) {
|
||||
collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId));
|
||||
}
|
||||
});
|
||||
return allIds.size;
|
||||
}, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]);
|
||||
|
||||
// 활성 노드만 필터링
|
||||
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
|
||||
@@ -504,8 +458,20 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 다이얼로그 열기
|
||||
const handleOpenDeleteDialog = (value: CategoryValue) => {
|
||||
// 삭제 다이얼로그 열기 (사전 확인 후)
|
||||
const handleOpenDeleteDialog = async (value: CategoryValue) => {
|
||||
try {
|
||||
const response = await checkCanDeleteCategoryValue(value.valueId);
|
||||
if (response.success && response.data) {
|
||||
if (!response.data.canDelete) {
|
||||
toast.error(response.data.reason || "이 카테고리는 삭제할 수 없습니다");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 사전 확인 실패 시에도 다이얼로그는 열어줌 (삭제 시 백엔드에서 재검증)
|
||||
}
|
||||
|
||||
setDeletingValue(value);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
@@ -616,8 +582,8 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
try {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const failMessages: string[] = [];
|
||||
|
||||
// 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제)
|
||||
for (const valueId of Array.from(checkedIds)) {
|
||||
try {
|
||||
const response = await deleteCategoryValue(valueId);
|
||||
@@ -625,6 +591,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
if (response.error) failMessages.push(response.error);
|
||||
}
|
||||
} catch {
|
||||
failCount++;
|
||||
@@ -634,12 +601,14 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setCheckedIds(new Set());
|
||||
setSelectedValue(null);
|
||||
loadTree(true); // 기존 펼침 상태 유지
|
||||
loadTree(true);
|
||||
|
||||
if (failCount === 0) {
|
||||
toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`);
|
||||
toast.success(`${successCount}개 카테고리가 삭제되었습니다`);
|
||||
} else if (successCount === 0) {
|
||||
toast.error(`삭제할 수 없습니다: ${failMessages[0] || "삭제 실패"}`);
|
||||
} else {
|
||||
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`);
|
||||
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패 (사용 중이거나 하위 항목 존재)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 일괄 삭제 오류:", error);
|
||||
@@ -889,14 +858,8 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<strong>{deletingValue?.valueLabel}</strong>을(를) 삭제하시겠습니까?
|
||||
{deletingValue && countAllDescendants(deletingValue) > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
하위 카테고리 {countAllDescendants(deletingValue)}개도 모두 함께 삭제됩니다.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -918,12 +881,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
<AlertDialogTitle>카테고리 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 <strong>{checkedIds.size}개</strong> 카테고리를 삭제하시겠습니까?
|
||||
{totalDeleteCount > checkedIds.size && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive">하위 카테고리 포함 총 {totalDeleteCount}개가 삭제됩니다.</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
||||
</AlertDialogDescription>
|
||||
@@ -934,7 +891,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||
onClick={handleBulkDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{totalDeleteCount}개 삭제
|
||||
{checkedIds.size}개 삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -89,6 +89,67 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
const onDataChangeRef = useRef(onDataChange);
|
||||
onDataChangeRef.current = onDataChange;
|
||||
|
||||
// Entity 조인 설정을 ref로 보관 (이벤트 핸들러 closure에서 항상 최신값 참조)
|
||||
const entityJoinsRef = useRef(config.entityJoins);
|
||||
useEffect(() => {
|
||||
entityJoinsRef.current = config.entityJoins;
|
||||
}, [config.entityJoins]);
|
||||
|
||||
// Entity 조인 해석: FK 값을 기반으로 참조 테이블에서 표시 데이터를 가져와 행에 채움
|
||||
const resolveEntityJoins = useCallback(async (rows: any[]): Promise<any[]> => {
|
||||
const entityJoins = entityJoinsRef.current;
|
||||
console.log("🔍 [V2Repeater] resolveEntityJoins 시작:", {
|
||||
entityJoins,
|
||||
rowCount: rows.length,
|
||||
sampleRow: rows[0],
|
||||
});
|
||||
|
||||
if (!entityJoins || entityJoins.length === 0) {
|
||||
console.warn("⚠️ [V2Repeater] entityJoins 설정 없음 - 해석 스킵");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const resolvedRows = rows.map((r) => ({ ...r }));
|
||||
|
||||
for (const join of entityJoins) {
|
||||
const fkValues = [...new Set(resolvedRows.map((r) => r[join.sourceColumn]).filter(Boolean))];
|
||||
console.log(`🔍 [V2Repeater] FK 값 추출: ${join.sourceColumn} → [${fkValues.join(", ")}]`);
|
||||
if (fkValues.length === 0) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${join.referenceTable}/data`, {
|
||||
page: 1,
|
||||
size: fkValues.length + 10,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "id", operator: "in", value: fkValues }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
console.log(`🔍 [V2Repeater] API 응답:`, response.data);
|
||||
const refData = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
const lookupMap = new Map(refData.map((r: any) => [String(r.id), r]));
|
||||
|
||||
resolvedRows.forEach((row) => {
|
||||
const fkVal = String(row[join.sourceColumn] || "");
|
||||
const refRecord = lookupMap.get(fkVal);
|
||||
if (refRecord) {
|
||||
join.columns.forEach((col) => {
|
||||
row[col.displayField] = refRecord[col.referenceField];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ [V2Repeater] Entity 조인 해석 완료: ${join.referenceTable} (${fkValues.length}건, 조회결과: ${refData.length}건)`);
|
||||
} catch (error) {
|
||||
console.error(`❌ [V2Repeater] Entity 조인 해석 실패: ${join.referenceTable}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedRows;
|
||||
}, []);
|
||||
|
||||
const handleReceiveData = useCallback(
|
||||
async (incomingData: any[], configOrMode?: any) => {
|
||||
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
|
||||
@@ -98,6 +159,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// mappingRules 처리: configOrMode에 mappingRules가 있으면 적용
|
||||
const mappingRules = configOrMode?.mappingRules;
|
||||
|
||||
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
|
||||
const metaFieldsToStrip = new Set([
|
||||
"id",
|
||||
@@ -107,12 +171,33 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
"updated_by",
|
||||
"company_code",
|
||||
]);
|
||||
const normalizedData = incomingData.map((item: any) => {
|
||||
let normalizedData = incomingData.map((item: any, index: number) => {
|
||||
let raw = item;
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
raw = { ...originalData, ...additionalFields };
|
||||
}
|
||||
|
||||
// mappingRules가 있으면 규칙에 따라 매핑 (필요한 필드만 추출)
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
const mapped: Record<string, any> = { _id: `receive_${Date.now()}_${index}` };
|
||||
for (const rule of mappingRules) {
|
||||
mapped[rule.targetField] = raw[rule.sourceField];
|
||||
}
|
||||
// additionalSources에서 추가된 필드도 유지 (mappingRules에 없는 필드 중 메타가 아닌 것)
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key) && !(key in mapped) && !key.startsWith("_")) {
|
||||
// 소스 테이블의 컬럼이 아닌 추가 데이터만 유지 (additionalSources 등)
|
||||
const isMappingSource = mappingRules.some((r: any) => r.sourceField === key);
|
||||
if (!isMappingSource) {
|
||||
mapped[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// mappingRules 없으면 기존 로직: 메타 필드만 제거
|
||||
const cleaned: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key)) {
|
||||
@@ -122,10 +207,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
return cleaned;
|
||||
});
|
||||
|
||||
console.log("📥 [V2Repeater] 매핑 후 데이터:", normalizedData);
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
normalizedData = await resolveEntityJoins(normalizedData);
|
||||
|
||||
console.log("📥 [V2Repeater] Entity 조인 후 데이터:", normalizedData);
|
||||
|
||||
const mode = configOrMode?.mode || configOrMode || "append";
|
||||
|
||||
// 카테고리 코드 → 라벨 변환
|
||||
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
|
||||
const codesToResolve = new Set<string>();
|
||||
for (const item of normalizedData) {
|
||||
for (const [key, val] of Object.entries(item)) {
|
||||
@@ -167,7 +258,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
|
||||
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
|
||||
},
|
||||
[],
|
||||
[resolveEntityJoins],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1412,32 +1503,31 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
let mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
// 매핑 규칙이 있으면 적용
|
||||
mappingRules.forEach((rule: any) => {
|
||||
newRow[rule.targetField] = item[rule.sourceField];
|
||||
});
|
||||
} else {
|
||||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
mappedData = await resolveEntityJoins(mappedData);
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
} else if (mode === "merge") {
|
||||
// 중복 제거 후 병합 (id 기준)
|
||||
const existingIds = new Set(data.map((row) => row.id || row._id));
|
||||
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
|
||||
handleDataChange([...data, ...newItems]);
|
||||
} else {
|
||||
// 기본: append
|
||||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
@@ -1447,12 +1537,21 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
const customEvent = event as CustomEvent;
|
||||
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
|
||||
|
||||
console.log("📨 [V2Repeater] splitPanelDataTransfer 수신:", {
|
||||
dataCount: transferData?.length,
|
||||
mappingRules,
|
||||
mode,
|
||||
sourcePosition,
|
||||
sampleSourceData: transferData?.[0],
|
||||
entityJoinsConfig: entityJoinsRef.current,
|
||||
});
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
let mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
@@ -1466,6 +1565,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||
return newRow;
|
||||
});
|
||||
|
||||
console.log("📨 [V2Repeater] 매핑 후 데이터:", mappedData);
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
mappedData = await resolveEntityJoins(mappedData);
|
||||
|
||||
console.log("📨 [V2Repeater] Entity 조인 후 데이터:", mappedData);
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
||||
@@ -48,12 +48,14 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
V2RepeaterConfig,
|
||||
RepeaterColumnConfig,
|
||||
RepeaterEntityJoin,
|
||||
DEFAULT_REPEATER_CONFIG,
|
||||
RENDER_MODE_OPTIONS,
|
||||
MODAL_SIZE_OPTIONS,
|
||||
@@ -151,6 +153,28 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태
|
||||
|
||||
// Entity 조인 관련 상태
|
||||
const [entityJoinData, setEntityJoinData] = useState<{
|
||||
joinTables: Array<{
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: { sourceColumn?: string };
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>;
|
||||
}>;
|
||||
availableColumns: Array<{
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
joinAlias: string;
|
||||
}>;
|
||||
}>({ joinTables: [], availableColumns: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 🆕 확장된 컬럼 (상세 설정 표시용)
|
||||
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
|
||||
|
||||
@@ -316,6 +340,89 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
loadRelatedTables();
|
||||
}, [currentTableName, config.mainTableName]);
|
||||
|
||||
// Entity 조인 컬럼 정보 로드 (저장 테이블 기준)
|
||||
const entityJoinTargetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: currentTableName;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!entityJoinTargetTable) return;
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
|
||||
setEntityJoinData({
|
||||
joinTables: result.joinTables || [],
|
||||
availableColumns: result.availableColumns || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinData({ joinTables: [], availableColumns: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
fetchEntityJoinColumns();
|
||||
}, [entityJoinTargetTable]);
|
||||
|
||||
// Entity 조인 컬럼 토글 (추가/제거)
|
||||
const toggleEntityJoinColumn = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
||||
const currentJoins = config.entityJoins || [];
|
||||
const existingJoinIdx = currentJoins.findIndex(
|
||||
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
|
||||
);
|
||||
|
||||
if (existingJoinIdx >= 0) {
|
||||
const existingJoin = currentJoins[existingJoinIdx];
|
||||
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
|
||||
|
||||
if (existingColIdx >= 0) {
|
||||
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
|
||||
if (updatedColumns.length === 0) {
|
||||
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
|
||||
updateConfig({ entityJoins: updated });
|
||||
}
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = {
|
||||
...existingJoin,
|
||||
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
|
||||
};
|
||||
updateConfig({ entityJoins: updated });
|
||||
}
|
||||
} else {
|
||||
updateConfig({
|
||||
entityJoins: [
|
||||
...currentJoins,
|
||||
{
|
||||
sourceColumn,
|
||||
referenceTable: joinTableName,
|
||||
columns: [{ referenceField: refColumnName, displayField }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[config.entityJoins, updateConfig],
|
||||
);
|
||||
|
||||
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
|
||||
const isEntityJoinColumnActive = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string) => {
|
||||
return (config.entityJoins || []).some(
|
||||
(j) =>
|
||||
j.sourceColumn === sourceColumn &&
|
||||
j.referenceTable === joinTableName &&
|
||||
j.columns.some((c) => c.referenceField === refColumnName),
|
||||
);
|
||||
},
|
||||
[config.entityJoins],
|
||||
);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<V2RepeaterConfig>) => {
|
||||
@@ -654,9 +761,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
||||
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
||||
<TabsTrigger value="entityJoin" className="text-xs">Entity 조인</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
@@ -1704,6 +1812,120 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Entity 조인 설정 탭 */}
|
||||
<TabsContent value="entityJoin" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Entity 조인 연결</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{loadingEntityJoins ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">로딩 중...</p>
|
||||
) : entityJoinData.joinTables.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{entityJoinTargetTable
|
||||
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
|
||||
: "저장 테이블을 먼저 설정해주세요"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
|
||||
|
||||
return (
|
||||
<div key={tableIndex} className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<span className="text-muted-foreground">({sourceColumn})</span>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
||||
{joinTable.availableColumns.map((column, colIndex) => {
|
||||
const isActive = isEntityJoinColumnActive(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
);
|
||||
const matchingCol = config.columns.find((c) => c.key === column.columnName);
|
||||
const displayField = matchingCol?.key || column.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
||||
isActive && "bg-blue-100",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleEntityJoinColumn(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
column.columnLabel,
|
||||
displayField,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-blue-400">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 설정된 Entity 조인 목록 */}
|
||||
{config.entityJoins && config.entityJoins.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">설정된 조인</h4>
|
||||
<div className="space-y-1">
|
||||
{config.entityJoins.map((join, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
|
||||
<Database className="h-3 w-3 text-blue-500" />
|
||||
<span className="font-medium">{join.sourceColumn}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{join.referenceTable}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({join.columns.map((c) => c.referenceField).join(", ")})
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateConfig({
|
||||
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
|
||||
});
|
||||
}}
|
||||
className="ml-auto h-4 w-4 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user