Compare commits

..

No commits in common. "58e1aec26202b9cd0fe76af7b707597b34d929cb" and "444b2fab2bf07d0dc0ac34b89a8adea8c31b4b76" have entirely different histories.

27 changed files with 568 additions and 3482 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,837 +0,0 @@
"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,7 +63,9 @@ export default function ScreenViewPage() {
useEffect(() => {
const initComponents = async () => {
try {
console.log("🚀 할당된 화면에서 컴포넌트 시스템 초기화 시작...");
await initializeComponents();
console.log("✅ 할당된 화면에서 컴포넌트 시스템 초기화 완료");
} catch (error) {
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
}
@ -157,6 +159,16 @@ 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);
}
};
@ -273,22 +285,31 @@ 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 }));
}}
>
@ -332,6 +353,7 @@ export default function ScreenViewPage() {
}}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
/>

View File

@ -1,158 +1,145 @@
/* ===== 서명용 손글씨 폰트 ===== */
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */
@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 *));
/* ===== 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 */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--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 {
/* 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%;
--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);
/* 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%;
/* Z-Index 계층 구조 */
--z-background: 1;
--z-layout: 10;
--z-content: 50;
--z-floating: 100;
--z-modal: 1000;
--z-tooltip: 2000;
--z-critical: 3000;
}
/* ===== Dark Theme ===== */
.dark {
--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%;
--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);
}
/* ===== Base Styles ===== */
* {
border-color: hsl(var(--border));
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
color: hsl(var(--foreground));
background: hsl(var(--background));
}
/* ===== Dialog/Modal Overlay ===== */
/* Radix UI Dialog Overlay - 60% 불투명도 배경 */
/* Dialog 오버레이 커스터마이징 - 어두운 배경 */
[data-radix-dialog-overlay],
.fixed.inset-0.z-50.bg-black {
background-color: rgba(0, 0, 0, 0.6) !important;
@ -164,150 +151,3 @@ body {
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" />
<Toaster position="top-right" richColors />
<ScreenModal />
</QueryProvider>
{/* Portal 컨테이너 */}

View File

@ -11,7 +11,6 @@ interface ScreenModalState {
isOpen: boolean;
screenId: number | null;
title: string;
description?: string;
size: "sm" | "md" | "lg" | "xl";
}
@ -24,7 +23,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
isOpen: false,
screenId: null,
title: "",
description: "",
size: "md",
});
@ -95,22 +93,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
const { screenId, title, description, size } = event.detail;
const { screenId, title, 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);
@ -218,9 +215,6 @@ 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

@ -1,421 +0,0 @@
"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,17 +224,6 @@ 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,363 +1,383 @@
"use client";
import React, { useState, useEffect } from "react";
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 "@/types/screen";
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 { dynamicFormApi } from "@/lib/api/dynamicForm";
interface EditModalState {
isOpen: boolean;
screenId: number | null;
title: string;
description?: string;
modalSize: "sm" | "md" | "lg" | "xl";
editData: Record<string, any>;
onSave?: () => void;
}
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/lib/types/screen";
interface EditModalProps {
className?: string;
isOpen: boolean;
onClose: () => void;
screenId?: number;
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
editData?: any;
onSave?: () => void;
onDataChange?: (formData: Record<string, any>) => void; // 폼 데이터 변경 콜백 추가
modalTitle?: string; // 모달 제목
modalDescription?: string; // 모달 설명
}
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);
/**
*
*
*/
export const EditModal: React.FC<EditModalProps> = ({
isOpen,
onClose,
screenId,
modalSize = "lg",
editData,
onSave,
onDataChange,
modalTitle,
modalDescription,
}) => {
const [loading, setLoading] = useState(false);
const [screenDimensions, setScreenDimensions] = useState<{
width: number;
height: number;
offsetX?: number;
offsetY?: number;
} | null>(null);
const [formData, setFormData] = useState<any>({});
const [originalData, setOriginalData] = useState<any>({}); // 부분 업데이트용 원본 데이터
const [screenData, setScreenData] = useState<any>(null);
const [components, setComponents] = useState<ComponentData[]>([]);
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
// 컴포넌트 기반 동적 크기 계산
const calculateModalSize = () => {
if (components.length === 0) {
return {
width: 400,
height: 300,
offsetX: 0,
offsetY: 0,
};
return { width: 600, height: 400 }; // 기본 크기
}
// 모든 컴포넌트의 경계 찾기
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백
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");
const maxHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 최소한의 여백만 추가
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),
};
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 };
};
// 전역 모달 이벤트 리스너
const dynamicSize = calculateModalSize();
// EditModal 전용 닫기 이벤트 리스너
useEffect(() => {
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();
console.log("🚪 EditModal: closeEditModal 이벤트 수신");
onClose();
};
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 (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
}
}, [modalState.isOpen, modalState.screenId]);
if (isOpen && dynamicSize) {
// 모달이 렌더링된 후 DOM 직접 조작으로 크기 강제 적용
setTimeout(() => {
const dialogContent = document.querySelector('[role="dialog"] > div');
const modalContent = document.querySelector('[role="dialog"] [class*="overflow-auto"]');
const loadScreenData = async (screenId: number) => {
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 지연으로 렌더링 완료 후 실행
}
}, [isOpen, dynamicSize]);
// 편집 데이터가 변경되면 폼 데이터 및 원본 데이터 초기화
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 () => {
try {
setLoading(true);
console.log("💾 편집 데이터 저장:", formData);
console.log("화면 데이터 로딩 시작:", screenId);
// TODO: 실제 저장 API 호출
// const result = await DynamicFormApi.updateFormData({
// screenId,
// data: formData,
// });
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
// 임시: 저장 성공 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 1000));
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("화면 데이터가 없습니다");
}
toast.success("수정이 완료되었습니다.");
onSave?.();
onClose();
} catch (error) {
console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
handleClose();
console.error("❌ 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
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 handleReset = () => {
if (editData) {
setFormData({ ...editData });
toast.info("초기값으로 되돌렸습니다.");
}
};
// 모달 크기 설정 - ScreenModal과 동일
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: {},
};
// 모달 크기 클래스 매핑
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";
}
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",
},
};
};
const modalStyle = getModalStyle();
if (!screenId) {
return null;
}
return (
<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>
<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>
)}
<div className="flex flex-1 items-center justify-center overflow-auto">
{/* 제목/설명이 없으면 접근성을 위한 숨김 헤더만 표시 */}
{!modalTitle && !modalDescription && (
<DialogHeader className="sr-only">
<DialogTitle></DialogTitle>
</DialogHeader>
)}
<div className="flex-1 overflow-hidden">
{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-b-2 border-blue-600"></div>
<p className="text-muted-foreground"> ...</p>
<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>
</div>
) : screenData ? (
) : screenData && components.length > 0 ? (
// 원본 화면과 동일한 레이아웃으로 렌더링
<div
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
// 실제 컨텐츠 크기 그대로 적용 (여백 제거)
width: dynamicSize.width,
height: dynamicSize.height,
overflow: "hidden",
}}
>
{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
{/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */}
<div className="relative" style={{ minHeight: "300px" }}>
{components.map((component, index) => (
<div
key={component.id}
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;
});
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
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
onSave={handleSave}
/>
);
})}
>
{/* 위젯 컴포넌트는 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>
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>
<div className="text-center">
<p className="text-gray-500"> .</p>
<p className="mt-1 text-sm text-gray-400"> ID: {screenId}</p>
</div>
</div>
)}
</div>
@ -365,5 +385,3 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</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-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
className={`mb-1 block text-sm font-medium ${hasError ? "text-destructive" : ""}`}
style={{
fontSize: labelStyle.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
color: hasError ? "#ef4444" : labelStyle.labelColor || "#212121",
fontWeight: labelStyle.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left",
backgroundColor: labelStyle.labelBackgroundColor,
padding: labelStyle.labelPadding,
borderRadius: labelStyle.labelBorderRadius,
marginBottom: labelStyle.labelMarginBottom || "8px",
marginBottom: labelStyle.labelMarginBottom || "4px",
}}
>
{widget.label}
{(widget.required || widget.componentConfig?.required) && <span className="text-destructive ml-1">*</span>}
{widget.required && <span className="ml-1 text-orange-500">*</span>}
</label>
);
@ -311,7 +311,7 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
};
return (
<div key={comp.id} className="space-y-2">
<div key={comp.id} className="space-y-1">
{renderLabel()}
{renderByWebType()}
{renderFieldValidation()}

View File

@ -470,6 +470,11 @@ 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%"
}}
/>,
);
}
@ -1833,10 +1838,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<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>
<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>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}

View File

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

View File

@ -42,11 +42,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
// 로컬 상태 관리 (실시간 입력 반영)
const [localInputs, setLocalInputs] = useState({
text: config.text !== undefined ? config.text : "버튼",
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 || ""),
modalTitle: config.action?.modalTitle || "",
editModalTitle: config.action?.editModalTitle || "",
editModalDescription: config.action?.editModalDescription || "",
targetUrl: config.action?.targetUrl || "",
});
const [screens, setScreens] = useState<ScreenOption[]>([]);
@ -87,11 +86,10 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
setLocalInputs({
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
modalTitle: String(latestAction.modalTitle || ""),
modalDescription: String(latestAction.modalDescription || ""),
editModalTitle: String(latestAction.editModalTitle || ""),
editModalDescription: String(latestAction.editModalDescription || ""),
targetUrl: String(latestAction.targetUrl || ""),
modalTitle: latestAction.modalTitle || "",
editModalTitle: latestAction.editModalTitle || "",
editModalDescription: latestAction.editModalDescription || "",
targetUrl: latestAction.targetUrl || "",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]);
@ -290,21 +288,6 @@ 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,10 +383,7 @@ 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("required", checked);
handleUpdate("componentConfig.required", checked);
}}
onCheckedChange={(checked) => handleUpdate("componentConfig.required", checked)}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>
@ -396,10 +393,7 @@ 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("readonly", checked);
handleUpdate("componentConfig.readonly", checked);
}}
onCheckedChange={(checked) => handleUpdate("componentConfig.readonly", checked)}
className="h-4 w-4"
/>
<Label className="text-xs"></Label>

View File

@ -235,6 +235,7 @@ export function FlowWidget({
try {
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
const countsResponse = await getAllStepCounts(flowId);
console.log("📊 스텝 카운트 API 응답:", countsResponse);
if (countsResponse.success && countsResponse.data) {
// Record 형태로 변환
@ -247,6 +248,7 @@ export function FlowWidget({
Object.assign(countsMap, countsResponse.data);
}
console.log("✅ 스텝 카운트 업데이트:", countsMap);
setStepCounts(countsMap);
}
@ -256,6 +258,12 @@ 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);
}
@ -404,6 +412,12 @@ 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);
}
@ -439,6 +453,7 @@ 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={`w-full ${borderClass}`}
className={`h-full w-full ${borderClass}`}
maxLength={config?.maxLength}
minLength={config?.minLength}
pattern={config?.pattern}

View File

@ -27,9 +27,8 @@ 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 w-full",
head_cell:
"text-muted-foreground rounded-md w-9 h-9 font-normal text-[0.8rem] inline-flex items-center justify-center",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
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

@ -1,263 +0,0 @@
"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-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",
"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",
"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-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",
"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",
className,
)}
{...props}

View File

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

View File

@ -1,47 +0,0 @@
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,6 +610,12 @@ 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}
@ -627,7 +633,16 @@ 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,7 +35,6 @@ export interface ButtonActionConfig {
// 모달/팝업 관련
modalTitle?: string;
modalDescription?: string;
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
popupHeight?: number;
@ -110,10 +109,10 @@ export class ButtonActionExecutor {
return this.handleNavigate(config, context);
case "modal":
return await this.handleModal(config, context);
return this.handleModal(config, context);
case "edit":
return await this.handleEdit(config, context);
return this.handleEdit(config, context);
case "control":
return this.handleControl(config, context);
@ -176,6 +175,14 @@ export class ButtonActionExecutor {
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
const isUpdate = false; // 현재는 항상 INSERT로 처리
console.log("💾 저장 모드 판단 (DB 기반):", {
tableName,
formData,
primaryKeys,
primaryKeyValue,
isUpdate: isUpdate ? "UPDATE" : "INSERT",
});
let saveResult;
if (isUpdate) {
@ -201,6 +208,7 @@ export class ButtonActionExecutor {
}
} else {
// INSERT 처리
console.log("🆕 INSERT 모드로 저장:", { formData });
// 🆕 자동으로 작성자 정보 추가
const writerValue = context.userId || context.userName || "unknown";
@ -225,6 +233,8 @@ export class ButtonActionExecutor {
throw new Error(saveResult.message || "저장에 실패했습니다.");
}
console.log("✅ 저장 성공:", saveResult);
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
@ -252,6 +262,7 @@ export class ButtonActionExecutor {
*/
private static extractPrimaryKeyValueFromDB(formData: Record<string, any>, primaryKeys: string[]): any {
if (!primaryKeys || primaryKeys.length === 0) {
console.log("🔍 DB에서 기본키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
return null;
}
@ -272,6 +283,9 @@ export class ButtonActionExecutor {
}
// 기본키 컬럼이 formData에 없는 경우
console.log(`❌ 기본키 컬럼 '${primaryKeyColumn}'이 formData에 없습니다. INSERT 모드로 처리됩니다.`);
console.log("📋 DB 기본키 컬럼들:", primaryKeys);
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null;
}
@ -305,6 +319,8 @@ export class ButtonActionExecutor {
}
// 기본 키를 찾지 못한 경우
console.log("🔍 추측 기반으로 기본 키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null;
}
@ -503,7 +519,7 @@ export class ButtonActionExecutor {
/**
*
*/
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
private static handleModal(config: ButtonActionConfig, context: ButtonActionContext): boolean {
// 모달 열기 로직
console.log("모달 열기:", {
title: config.modalTitle,
@ -512,25 +528,11 @@ 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",
},
});
@ -632,7 +634,7 @@ export class ButtonActionExecutor {
/**
*
*/
private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
private static handleEdit(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { selectedRowsData, flowSelectedData } = context;
// 플로우 선택 데이터 우선 사용
@ -669,7 +671,7 @@ export class ButtonActionExecutor {
const rowData = dataToEdit[0];
console.log("📝 단일 항목 편집:", rowData);
await this.openEditForm(config, rowData, context);
this.openEditForm(config, rowData, context);
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
@ -686,17 +688,13 @@ export class ButtonActionExecutor {
/**
* ( )
*/
private static async openEditForm(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
private static openEditForm(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
const editMode = config.editMode || "modal";
switch (editMode) {
case "modal":
// 모달로 편집 폼 열기
await this.openEditModal(config, rowData, context);
this.openEditModal(config, rowData, context);
break;
case "navigate":
@ -718,36 +716,17 @@ export class ButtonActionExecutor {
/**
*
*/
private static async openEditModal(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
private static openEditModal(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): 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.11.1",
"react-day-picker": "^9.9.0",
"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.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
"version": "9.11.0",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.0.tgz",
"integrity": "sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ==",
"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.11.1",
"react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0",