Merge pull request 'feature/screen-management' (#169) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/169
This commit is contained in:
kjs 2025-10-30 14:39:45 +09:00
commit 58e1aec262
27 changed files with 3482 additions and 568 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,837 @@
"use client";
import { useState } from "react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { CustomCalendar } from "@/components/ui/custom-calendar";
import { ExampleFormDialog } from "@/components/examples/ExampleFormDialog";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
AlertCircle,
Check,
ChevronDown,
Info,
Loader2,
MoreHorizontal,
Plus,
Search,
Trash2,
User,
} from "lucide-react";
import { toast } from "sonner";
export default function UIComponentsDemoPage() {
const [date, setDate] = useState<Date | undefined>(new Date());
const [progress, setProgress] = useState(45);
const [switchOn, setSwitchOn] = useState(false);
const [checkboxChecked, setCheckboxChecked] = useState(false);
const [sliderValue, setSliderValue] = useState([50]);
const [radioValue, setRadioValue] = useState("option1");
return (
<div className="bg-background min-h-screen p-8">
<div className="mx-auto max-w-7xl space-y-8">
{/* 헤더 */}
<div className="space-y-4">
<div className="space-y-2">
<h1 className="text-4xl font-bold">shadcn/ui </h1>
<p className="text-muted-foreground text-lg"> UI </p>
</div>
{/* 실전 예시 폼 */}
<Card className="bg-primary/5 border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-primary">🎯</span>
예시: 완전한
</CardTitle>
<CardDescription>
shadcn/ui . , ,
.
</CardDescription>
</CardHeader>
<CardContent>
<ExampleFormDialog />
</CardContent>
</Card>
</div>
{/* 버튼 섹션 */}
<Card>
<CardHeader>
<CardTitle>Button ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button variant="default">Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Button size="lg">Large</Button>
<Button size="default">Default</Button>
<Button size="sm">Small</Button>
<Button size="icon">
<Plus className="h-4 w-4" />
</Button>
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Button disabled>Disabled</Button>
<Button>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
With Icon
</Button>
</div>
</CardContent>
</Card>
{/* Badge 섹션 */}
<Card>
<CardHeader>
<CardTitle>Badge ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
</div>
</CardContent>
</Card>
{/* Alert 섹션 */}
<Card>
<CardHeader>
<CardTitle>Alert ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle> </AlertTitle>
<AlertDescription>
. .
</AlertDescription>
</Alert>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle> </AlertTitle>
<AlertDescription> . .</AlertDescription>
</Alert>
</CardContent>
</Card>
{/* Input & Form 섹션 */}
<Card>
<CardHeader>
<CardTitle>Input & Form ( )</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Text Input */}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" type="email" placeholder="example@email.com" />
</div>
{/* Textarea */}
<div className="space-y-2">
<Label htmlFor="message"></Label>
<Textarea id="message" placeholder="메시지를 입력하세요..." rows={4} />
</div>
{/* Select */}
<div className="space-y-2">
<Label></Label>
<Select>
<SelectTrigger className="w-full">
<SelectValue placeholder="옵션을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1"> 1</SelectItem>
<SelectItem value="option2"> 2</SelectItem>
<SelectItem value="option3"> 3</SelectItem>
</SelectContent>
</Select>
</div>
{/* Checkbox */}
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={checkboxChecked}
onCheckedChange={(checked) => setCheckboxChecked(checked as boolean)}
/>
<Label htmlFor="terms" className="cursor-pointer">
</Label>
</div>
{/* Radio Group */}
<div className="space-y-2">
<Label> </Label>
<RadioGroup value={radioValue} onValueChange={setRadioValue}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option1" id="r1" />
<Label htmlFor="r1" className="cursor-pointer">
1
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option2" id="r2" />
<Label htmlFor="r2" className="cursor-pointer">
2
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="option3" id="r3" />
<Label htmlFor="r3" className="cursor-pointer">
3
</Label>
</div>
</RadioGroup>
</div>
{/* Switch */}
<div className="flex items-center space-x-2">
<Switch id="airplane" checked={switchOn} onCheckedChange={setSwitchOn} />
<Label htmlFor="airplane" className="cursor-pointer">
{switchOn ? "켜짐" : "꺼짐"}
</Label>
</div>
{/* Slider */}
<div className="space-y-2">
<Label> (: {sliderValue[0]})</Label>
<Slider value={sliderValue} onValueChange={setSliderValue} max={100} step={1} />
</div>
</CardContent>
</Card>
{/* Dialog & Modal 섹션 */}
<Card>
<CardHeader>
<CardTitle>Dialog & Modal ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{/* Dialog */}
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Dialog </Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input id="name" placeholder="이름을 입력하세요" />
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Alert Dialog */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">Alert Dialog </Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> ?</AlertDialogTitle>
<AlertDialogDescription> . ?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
{/* Dropdown & Popover 섹션 */}
<Card>
<CardHeader>
<CardTitle>Dropdown & Popover ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{/* Dropdown Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuItem></DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive"></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Popover */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline"> </Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h4 className="leading-none font-medium"> </h4>
<p className="text-muted-foreground text-sm"> .</p>
</div>
</PopoverContent>
</Popover>
</div>
</CardContent>
</Card>
{/* Command 섹션 */}
<Card>
<CardHeader>
<CardTitle>Command ( )</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<Command className="rounded-lg border shadow-md">
<CommandInput placeholder="명령어 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup heading="제안">
<CommandItem>
<Search className="mr-2 h-4 w-4" />
<span></span>
</CommandItem>
<CommandItem>
<User className="mr-2 h-4 w-4" />
<span></span>
</CommandItem>
<CommandItem>
<Plus className="mr-2 h-4 w-4" />
<span> </span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</CardContent>
</Card>
{/* Tabs 섹션 */}
<Card>
<CardHeader>
<CardTitle>Tabs ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="tab1" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="tab1"> 1</TabsTrigger>
<TabsTrigger value="tab2"> 2</TabsTrigger>
<TabsTrigger value="tab3"> 3</TabsTrigger>
</TabsList>
<TabsContent value="tab1" className="space-y-4">
<p className="text-muted-foreground text-sm"> .</p>
</TabsContent>
<TabsContent value="tab2" className="space-y-4">
<p className="text-muted-foreground text-sm"> .</p>
</TabsContent>
<TabsContent value="tab3" className="space-y-4">
<p className="text-muted-foreground text-sm"> .</p>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Accordion 섹션 */}
<Card>
<CardHeader>
<CardTitle>Accordion ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger> </AccordionTrigger>
<AccordionContent> . .</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger> </AccordionTrigger>
<AccordionContent> . .</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger> </AccordionTrigger>
<AccordionContent> . .</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
{/* Collapsible 섹션 */}
<Card>
<CardHeader>
<CardTitle>Collapsible ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between">
<span> </span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 space-y-2">
<div className="rounded-md border px-4 py-2 text-sm"> 1</div>
<div className="rounded-md border px-4 py-2 text-sm"> 2</div>
<div className="rounded-md border px-4 py-2 text-sm"> 3</div>
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
{/* Progress 섹션 */}
<Card>
<CardHeader>
<CardTitle>Progress ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"> ...</span>
<span className="font-medium">{progress}%</span>
</div>
<Progress value={progress} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => setProgress(Math.max(0, progress - 10))}>
-10%
</Button>
<Button size="sm" onClick={() => setProgress(Math.min(100, progress + 10))}>
+10%
</Button>
</div>
</CardContent>
</Card>
{/* Avatar 섹션 */}
<Card>
<CardHeader>
<CardTitle>Avatar ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<Avatar>
<AvatarImage src="https://github.com/shadcn.png" />
<AvatarFallback>CN</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>KJ</AvatarFallback>
</Avatar>
<Avatar>
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</div>
</CardContent>
</Card>
{/* Table 섹션 */}
<Card>
<CardHeader>
<CardTitle>Table ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableCaption> </TableCaption>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">001</TableCell>
<TableCell></TableCell>
<TableCell>
<Badge></Badge>
</TableCell>
<TableCell className="text-right">100,000</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">002</TableCell>
<TableCell></TableCell>
<TableCell>
<Badge variant="secondary"></Badge>
</TableCell>
<TableCell className="text-right">250,000</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">003</TableCell>
<TableCell></TableCell>
<TableCell>
<Badge variant="destructive"></Badge>
</TableCell>
<TableCell className="text-right">50,000</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
{/* ScrollArea 섹션 */}
<Card>
<CardHeader>
<CardTitle>Scroll Area ( )</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-72 w-full rounded-md border p-4">
<div className="space-y-2">
{Array.from({ length: 50 }).map((_, i) => (
<div key={i} className="text-muted-foreground text-sm">
{i + 1}
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Calendar & Date Picker 섹션 */}
<Card>
<CardHeader>
<CardTitle>Calendar & Date Picker ( )</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Date Picker (권장 방식 - Custom Calendar 사용) */}
<div className="space-y-2">
<Label>Date Picker (Popover - )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
{date ? (
date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
})
) : (
<span className="text-muted-foreground"> </span>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="ml-auto h-4 w-4 opacity-50"
>
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</svg>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CustomCalendar mode="single" selected={date} onSelect={setDate} />
</PopoverContent>
</Popover>
{date && <p className="text-muted-foreground text-xs"> : {date.toISOString().split("T")[0]}</p>}
</div>
<Separator />
{/* 커스텀 Calendar (shadcn/ui 스타일) */}
<div className="space-y-2">
<Label>Custom Calendar ( - shadcn/ui )</Label>
<CustomCalendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded-md border shadow-sm"
/>
</div>
<Separator />
{/* 기존 react-day-picker Calendar */}
<div className="space-y-2">
<Label>React-Day-Picker Calendar ()</Label>
<Calendar mode="single" selected={date} onSelect={setDate} className="rounded-md border shadow-sm" />
</div>
</CardContent>
</Card>
{/* Separator 섹션 */}
<Card>
<CardHeader>
<CardTitle>Separator ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm"> </p>
<Separator className="my-4" />
<p className="text-sm"> </p>
</div>
<div className="flex h-20 items-center space-x-4">
<div className="text-sm"></div>
<Separator orientation="vertical" />
<div className="text-sm"></div>
</div>
</CardContent>
</Card>
{/* Toast 버튼 섹션 */}
<Card>
<CardHeader>
<CardTitle>Toast ( )</CardTitle>
<CardDescription>Sonner ( )</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.success("성공", {
description: "작업이 성공적으로 완료되었습니다.",
})
}
>
</Button>
<Button
variant="destructive"
onClick={() =>
toast.error("오류", {
description: "작업 중 오류가 발생했습니다.",
})
}
>
</Button>
<Button
variant="outline"
onClick={() =>
toast("알림", {
description: "일반 알림 메시지입니다.",
})
}
>
</Button>
<Button
variant="secondary"
onClick={() => {
const promise = new Promise((resolve) => setTimeout(resolve, 2000));
toast.promise(promise, {
loading: "처리 중...",
success: "완료!",
error: "실패",
});
}}
>
Promise
</Button>
</div>
</CardContent>
</Card>
{/* Lucide Icons 섹션 */}
<Card>
<CardHeader>
<CardTitle>Lucide Icons ()</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-6 gap-4 sm:grid-cols-8 md:grid-cols-12">
{[
{ Icon: Plus, name: "Plus" },
{ Icon: Trash2, name: "Trash2" },
{ Icon: Search, name: "Search" },
{ Icon: User, name: "User" },
{ Icon: Check, name: "Check" },
{ Icon: ChevronDown, name: "ChevronDown" },
{ Icon: AlertCircle, name: "AlertCircle" },
{ Icon: Info, name: "Info" },
{ Icon: Loader2, name: "Loader2" },
{ Icon: MoreHorizontal, name: "MoreHorizontal" },
].map(({ Icon, name }) => (
<div
key={name}
className="hover:bg-accent flex flex-col items-center gap-2 rounded-lg border p-3"
title={name}
>
<Icon className="h-6 w-6" />
<span className="text-muted-foreground text-[10px]">{name}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* 컬러 팔레트 */}
<Card>
<CardHeader>
<CardTitle>Color Palette ( )</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="space-y-2">
<div className="bg-primary h-20 rounded-lg" />
<p className="text-xs font-medium">Primary</p>
<code className="text-muted-foreground text-[10px]">bg-primary</code>
</div>
<div className="space-y-2">
<div className="bg-secondary h-20 rounded-lg" />
<p className="text-xs font-medium">Secondary</p>
<code className="text-muted-foreground text-[10px]">bg-secondary</code>
</div>
<div className="space-y-2">
<div className="bg-destructive h-20 rounded-lg" />
<p className="text-xs font-medium">Destructive</p>
<code className="text-muted-foreground text-[10px]">bg-destructive</code>
</div>
<div className="space-y-2">
<div className="bg-muted h-20 rounded-lg" />
<p className="text-xs font-medium">Muted</p>
<code className="text-muted-foreground text-[10px]">bg-muted</code>
</div>
<div className="space-y-2">
<div className="bg-accent h-20 rounded-lg" />
<p className="text-xs font-medium">Accent</p>
<code className="text-muted-foreground text-[10px]">bg-accent</code>
</div>
<div className="space-y-2">
<div className="bg-card h-20 rounded-lg border" />
<p className="text-xs font-medium">Card</p>
<code className="text-muted-foreground text-[10px]">bg-card</code>
</div>
<div className="space-y-2">
<div className="bg-popover h-20 rounded-lg border" />
<p className="text-xs font-medium">Popover</p>
<code className="text-muted-foreground text-[10px]">bg-popover</code>
</div>
<div className="space-y-2">
<div className="bg-background h-20 rounded-lg border" />
<p className="text-xs font-medium">Background</p>
<code className="text-muted-foreground text-[10px]">bg-background</code>
</div>
</div>
</CardContent>
</Card>
{/* 푸터 */}
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground text-center text-sm">
{" "}
<a
href="https://ui.shadcn.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground underline"
>
shadcn/ui
</a>
.
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -63,9 +63,7 @@ export default function ScreenViewPage() {
useEffect(() => {
const initComponents = async () => {
try {
console.log("🚀 할당된 화면에서 컴포넌트 시스템 초기화 시작...");
await initializeComponents();
console.log("✅ 할당된 화면에서 컴포넌트 시스템 초기화 완료");
} catch (error) {
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
}
@ -159,16 +157,6 @@ export default function ScreenViewPage() {
const scaleY = containerHeight / designHeight;
const newScale = Math.min(scaleX, scaleY);
console.log("📏 캔버스 스케일 계산:", {
designWidth,
designHeight,
containerWidth,
containerHeight,
scaleX,
scaleY,
finalScale: newScale,
});
setScale(newScale);
}
};
@ -285,31 +273,22 @@ export default function ScreenViewPage() {
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
dataCount: selectedData.length,
selectedData,
stepId,
});
setFlowSelectedData(selectedData);
setFlowSelectedStepId(stepId);
console.log("🔍 [page.tsx] 상태 업데이트 완료");
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
console.log("🔄 테이블 새로고침 요청됨");
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]); // 선택 해제
}}
flowRefreshKey={flowRefreshKey}
onFlowRefresh={() => {
console.log("🔄 플로우 새로고침 요청됨");
setFlowRefreshKey((prev) => prev + 1);
setFlowSelectedData([]); // 선택 해제
setFlowSelectedStepId(null);
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
>
@ -353,7 +332,6 @@ export default function ScreenViewPage() {
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>

View File

@ -1,145 +1,158 @@
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */
/* ===== 서명용 손글씨 폰트 ===== */
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
/* ===== Tailwind CSS & Animations ===== */
@import "tailwindcss";
@import "tw-animate-css";
/* ===== Dark Mode Variant ===== */
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
/* ===== Tailwind Theme Extensions ===== */
@theme {
/* Color System - HSL Format (shadcn/ui Standard) */
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
/* Chart Colors */
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
/* Sidebar Colors */
--color-sidebar: hsl(var(--sidebar-background));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
--color-sidebar-primary: hsl(var(--sidebar-primary));
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
--color-sidebar-accent: hsl(var(--sidebar-accent));
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
--color-sidebar-border: hsl(var(--sidebar-border));
--color-sidebar-ring: hsl(var(--sidebar-ring));
/* Font Families */
--font-sans: var(--font-inter);
--font-mono: var(--font-jetbrains-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-success: var(--success);
--color-warning: var(--warning);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
/* ===== CSS Variables (shadcn/ui Official) ===== */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--success: oklch(0.647 0.176 142.5);
--warning: oklch(0.808 0.171 85.6);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* Light Theme Colors - HSL Format */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
/* Z-Index 계층 구조 */
--z-background: 1;
--z-layout: 10;
--z-content: 50;
--z-floating: 100;
--z-modal: 1000;
--z-tooltip: 2000;
--z-critical: 3000;
/* Chart Colors */
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
/* Border Radius */
--radius: 0.5rem;
/* Sidebar Colors */
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
/* ===== Dark Theme ===== */
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--success: oklch(0.697 0.17 142.5);
--warning: oklch(0.808 0.171 85.6);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Chart Colors - Dark */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* Sidebar Colors - Dark */
--sidebar-background: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217.2 91.2% 59.8%;
--sidebar-primary-foreground: 222.2 47.4% 11.2%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
/* ===== Base Styles ===== */
* {
border-color: hsl(var(--border));
}
/* Dialog 오버레이 커스터마이징 - 어두운 배경 */
body {
color: hsl(var(--foreground));
background: hsl(var(--background));
}
/* ===== Dialog/Modal Overlay ===== */
/* Radix UI Dialog Overlay - 60% 불투명도 배경 */
[data-radix-dialog-overlay],
.fixed.inset-0.z-50.bg-black {
background-color: rgba(0, 0, 0, 0.6) !important;
@ -151,3 +164,150 @@
background-color: rgba(0, 0, 0, 0.6) !important;
backdrop-filter: none !important;
}
/* ===== Accessibility - Focus Styles ===== */
/* 모든 인터랙티브 요소에 대한 포커스 스타일 */
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* ===== Scrollbar Styles (Optional) ===== */
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--muted));
}
/* ===== Animation Utilities ===== */
/* Smooth transitions for interactive elements */
button,
a,
input,
textarea,
select {
transition-property:
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter,
backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
/* Disable animations for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ===== Print Styles ===== */
@media print {
* {
background: transparent !important;
color: black !important;
box-shadow: none !important;
text-shadow: none !important;
}
a,
a:visited {
text-decoration: underline;
}
a[href]::after {
content: " (" attr(href) ")";
}
img {
max-width: 100% !important;
}
@page {
margin: 2cm;
}
p,
h2,
h3 {
orphans: 3;
widows: 3;
}
h2,
h3 {
page-break-after: avoid;
}
}
/* ===== Custom Utilities (Project-Specific) ===== */
/* 손글씨 폰트 클래스 */
.font-handwriting {
font-family: "Allura", cursive;
}
.font-dancing-script {
font-family: "Dancing Script", cursive;
}
.font-great-vibes {
font-family: "Great Vibes", cursive;
}
.font-pacifico {
font-family: "Pacifico", cursive;
}
.font-satisfy {
font-family: "Satisfy", cursive;
}
.font-caveat {
font-family: "Caveat", cursive;
}
/* 한글 손글씨 폰트 */
.font-nanum-brush {
font-family: "Nanum Brush Script", cursive;
}
.font-nanum-pen {
font-family: "Nanum Pen Script", cursive;
}
.font-gaegu {
font-family: "Gaegu", cursive;
}
/* ===== Component-Specific Overrides ===== */
/* 필요시 특정 컴포넌트에 대한 스타일 오버라이드를 여기에 추가 */
/* 예: Calendar, Table 등의 미세 조정 */
/* ===== End of Global Styles ===== */

View File

@ -44,7 +44,7 @@ export default function RootLayout({
<div id="root" className="h-full">
<QueryProvider>
<RegistryProvider>{children}</RegistryProvider>
<Toaster position="top-right" richColors />
<Toaster position="top-right" />
<ScreenModal />
</QueryProvider>
{/* Portal 컨테이너 */}

View File

@ -11,6 +11,7 @@ interface ScreenModalState {
isOpen: boolean;
screenId: number | null;
title: string;
description?: string;
size: "sm" | "md" | "lg" | "xl";
}
@ -23,6 +24,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
isOpen: false,
screenId: null,
title: "",
description: "",
size: "md",
});
@ -93,21 +95,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, size } = event.detail;
const { screenId, title, description, size } = event.detail;
setModalState({
isOpen: true,
screenId,
title,
description: description || "",
size,
});
};
const handleCloseModal = () => {
console.log("🚪 ScreenModal 닫기 이벤트 수신");
setModalState({
isOpen: false,
screenId: null,
title: "",
description: "",
size: "md",
});
setScreenData(null);
@ -215,6 +218,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
{modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}

View File

@ -0,0 +1,421 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { CustomCalendar } from "@/components/ui/custom-calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { AlertCircle, CheckCircle, Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface FormData {
name: string;
email: string;
phone: string;
category: string;
priority: string;
startDate?: Date;
endDate?: Date;
description: string;
isActive: boolean;
}
interface FormErrors {
name?: string;
email?: string;
phone?: string;
category?: string;
startDate?: string;
}
export function ExampleFormDialog() {
const [isOpen, setIsOpen] = useState(false);
const [formData, setFormData] = useState<FormData>({
name: "",
email: "",
phone: "",
category: "",
priority: "medium",
description: "",
isActive: true,
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 폼 유효성 검사
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
// 이름 검증
if (!formData.name.trim()) {
newErrors.name = "이름을 입력해주세요";
} else if (formData.name.length < 2) {
newErrors.name = "이름은 2자 이상이어야 합니다";
}
// 이메일 검증
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email.trim()) {
newErrors.email = "이메일을 입력해주세요";
} else if (!emailRegex.test(formData.email)) {
newErrors.email = "올바른 이메일 형식이 아닙니다";
}
// 전화번호 검증
const phoneRegex = /^[0-9-]+$/;
if (formData.phone && !phoneRegex.test(formData.phone)) {
newErrors.phone = "올바른 전화번호 형식이 아닙니다";
}
// 카테고리 검증
if (!formData.category) {
newErrors.category = "카테고리를 선택해주세요";
}
// 날짜 검증
if (formData.startDate && formData.endDate) {
if (formData.startDate > formData.endDate) {
newErrors.startDate = "시작일은 종료일보다 이전이어야 합니다";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// 폼 제출
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
setIsSubmitting(true);
// API 호출 시뮬레이션 (실제로는 API 호출)
await new Promise((resolve) => setTimeout(resolve, 1500));
console.log("폼 데이터:", formData);
setIsSubmitting(false);
setIsOpen(false);
// 폼 초기화
setFormData({
name: "",
email: "",
phone: "",
category: "",
priority: "medium",
description: "",
isActive: true,
});
setErrors({});
};
// 폼 필드 변경
const handleChange = (field: keyof FormData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// 해당 필드의 에러 제거
if (errors[field as keyof FormErrors]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field as keyof FormErrors];
return newErrors;
});
}
};
// 취소
const handleCancel = () => {
setIsOpen(false);
setFormData({
name: "",
email: "",
phone: "",
category: "",
priority: "medium",
description: "",
isActive: true,
});
setErrors({});
};
return (
<>
{/* 트리거 버튼 */}
<Button onClick={() => setIsOpen(true)} className="gap-2">
</Button>
{/* 폼 Dialog */}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. * .
</DialogDescription>
</DialogHeader>
{/* 폼 컨텐츠 */}
<div className="space-y-4">
{/* 이름 (필수) */}
<div className="space-y-2">
<Label htmlFor="name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
placeholder="홍길동"
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.name && "border-destructive")}
/>
{errors.name && (
<p className="text-destructive flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{errors.name}
</p>
)}
</div>
{/* 이메일 (필수) */}
<div className="space-y-2">
<Label htmlFor="email" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
placeholder="example@email.com"
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.email && "border-destructive")}
/>
{errors.email && (
<p className="text-destructive flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{errors.email}
</p>
)}
{!errors.email && formData.email && formData.email.includes("@") && (
<p className="text-muted-foreground flex items-center gap-1 text-xs">
<CheckCircle className="h-3 w-3 text-green-600" />
</p>
)}
</div>
{/* 전화번호 (선택) */}
<div className="space-y-2">
<Label htmlFor="phone" className="text-xs sm:text-sm">
</Label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)}
placeholder="010-1234-5678"
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.phone && "border-destructive")}
/>
{errors.phone && (
<p className="text-destructive flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{errors.phone}
</p>
)}
</div>
{/* 카테고리 & 우선순위 (같은 줄) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* 카테고리 (필수) */}
<div className="space-y-2">
<Label htmlFor="category" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
<SelectTrigger
id="category"
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.category && "border-destructive")}
>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="customer"></SelectItem>
<SelectItem value="partner"></SelectItem>
<SelectItem value="supplier"></SelectItem>
<SelectItem value="employee"></SelectItem>
</SelectContent>
</Select>
{errors.category && (
<p className="text-destructive flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{errors.category}
</p>
)}
</div>
{/* 우선순위 */}
<div className="space-y-2">
<Label htmlFor="priority" className="text-xs sm:text-sm">
</Label>
<Select value={formData.priority} onValueChange={(value) => handleChange("priority", value)}>
<SelectTrigger id="priority" className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="high"></SelectItem>
<SelectItem value="urgent"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 시작일 & 종료일 (같은 줄) */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* 시작일 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-8 w-full justify-start text-left text-xs font-normal sm:h-10 sm:text-sm",
!formData.startDate && "text-muted-foreground",
errors.startDate && "border-destructive",
)}
>
{formData.startDate ? (
formData.startDate.toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
})
) : (
<span> </span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CustomCalendar
mode="single"
selected={formData.startDate}
onSelect={(date) => handleChange("startDate", date)}
/>
</PopoverContent>
</Popover>
{errors.startDate && (
<p className="text-destructive flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
{errors.startDate}
</p>
)}
</div>
{/* 종료일 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-8 w-full justify-start text-left text-xs font-normal sm:h-10 sm:text-sm",
!formData.endDate && "text-muted-foreground",
)}
>
{formData.endDate ? (
formData.endDate.toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
})
) : (
<span> </span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<CustomCalendar
mode="single"
selected={formData.endDate}
onSelect={(date) => handleChange("endDate", date)}
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="추가 정보를 입력하세요..."
className="min-h-[80px] text-xs sm:text-sm"
/>
<p className="text-muted-foreground text-xs">{formData.description.length} / 500</p>
</div>
{/* 활성화 상태 */}
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
<div className="space-y-0.5">
<Label htmlFor="isActive" className="text-xs font-medium sm:text-sm">
</Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => handleChange("isActive", checked)}
/>
</div>
</div>
{/* 푸터 */}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isSubmitting ? "처리 중..." : "등록"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -224,6 +224,17 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
<Label htmlFor="screenName"></Label>
<Input id="screenName" value={screenName} onChange={(e) => setScreenName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="화면 설명을 입력하세요 (모달에 표시됨)"
/>
</div>
<div className="space-y-2">
<Label htmlFor="screenCode"> </Label>
<Input

View File

@ -1,383 +1,363 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { X, Save, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/lib/types/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
interface EditModalProps {
interface EditModalState {
isOpen: boolean;
onClose: () => void;
screenId?: number;
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
editData?: any;
screenId: number | null;
title: string;
description?: string;
modalSize: "sm" | "md" | "lg" | "xl";
editData: Record<string, any>;
onSave?: () => void;
onDataChange?: (formData: Record<string, any>) => void; // 폼 데이터 변경 콜백 추가
modalTitle?: string; // 모달 제목
modalDescription?: string; // 모달 설명
}
/**
*
*
*/
export const EditModal: React.FC<EditModalProps> = ({
isOpen,
onClose,
screenId,
modalSize = "lg",
editData,
onSave,
onDataChange,
modalTitle,
modalDescription,
}) => {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState<any>({});
const [originalData, setOriginalData] = useState<any>({}); // 부분 업데이트용 원본 데이터
const [screenData, setScreenData] = useState<any>(null);
const [components, setComponents] = useState<ComponentData[]>([]);
interface EditModalProps {
className?: string;
}
// 컴포넌트 기반 동적 크기 계산
const calculateModalSize = () => {
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const [modalState, setModalState] = useState<EditModalState>({
isOpen: false,
screenId: null,
title: "",
description: "",
modalSize: "md",
editData: {},
onSave: undefined,
});
const [screenData, setScreenData] = useState<{
components: ComponentData[];
screenInfo: any;
} | null>(null);
const [loading, setLoading] = useState(false);
const [screenDimensions, setScreenDimensions] = useState<{
width: number;
height: number;
offsetX?: number;
offsetY?: number;
} | null>(null);
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {
return { width: 600, height: 400 }; // 기본 크기
return {
width: 400,
height: 300,
offsetX: 0,
offsetY: 0,
};
}
const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백
// 모든 컴포넌트의 경계 찾기
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const maxHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 최소한의 여백만 추가
components.forEach((component) => {
const x = parseFloat(component.position?.x?.toString() || "0");
const y = parseFloat(component.position?.y?.toString() || "0");
const width = parseFloat(component.size?.width?.toString() || "100");
const height = parseFloat(component.size?.height?.toString() || "40");
console.log(`🎯 계산된 모달 크기: ${maxWidth}px x ${maxHeight}px`);
console.log(
"📍 컴포넌트 위치들:",
components.map((c) => ({ x: c.position?.x, y: c.position?.y, w: c.size?.width, h: c.size?.height })),
);
return { width: maxWidth, height: maxHeight };
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
// 실제 컨텐츠 크기 계산
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// 적절한 여백 추가
const paddingX = 40;
const paddingY = 40;
const finalWidth = Math.max(contentWidth + paddingX, 400);
const finalHeight = Math.max(contentHeight + paddingY, 300);
return {
width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 0.9),
offsetX: Math.max(0, minX - paddingX / 2),
offsetY: Math.max(0, minY - paddingY / 2),
};
};
const dynamicSize = calculateModalSize();
// EditModal 전용 닫기 이벤트 리스너
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleCloseEditModal = () => {
console.log("🚪 EditModal: closeEditModal 이벤트 수신");
onClose();
const handleOpenEditModal = (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
console.log("🚀 EditModal 열기 이벤트 수신:", {
screenId,
title,
description,
modalSize,
editData,
});
setModalState({
isOpen: true,
screenId,
title,
description: description || "",
modalSize: modalSize || "lg",
editData: editData || {},
onSave,
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
setOriginalData(editData || {});
};
const handleCloseEditModal = () => {
console.log("🚪 EditModal 닫기 이벤트 수신");
handleClose();
};
window.addEventListener("openEditModal", handleOpenEditModal as EventListener);
window.addEventListener("closeEditModal", handleCloseEditModal);
return () => {
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal);
};
}, [onClose]);
}, []);
// DialogContent 크기 강제 적용
// 화면 데이터 로딩
useEffect(() => {
if (isOpen && dynamicSize) {
// 모달이 렌더링된 후 DOM 직접 조작으로 크기 강제 적용
setTimeout(() => {
const dialogContent = document.querySelector('[role="dialog"] > div');
const modalContent = document.querySelector('[role="dialog"] [class*="overflow-auto"]');
if (dialogContent) {
const targetWidth = dynamicSize.width;
const targetHeight = dynamicSize.height;
console.log(`🔧 DialogContent 크기 강제 적용: ${targetWidth}px x ${targetHeight}px`);
dialogContent.style.width = `${targetWidth}px`;
dialogContent.style.height = `${targetHeight}px`;
dialogContent.style.minWidth = `${targetWidth}px`;
dialogContent.style.minHeight = `${targetHeight}px`;
dialogContent.style.maxWidth = "95vw";
dialogContent.style.maxHeight = "95vh";
dialogContent.style.padding = "0";
}
// 스크롤 완전 제거
if (modalContent) {
modalContent.style.overflow = "hidden";
console.log("🚫 스크롤 완전 비활성화");
}
}, 100); // 100ms 지연으로 렌더링 완료 후 실행
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
}
}, [isOpen, dynamicSize]);
}, [modalState.isOpen, modalState.screenId]);
// 편집 데이터가 변경되면 폼 데이터 및 원본 데이터 초기화
useEffect(() => {
if (editData) {
console.log("📋 편집 데이터 로드:", editData);
console.log("📋 편집 데이터 키들:", Object.keys(editData));
// 원본 데이터와 현재 폼 데이터 모두 설정
const dataClone = { ...editData };
setOriginalData(dataClone); // 원본 데이터 저장 (부분 업데이트용)
setFormData(dataClone); // 편집용 폼 데이터 설정
console.log("📋 originalData 설정 완료:", dataClone);
console.log("📋 formData 설정 완료:", dataClone);
} else {
console.log("⚠️ editData가 없습니다.");
setOriginalData({});
setFormData({});
}
}, [editData]);
// formData 변경 시 로그
useEffect(() => {
console.log("🔄 EditModal formData 상태 변경:", formData);
console.log("🔄 formData 키들:", Object.keys(formData || {}));
}, [formData]);
// 화면 데이터 로드
useEffect(() => {
const fetchScreenData = async () => {
if (!screenId || !isOpen) return;
try {
setLoading(true);
console.log("🔄 화면 데이터 로드 시작:", screenId);
// 화면 정보와 레이아웃 데이터를 동시에 로드
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
console.log("📋 화면 정보:", screenInfo);
console.log("🎨 레이아웃 데이터:", layoutData);
setScreenData(screenInfo);
if (layoutData && layoutData.components) {
setComponents(layoutData.components);
console.log("✅ 화면 컴포넌트 로드 완료:", layoutData.components);
// 컴포넌트와 formData 매칭 정보 출력
console.log("🔍 컴포넌트-formData 매칭 분석:");
layoutData.components.forEach((comp) => {
if (comp.columnName) {
const formValue = formData[comp.columnName];
console.log(
` - ${comp.columnName}: "${formValue}" (타입: ${comp.type}, 웹타입: ${(comp as any).widgetType})`,
);
// 코드 타입인 경우 특별히 로깅
if ((comp as any).widgetType === "code") {
console.log(" 🔍 코드 타입 세부정보:", {
columnName: comp.columnName,
componentId: comp.id,
formValue,
webTypeConfig: (comp as any).webTypeConfig,
});
}
}
});
} else {
console.log("⚠️ 레이아웃 데이터가 없습니다:", layoutData);
}
} catch (error) {
console.error("❌ 화면 데이터 로드 실패:", error);
toast.error("화면을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
fetchScreenData();
}, [screenId, isOpen]);
// 저장 처리
const handleSave = async () => {
const loadScreenData = async (screenId: number) => {
try {
setLoading(true);
console.log("💾 편집 데이터 저장:", formData);
// TODO: 실제 저장 API 호출
// const result = await DynamicFormApi.updateFormData({
// screenId,
// data: formData,
// });
console.log("화면 데이터 로딩 시작:", screenId);
// 임시: 저장 성공 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1000));
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
toast.success("수정이 완료되었습니다.");
onSave?.();
onClose();
console.log("API 응답:", { screenInfo, layoutData });
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면의 실제 크기 계산
const dimensions = calculateScreenDimensions(components);
setScreenDimensions(dimensions);
setScreenData({
components,
screenInfo: screenInfo,
});
console.log("화면 데이터 설정 완료:", {
componentsCount: components.length,
dimensions,
screenInfo,
});
} else {
throw new Error("화면 데이터가 없습니다");
}
} catch (error) {
console.error("❌ 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
handleClose();
} finally {
setLoading(false);
}
};
// 초기화 처리
const handleReset = () => {
if (editData) {
setFormData({ ...editData });
toast.info("초기값으로 되돌렸습니다.");
const handleClose = () => {
setModalState({
isOpen: false,
screenId: null,
title: "",
description: "",
modalSize: "md",
editData: {},
onSave: undefined,
});
setScreenData(null);
setFormData({});
setOriginalData({});
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
const handleSave = async () => {
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;
}
try {
console.log("💾 수정 저장 시작:", {
tableName: screenData.screenInfo.tableName,
formData,
originalData,
});
// 변경된 필드만 추출
const changedData: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
changedData[key] = formData[key];
}
});
console.log("📝 변경된 필드:", changedData);
if (Object.keys(changedData).length === 0) {
toast.info("변경된 내용이 없습니다.");
handleClose();
return;
}
// UPDATE 액션 실행
const response = await dynamicFormApi.updateData(screenData.screenInfo.tableName, {
...originalData, // 원본 데이터 (WHERE 조건용)
...changedData, // 변경된 데이터만
});
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행
if (modalState.onSave) {
modalState.onSave();
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
}
} catch (error: any) {
console.error("❌ 수정 실패:", error);
toast.error(error.message || "데이터 수정 중 오류가 발생했습니다.");
}
};
// 모달 크기 클래스 매핑
const getModalSizeClass = () => {
switch (modalSize) {
case "sm":
return "max-w-md";
case "md":
return "max-w-lg";
case "lg":
return "max-w-4xl";
case "xl":
return "max-w-6xl";
case "full":
return "max-w-[95vw] max-h-[95vh]";
default:
return "max-w-4xl";
// 모달 크기 설정 - ScreenModal과 동일
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: {},
};
}
const headerHeight = 60;
const totalHeight = screenDimensions.height + headerHeight;
return {
className: "overflow-hidden p-0",
style: {
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
maxWidth: "98vw",
maxHeight: "95vh",
},
};
};
if (!screenId) {
return null;
}
const modalStyle = getModalStyle();
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className="p-0"
style={{
// 실제 컨텐츠 크기 그대로 적용 (패딩/여백 제거)
width: dynamicSize.width,
height: dynamicSize.height,
minWidth: dynamicSize.width,
minHeight: dynamicSize.height,
maxWidth: "95vw",
maxHeight: "95vh",
zIndex: 9999, // 모든 컴포넌트보다 위에 표시
}}
data-radix-portal="true"
>
{/* 모달 헤더 (제목/설명이 있으면 표시) */}
{(modalTitle || modalDescription) && (
<DialogHeader className="border-b bg-gray-50 px-6 py-4">
<DialogTitle className="text-lg font-semibold">{modalTitle || "수정"}</DialogTitle>
{modalDescription && <p className="mt-1 text-sm text-gray-600">{modalDescription}</p>}
</DialogHeader>
)}
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
{modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</DialogHeader>
{/* 제목/설명이 없으면 접근성을 위한 숨김 헤더만 표시 */}
{!modalTitle && !modalDescription && (
<DialogHeader className="sr-only">
<DialogTitle></DialogTitle>
</DialogHeader>
)}
<div className="flex-1 overflow-hidden">
<div className="flex flex-1 items-center justify-center overflow-auto">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-500"></div>
<p className="text-muted-foreground"> ...</p>
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
) : screenData && components.length > 0 ? (
// 원본 화면과 동일한 레이아웃으로 렌더링
) : screenData ? (
<div
className="relative bg-white"
style={{
// 실제 컨텐츠 크기 그대로 적용 (여백 제거)
width: dynamicSize.width,
height: dynamicSize.height,
overflow: "hidden",
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */}
<div className="relative" style={{ minHeight: "300px" }}>
{components.map((component, index) => (
<div
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
style={{
position: "absolute",
top: component.position?.y || 0,
left: component.position?.x || 0,
width: component.size?.width || 200,
height: component.size?.height || 40,
zIndex: component.position?.z || 1000 + index, // 모달 내부에서 충분히 높은 z-index
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`);
console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("📝 EditModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}
>
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시) */}
{component.type === "widget" ? (
<InteractiveScreenViewer
component={component}
allComponents={components}
hideLabel={false} // ✅ 라벨 표시
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, value);
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
// 변경된 데이터를 즉시 부모로 전달
if (onDataChange) {
console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData);
onDataChange(newFormData);
}
}}
screenInfo={{
id: screenId || 0,
tableName: screenData.tableName,
}}
/>
) : (
<DynamicComponentRenderer
component={{
...component,
style: {
...component.style,
labelDisplay: true, // ✅ 라벨 표시
},
}}
screenId={screenId}
tableName={screenData.tableName}
formData={formData}
originalData={originalData} // 부분 업데이트용 원본 데이터 전달
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, value);
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
// 변경된 데이터를 즉시 부모로 전달
if (onDataChange) {
console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData);
onDataChange(newFormData);
}
}}
// 편집 모드로 설정
mode="edit"
// 모달 내에서 렌더링되고 있음을 표시
isInModal={true}
// 인터랙티브 모드 활성화 (formData 사용을 위해 필수)
isInteractive={true}
/>
)}
</div>
))}
</div>
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
onSave={handleSave}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-gray-500"> .</p>
<p className="mt-1 text-sm text-gray-400"> ID: {screenId}</p>
</div>
<p className="text-muted-foreground"> .</p>
</div>
)}
</div>
@ -385,3 +365,5 @@ export const EditModal: React.FC<EditModalProps> = ({
</Dialog>
);
};
export default EditModal;

View File

@ -229,21 +229,21 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
const labelStyle = widget.style || {};
const labelElement = (
<label
className={`mb-1 block text-sm font-medium ${hasError ? "text-destructive" : ""}`}
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
style={{
fontSize: labelStyle.labelFontSize || "14px",
color: hasError ? "#ef4444" : labelStyle.labelColor || "#212121",
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
fontWeight: labelStyle.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left",
backgroundColor: labelStyle.labelBackgroundColor,
padding: labelStyle.labelPadding,
borderRadius: labelStyle.labelBorderRadius,
marginBottom: labelStyle.labelMarginBottom || "4px",
marginBottom: labelStyle.labelMarginBottom || "8px",
}}
>
{widget.label}
{widget.required && <span className="ml-1 text-orange-500">*</span>}
{(widget.required || widget.componentConfig?.required) && <span className="text-destructive ml-1">*</span>}
</label>
);
@ -311,7 +311,7 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
};
return (
<div key={comp.id} className="space-y-1">
<div key={comp.id} className="space-y-2">
{renderLabel()}
{renderByWebType()}
{renderFieldValidation()}

View File

@ -470,11 +470,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
maxLength={config?.maxLength}
pattern={getPatternByFormat(config?.format || "none")}
className={`w-full ${isAutoInput ? "bg-gray-50 text-gray-700" : ""}`}
style={{
height: "100%",
minHeight: "100%",
maxHeight: "100%"
}}
/>,
);
}
@ -1838,12 +1833,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block mb-3" style={labelStyle}>
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold">
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>}
</div>
</div>
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}

View File

@ -2576,10 +2576,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}),
style: {
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
labelFontSize: "12px",
labelColor: "#212121",
labelFontSize: "14px",
labelColor: "#000000", // 순수한 검정
labelFontWeight: "500",
labelMarginBottom: "6px",
labelMarginBottom: "8px",
},
componentConfig: {
type: componentId, // text-input, number-input 등

View File

@ -42,10 +42,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text !== undefined ? config.text : "버튼",
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
modalTitle: String(config.action?.modalTitle || ""),
modalDescription: String(config.action?.modalDescription || ""),
editModalTitle: String(config.action?.editModalTitle || ""),
editModalDescription: String(config.action?.editModalDescription || ""),
targetUrl: String(config.action?.targetUrl || ""),
});
const [screens, setScreens] = useState<ScreenOption[]>([]);
@ -86,10 +87,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
setLocalInputs({
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
modalTitle: latestAction.modalTitle || "",
editModalTitle: latestAction.editModalTitle || "",
editModalDescription: latestAction.editModalDescription || "",
targetUrl: latestAction.targetUrl || "",
modalTitle: String(latestAction.modalTitle || ""),
modalDescription: String(latestAction.modalDescription || ""),
editModalTitle: String(latestAction.editModalTitle || ""),
editModalDescription: String(latestAction.editModalDescription || ""),
targetUrl: String(latestAction.targetUrl || ""),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
@ -288,6 +290,21 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
</div>
<div>
<Label htmlFor="modal-description"> </Label>
<Input
id="modal-description"
placeholder="모달 설명을 입력하세요 (선택사항)"
value={localInputs.modalDescription}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalDescription: newValue }));
onUpdateProperty("componentConfig.action.modalDescription", newValue);
}}
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label htmlFor="modal-size"> </Label>
<Select

View File

@ -383,7 +383,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
onCheckedChange={(checked) => handleUpdate("componentConfig.required", checked)}
onCheckedChange={(checked) => {
handleUpdate("required", checked);
handleUpdate("componentConfig.required", checked);
}}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
@ -393,7 +396,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="flex items-center space-x-2">
<Checkbox
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
onCheckedChange={(checked) => handleUpdate("componentConfig.readonly", checked)}
onCheckedChange={(checked) => {
handleUpdate("readonly", checked);
handleUpdate("componentConfig.readonly", checked);
}}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>

View File

@ -235,7 +235,6 @@ export function FlowWidget({
try {
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
const countsResponse = await getAllStepCounts(flowId);
console.log("📊 스텝 카운트 API 응답:", countsResponse);
if (countsResponse.success && countsResponse.data) {
// Record 형태로 변환
@ -248,7 +247,6 @@ export function FlowWidget({
Object.assign(countsMap, countsResponse.data);
}
console.log("✅ 스텝 카운트 업데이트:", countsMap);
setStepCounts(countsMap);
}
@ -258,12 +256,6 @@ export function FlowWidget({
// 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId, selectedStepId);
console.log("🔄 새로고침 시 컬럼 라벨 조회:", {
stepId: selectedStepId,
success: labelsResponse.success,
labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0,
labels: labelsResponse.data,
});
if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data);
}
@ -412,12 +404,6 @@ export function FlowWidget({
try {
// 컬럼 라벨 조회
const labelsResponse = await getStepColumnLabels(flowId!, firstStep.id);
console.log("🏷️ 첫 번째 스텝 컬럼 라벨 조회:", {
stepId: firstStep.id,
success: labelsResponse.success,
labelsCount: labelsResponse.data ? Object.keys(labelsResponse.data).length : 0,
labels: labelsResponse.data,
});
if (labelsResponse.success && labelsResponse.data) {
setColumnLabels(labelsResponse.data);
}
@ -453,7 +439,6 @@ export function FlowWidget({
// flowRefreshKey가 변경될 때마다 스텝 데이터 새로고침
useEffect(() => {
if (flowRefreshKey !== undefined && flowRefreshKey > 0 && flowId) {
console.log("🔄 플로우 새로고침 실행, flowRefreshKey:", flowRefreshKey);
refreshStepData();
}
}, [flowRefreshKey]);

View File

@ -96,7 +96,7 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
onChange={(e) => onChange?.(e.target.value)}
disabled={readonly || isAutoInput}
required={required}
className={`h-full w-full ${borderClass}`}
className={`w-full ${borderClass}`}
maxLength={config?.maxLength}
minLength={config?.minLength}
pattern={config?.pattern}

View File

@ -27,8 +27,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
head_row: "flex w-full",
head_cell:
"text-muted-foreground rounded-md w-9 h-9 font-normal text-[0.8rem] inline-flex items-center justify-center",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),

View File

@ -0,0 +1,263 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface CustomCalendarProps {
selected?: Date;
onSelect?: (date: Date | undefined) => void;
className?: string;
mode?: "single";
size?: "sm" | "default" | "lg";
}
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export function CustomCalendar({
selected,
onSelect,
className,
mode = "single",
size = "default",
}: CustomCalendarProps) {
// 크기별 클래스 정의
const sizeClasses = {
sm: {
cell: "h-7 w-7 text-xs",
header: "text-xs",
day: "text-[0.7rem]",
nav: "h-6 w-6",
},
default: {
cell: "h-9 w-9 text-sm",
header: "text-[0.8rem]",
day: "text-sm",
nav: "h-7 w-7",
},
lg: {
cell: "h-11 w-11 text-base",
header: "text-sm",
day: "text-base",
nav: "h-8 w-8",
},
};
const currentSize = sizeClasses[size];
const [currentDate, setCurrentDate] = React.useState(selected || new Date());
const [viewYear, setViewYear] = React.useState(currentDate.getFullYear());
const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth());
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate();
};
const getFirstDayOfMonth = (year: number, month: number) => {
return new Date(year, month, 1).getDay();
};
const generateCalendarDays = () => {
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
const daysInPrevMonth = getDaysInMonth(viewYear, viewMonth - 1);
const days: Array<{
date: number;
month: "prev" | "current" | "next";
fullDate: Date;
}> = [];
// Previous month days
for (let i = firstDay - 1; i >= 0; i--) {
const date = daysInPrevMonth - i;
days.push({
date,
month: "prev",
fullDate: new Date(viewYear, viewMonth - 1, date),
});
}
// Current month days
for (let i = 1; i <= daysInMonth; i++) {
days.push({
date: i,
month: "current",
fullDate: new Date(viewYear, viewMonth, i),
});
}
// Next month days
const remainingDays = 42 - days.length; // 6 rows * 7 days
for (let i = 1; i <= remainingDays; i++) {
days.push({
date: i,
month: "next",
fullDate: new Date(viewYear, viewMonth + 1, i),
});
}
return days;
};
const handlePrevMonth = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear(viewYear - 1);
} else {
setViewMonth(viewMonth - 1);
}
};
const handleNextMonth = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear(viewYear + 1);
} else {
setViewMonth(viewMonth + 1);
}
};
const handleDateClick = (date: Date) => {
if (onSelect) {
onSelect(date);
}
};
const isToday = (date: Date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
const isSelected = (date: Date) => {
if (!selected) return false;
return (
date.getDate() === selected.getDate() &&
date.getMonth() === selected.getMonth() &&
date.getFullYear() === selected.getFullYear()
);
};
const calendarDays = generateCalendarDays();
return (
<div className={cn("p-3", className)}>
{/* Header */}
<div className="flex items-center justify-between gap-2 pb-4">
<Button
variant="outline"
size="icon"
className={cn("bg-transparent p-0 opacity-50 hover:opacity-100", currentSize.nav)}
onClick={handlePrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
{/* 월 선택 */}
<Select value={viewMonth.toString()} onValueChange={(value) => setViewMonth(parseInt(value))}>
<SelectTrigger className={cn("w-[110px] font-medium", currentSize.header)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTHS.map((month, index) => (
<SelectItem key={index} value={index.toString()}>
{month}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 연도 선택 */}
<Select value={viewYear.toString()} onValueChange={(value) => setViewYear(parseInt(value))}>
<SelectTrigger className={cn("w-[80px] font-medium", currentSize.header)}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 100 }, (_, i) => {
const year = new Date().getFullYear() - 50 + i;
return (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="icon"
className={cn("bg-transparent p-0 opacity-50 hover:opacity-100", currentSize.nav)}
onClick={handleNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Days of week */}
<div className="mb-2 grid grid-cols-7 gap-0">
{DAYS.map((day) => (
<div
key={day}
className={cn(
"text-muted-foreground flex items-center justify-center font-normal",
currentSize.cell,
currentSize.day,
)}
>
{day}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-0">
{calendarDays.map((day, index) => {
const isOutside = day.month !== "current";
const isTodayDate = isToday(day.fullDate);
const isSelectedDate = isSelected(day.fullDate);
return (
<Button
key={index}
variant="ghost"
className={cn(
"p-0 font-normal",
currentSize.cell,
isOutside && "text-muted-foreground opacity-50",
isTodayDate && !isSelectedDate && "bg-accent text-accent-foreground",
isSelectedDate && "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
)}
onClick={() => handleDateClick(day.fullDate)}
>
{day.date}
</Button>
);
})}
</div>
</div>
);
}
CustomCalendar.displayName = "CustomCalendar";

View File

@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,

View File

@ -31,7 +31,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-8 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}

View File

@ -34,11 +34,7 @@ export class DynamicFormApi {
*/
static async saveFormData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
try {
console.log("💾 폼 데이터 저장 요청:", formData);
const response = await apiClient.post("/dynamic-form/save", formData);
console.log("✅ 폼 데이터 저장 성공:", response.data);
return {
success: true,
data: response.data,
@ -64,11 +60,7 @@ export class DynamicFormApi {
*/
static async saveData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
try {
console.log("🚀 개선된 폼 데이터 저장 요청:", formData);
const response = await apiClient.post("/dynamic-form/save-enhanced", formData);
console.log("✅ 개선된 폼 데이터 저장 성공:", response.data);
return response.data;
} catch (error: any) {
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
@ -400,11 +392,7 @@ export class DynamicFormApi {
*/
static async getTablePrimaryKeys(tableName: string): Promise<ApiResponse<string[]>> {
try {
console.log("🔑 테이블 기본키 조회 요청:", tableName);
const response = await apiClient.get(`/dynamic-form/table/${tableName}/primary-keys`);
console.log("✅ 테이블 기본키 조회 성공:", response.data);
return {
success: true,
data: response.data.data,

View File

@ -82,8 +82,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
// 🔍 디버깅: props 확인
// 🆕 플로우 단계별 표시 제어
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);

View File

@ -0,0 +1,47 @@
import { ComponentRegistryItem } from "../../types";
export const formLayoutRegistry: ComponentRegistryItem = {
name: "form-layout",
displayName: "Form Layout",
description: "shadcn/ui 스타일의 자동 폼 레이아웃 - 필드를 추가하고 정갈한 입력 폼을 만드세요",
category: "form",
icon: "FileText",
component: () => import("./FormLayoutComponent").then((m) => m.FormLayoutComponent),
configPanel: () => import("./FormLayoutConfigPanel").then((m) => m.FormLayoutConfigPanel),
defaultProps: {
fields: [
{
id: "field-example-1",
name: "name",
label: "이름",
type: "text",
placeholder: "이름을 입력하세요",
required: true,
gridColumn: "half",
},
{
id: "field-example-2",
name: "email",
label: "이메일",
type: "email",
placeholder: "example@domain.com",
required: true,
gridColumn: "half",
},
{
id: "field-example-3",
name: "description",
label: "설명",
type: "textarea",
placeholder: "상세 설명을 입력하세요",
required: false,
gridColumn: "full",
},
],
submitButtonText: "제출",
cancelButtonText: "취소",
columns: 2,
},
previewImage: "/registry/form-layout-preview.png",
};

View File

@ -610,12 +610,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
className={`box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
onClick={(e) => {
console.log("🖱️ Input 클릭됨:", {
componentId: component.id,
disabled: componentConfig.disabled,
readOnly: componentConfig.readonly,
autoGenEnabled: testAutoGeneration.enabled,
});
handleClick(e);
}}
onDragStart={onDragStart}
@ -633,16 +627,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// isInteractive 모드에서는 formData 업데이트
if (isInteractive && onFormDataChange && component.columnName) {
console.log(`✅ TextInputComponent onChange 조건 충족:`, {
columnName: component.columnName,
newValue,
valueType: typeof newValue,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
onFormDataChangeType: typeof onFormDataChange,
});
onFormDataChange(component.columnName, newValue);
console.log(`✅ onFormDataChange 호출 완료`);
} else {
console.log("❌ TextInputComponent onFormDataChange 조건 미충족:", {
isInteractive,

View File

@ -35,6 +35,7 @@ export interface ButtonActionConfig {
// 모달/팝업 관련
modalTitle?: string;
modalDescription?: string;
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
popupHeight?: number;
@ -109,10 +110,10 @@ export class ButtonActionExecutor {
return this.handleNavigate(config, context);
case "modal":
return this.handleModal(config, context);
return await this.handleModal(config, context);
case "edit":
return this.handleEdit(config, context);
return await this.handleEdit(config, context);
case "control":
return this.handleControl(config, context);
@ -175,14 +176,6 @@ export class ButtonActionExecutor {
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
const isUpdate = false; // 현재는 항상 INSERT로 처리
console.log("💾 저장 모드 판단 (DB 기반):", {
tableName,
formData,
primaryKeys,
primaryKeyValue,
isUpdate: isUpdate ? "UPDATE" : "INSERT",
});
let saveResult;
if (isUpdate) {
@ -208,7 +201,6 @@ export class ButtonActionExecutor {
}
} else {
// INSERT 처리
console.log("🆕 INSERT 모드로 저장:", { formData });
// 🆕 자동으로 작성자 정보 추가
const writerValue = context.userId || context.userName || "unknown";
@ -233,8 +225,6 @@ export class ButtonActionExecutor {
throw new Error(saveResult.message || "저장에 실패했습니다.");
}
console.log("✅ 저장 성공:", saveResult);
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
@ -262,7 +252,6 @@ export class ButtonActionExecutor {
*/
private static extractPrimaryKeyValueFromDB(formData: Record<string, any>, primaryKeys: string[]): any {
if (!primaryKeys || primaryKeys.length === 0) {
console.log("🔍 DB에서 기본키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
return null;
}
@ -283,9 +272,6 @@ export class ButtonActionExecutor {
}
// 기본키 컬럼이 formData에 없는 경우
console.log(`❌ 기본키 컬럼 '${primaryKeyColumn}'이 formData에 없습니다. INSERT 모드로 처리됩니다.`);
console.log("📋 DB 기본키 컬럼들:", primaryKeys);
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null;
}
@ -319,8 +305,6 @@ export class ButtonActionExecutor {
}
// 기본 키를 찾지 못한 경우
console.log("🔍 추측 기반으로 기본 키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null;
}
@ -519,7 +503,7 @@ export class ButtonActionExecutor {
/**
*
*/
private static handleModal(config: ButtonActionConfig, context: ButtonActionContext): boolean {
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 모달 열기 로직
console.log("모달 열기:", {
title: config.modalTitle,
@ -528,11 +512,25 @@ export class ButtonActionExecutor {
});
if (config.targetScreenId) {
// 1. config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
if (!description) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "화면",
description: description,
size: config.modalSize || "md",
},
});
@ -634,7 +632,7 @@ export class ButtonActionExecutor {
/**
*
*/
private static handleEdit(config: ButtonActionConfig, context: ButtonActionContext): boolean {
private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { selectedRowsData, flowSelectedData } = context;
// 플로우 선택 데이터 우선 사용
@ -671,7 +669,7 @@ export class ButtonActionExecutor {
const rowData = dataToEdit[0];
console.log("📝 단일 항목 편집:", rowData);
this.openEditForm(config, rowData, context);
await this.openEditForm(config, rowData, context);
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
@ -688,13 +686,17 @@ export class ButtonActionExecutor {
/**
* ( )
*/
private static openEditForm(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
private static async openEditForm(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
const editMode = config.editMode || "modal";
switch (editMode) {
case "modal":
// 모달로 편집 폼 열기
this.openEditModal(config, rowData, context);
await this.openEditModal(config, rowData, context);
break;
case "navigate":
@ -716,17 +718,36 @@ export class ButtonActionExecutor {
/**
*
*/
private static openEditModal(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
private static async openEditModal(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
console.log("🎭 편집 모달 열기:", {
targetScreenId: config.targetScreenId,
modalSize: config.modalSize,
rowData,
});
// 1. config에 editModalDescription이 있으면 우선 사용
let description = config.editModalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
if (!description && config.targetScreenId) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 모달 열기 이벤트 발생
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
description: description,
modalSize: config.modalSize || "lg",
editData: rowData,
onSave: () => {

View File

@ -61,7 +61,7 @@
"mammoth": "^1.11.0",
"next": "15.4.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0",
@ -11575,9 +11575,9 @@
}
},
"node_modules/react-day-picker": {
"version": "9.11.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.0.tgz",
"integrity": "sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ==",
"version": "9.11.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",

View File

@ -69,7 +69,7 @@
"mammoth": "^1.11.0",
"next": "15.4.4",
"react": "19.1.0",
"react-day-picker": "^9.9.0",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0",