Merge pull request 'feature/screen-management' (#169) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/169
This commit is contained in:
commit
58e1aec262
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,837 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import { CustomCalendar } from "@/components/ui/custom-calendar";
|
||||||
|
import { ExampleFormDialog } from "@/components/examples/ExampleFormDialog";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export default function UIComponentsDemoPage() {
|
||||||
|
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||||
|
const [progress, setProgress] = useState(45);
|
||||||
|
const [switchOn, setSwitchOn] = useState(false);
|
||||||
|
const [checkboxChecked, setCheckboxChecked] = useState(false);
|
||||||
|
const [sliderValue, setSliderValue] = useState([50]);
|
||||||
|
const [radioValue, setRadioValue] = useState("option1");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background min-h-screen p-8">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-8">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-4xl font-bold">shadcn/ui 컴포넌트 데모</h1>
|
||||||
|
<p className="text-muted-foreground text-lg">프로젝트에서 사용 가능한 모든 UI 컴포넌트를 확인하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실전 예시 폼 */}
|
||||||
|
<Card className="bg-primary/5 border-primary/20">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<span className="text-primary">🎯</span>
|
||||||
|
실전 예시: 완전한 입력 폼
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
모든 shadcn/ui 컴포넌트를 활용한 완전한 폼 예시입니다. 유효성 검사, 에러 처리, 반응형 디자인이 모두
|
||||||
|
포함되어 있습니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ExampleFormDialog />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Button (버튼)</CardTitle>
|
||||||
|
<CardDescription>다양한 스타일의 버튼 컴포넌트</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="default">Default</Button>
|
||||||
|
<Button variant="secondary">Secondary</Button>
|
||||||
|
<Button variant="destructive">Destructive</Button>
|
||||||
|
<Button variant="outline">Outline</Button>
|
||||||
|
<Button variant="ghost">Ghost</Button>
|
||||||
|
<Button variant="link">Link</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="lg">Large</Button>
|
||||||
|
<Button size="default">Default</Button>
|
||||||
|
<Button size="sm">Small</Button>
|
||||||
|
<Button size="icon">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled>Disabled</Button>
|
||||||
|
<Button>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
With Icon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Badge 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Badge (배지)</CardTitle>
|
||||||
|
<CardDescription>상태 표시 및 태그용 배지</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge>Default</Badge>
|
||||||
|
<Badge variant="secondary">Secondary</Badge>
|
||||||
|
<Badge variant="destructive">Destructive</Badge>
|
||||||
|
<Badge variant="outline">Outline</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Alert 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Alert (알림)</CardTitle>
|
||||||
|
<CardDescription>정보 표시용 알림 박스</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertTitle>기본 알림</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
이것은 기본 알림 메시지입니다. 사용자에게 정보를 전달할 때 사용합니다.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>오류 발생</AlertTitle>
|
||||||
|
<AlertDescription>문제가 발생했습니다. 다시 시도해주세요.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Input & Form 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Input & Form (입력 필드)</CardTitle>
|
||||||
|
<CardDescription>폼 입력 컴포넌트들</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Text Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">이메일</Label>
|
||||||
|
<Input id="email" type="email" placeholder="example@email.com" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Textarea */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="message">메시지</Label>
|
||||||
|
<Textarea id="message" placeholder="메시지를 입력하세요..." rows={4} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>셀렉트</Label>
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="옵션을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="option1">옵션 1</SelectItem>
|
||||||
|
<SelectItem value="option2">옵션 2</SelectItem>
|
||||||
|
<SelectItem value="option3">옵션 3</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="terms"
|
||||||
|
checked={checkboxChecked}
|
||||||
|
onCheckedChange={(checked) => setCheckboxChecked(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="terms" className="cursor-pointer">
|
||||||
|
약관에 동의합니다
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Radio Group */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>라디오 그룹</Label>
|
||||||
|
<RadioGroup value={radioValue} onValueChange={setRadioValue}>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="option1" id="r1" />
|
||||||
|
<Label htmlFor="r1" className="cursor-pointer">
|
||||||
|
옵션 1
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="option2" id="r2" />
|
||||||
|
<Label htmlFor="r2" className="cursor-pointer">
|
||||||
|
옵션 2
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="option3" id="r3" />
|
||||||
|
<Label htmlFor="r3" className="cursor-pointer">
|
||||||
|
옵션 3
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Switch */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch id="airplane" checked={switchOn} onCheckedChange={setSwitchOn} />
|
||||||
|
<Label htmlFor="airplane" className="cursor-pointer">
|
||||||
|
비행기 모드 {switchOn ? "켜짐" : "꺼짐"}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slider */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>슬라이더 (값: {sliderValue[0]})</Label>
|
||||||
|
<Slider value={sliderValue} onValueChange={setSliderValue} max={100} step={1} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dialog & Modal 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dialog & Modal (대화상자)</CardTitle>
|
||||||
|
<CardDescription>모달 대화상자 컴포넌트</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Dialog */}
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">Dialog 열기</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">대화상자 제목</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
여기에 대화상자 설명이 들어갑니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">이름</Label>
|
||||||
|
<Input id="name" placeholder="이름을 입력하세요" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">확인</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Alert Dialog */}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Alert Dialog 열기</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>정말 삭제하시겠습니까?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction>삭제</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Dropdown & Popover 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dropdown & Popover (드롭다운)</CardTitle>
|
||||||
|
<CardDescription>드롭다운 메뉴 및 팝오버</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
드롭다운 메뉴
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuLabel>내 계정</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>프로필</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>설정</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>팀</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-destructive">로그아웃</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Popover */}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline">팝오버 열기</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="leading-none font-medium">팝오버 제목</h4>
|
||||||
|
<p className="text-muted-foreground text-sm">팝오버 내용이 여기에 표시됩니다.</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Command 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Command (커맨드 팔레트)</CardTitle>
|
||||||
|
<CardDescription>검색 가능한 커맨드 인터페이스</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Command className="rounded-lg border shadow-md">
|
||||||
|
<CommandInput placeholder="명령어 검색..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup heading="제안">
|
||||||
|
<CommandItem>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
<span>검색</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
<span>사용자</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
<span>새로 만들기</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tabs 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tabs (탭)</CardTitle>
|
||||||
|
<CardDescription>탭 네비게이션</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Tabs defaultValue="tab1" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="tab1">탭 1</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab2">탭 2</TabsTrigger>
|
||||||
|
<TabsTrigger value="tab3">탭 3</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="tab1" className="space-y-4">
|
||||||
|
<p className="text-muted-foreground text-sm">첫 번째 탭의 내용입니다.</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="tab2" className="space-y-4">
|
||||||
|
<p className="text-muted-foreground text-sm">두 번째 탭의 내용입니다.</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="tab3" className="space-y-4">
|
||||||
|
<p className="text-muted-foreground text-sm">세 번째 탭의 내용입니다.</p>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Accordion 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Accordion (아코디언)</CardTitle>
|
||||||
|
<CardDescription>접이식 패널</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="item-1">
|
||||||
|
<AccordionTrigger>첫 번째 항목</AccordionTrigger>
|
||||||
|
<AccordionContent>첫 번째 항목의 내용입니다. 여기에 상세 정보가 표시됩니다.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">
|
||||||
|
<AccordionTrigger>두 번째 항목</AccordionTrigger>
|
||||||
|
<AccordionContent>두 번째 항목의 내용입니다. 추가 설명이 들어갑니다.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="item-3">
|
||||||
|
<AccordionTrigger>세 번째 항목</AccordionTrigger>
|
||||||
|
<AccordionContent>세 번째 항목의 내용입니다. 더 많은 정보를 확인할 수 있습니다.</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Collapsible 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Collapsible (접이식)</CardTitle>
|
||||||
|
<CardDescription>간단한 접이식 컴포넌트</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Collapsible>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button variant="ghost" className="w-full justify-between">
|
||||||
|
<span>더 보기</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className="mt-2 space-y-2">
|
||||||
|
<div className="rounded-md border px-4 py-2 text-sm">추가 내용 1</div>
|
||||||
|
<div className="rounded-md border px-4 py-2 text-sm">추가 내용 2</div>
|
||||||
|
<div className="rounded-md border px-4 py-2 text-sm">추가 내용 3</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Progress 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Progress (진행률)</CardTitle>
|
||||||
|
<CardDescription>진행 상태 표시</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">업로드 중...</span>
|
||||||
|
<span className="font-medium">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => setProgress(Math.max(0, progress - 10))}>
|
||||||
|
-10%
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setProgress(Math.min(100, progress + 10))}>
|
||||||
|
+10%
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Avatar 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Avatar (아바타)</CardTitle>
|
||||||
|
<CardDescription>사용자 프로필 이미지</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src="https://github.com/shadcn.png" />
|
||||||
|
<AvatarFallback>CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback>KJ</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Table (테이블)</CardTitle>
|
||||||
|
<CardDescription>데이터 테이블</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableCaption>최근 거래 내역</TableCaption>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>이름</TableHead>
|
||||||
|
<TableHead>상태</TableHead>
|
||||||
|
<TableHead className="text-right">금액</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">001</TableCell>
|
||||||
|
<TableCell>김철수</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge>완료</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">₩100,000</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">002</TableCell>
|
||||||
|
<TableCell>이영희</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">대기</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">₩250,000</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-medium">003</TableCell>
|
||||||
|
<TableCell>박민수</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="destructive">취소</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">₩50,000</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ScrollArea 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Scroll Area (스크롤 영역)</CardTitle>
|
||||||
|
<CardDescription>커스텀 스크롤바가 있는 영역</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-72 w-full rounded-md border p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Array.from({ length: 50 }).map((_, i) => (
|
||||||
|
<div key={i} className="text-muted-foreground text-sm">
|
||||||
|
항목 {i + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Calendar & Date Picker 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Calendar & Date Picker (캘린더 및 날짜 선택)</CardTitle>
|
||||||
|
<CardDescription>다양한 형태의 날짜 선택 컴포넌트</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Date Picker (권장 방식 - Custom Calendar 사용) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Date Picker (Popover 방식 - 권장)</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
|
||||||
|
{date ? (
|
||||||
|
date.toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">날짜를 선택하세요</span>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="ml-auto h-4 w-4 opacity-50"
|
||||||
|
>
|
||||||
|
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
|
||||||
|
<line x1="16" x2="16" y1="2" y2="6" />
|
||||||
|
<line x1="8" x2="8" y1="2" y2="6" />
|
||||||
|
<line x1="3" x2="21" y1="10" y2="10" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<CustomCalendar mode="single" selected={date} onSelect={setDate} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{date && <p className="text-muted-foreground text-xs">선택된 날짜: {date.toISOString().split("T")[0]}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 커스텀 Calendar (shadcn/ui 스타일) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Custom Calendar (자체 제작 - shadcn/ui 스타일)</Label>
|
||||||
|
<CustomCalendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
onSelect={setDate}
|
||||||
|
className="rounded-md border shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 기존 react-day-picker Calendar */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>React-Day-Picker Calendar (원본)</Label>
|
||||||
|
<Calendar mode="single" selected={date} onSelect={setDate} className="rounded-md border shadow-sm" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Separator 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Separator (구분선)</CardTitle>
|
||||||
|
<CardDescription>컨텐츠 구분용 선</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">위 내용</p>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<p className="text-sm">아래 내용</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-20 items-center space-x-4">
|
||||||
|
<div className="text-sm">왼쪽</div>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<div className="text-sm">오른쪽</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Toast 버튼 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Toast (토스트 알림)</CardTitle>
|
||||||
|
<CardDescription>Sonner 토스트 알림 (우측 상단에 표시됨)</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
toast.success("성공", {
|
||||||
|
description: "작업이 성공적으로 완료되었습니다.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
성공 토스트
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() =>
|
||||||
|
toast.error("오류", {
|
||||||
|
description: "작업 중 오류가 발생했습니다.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
오류 토스트
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
toast("알림", {
|
||||||
|
description: "일반 알림 메시지입니다.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
일반 토스트
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
const promise = new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
toast.promise(promise, {
|
||||||
|
loading: "처리 중...",
|
||||||
|
success: "완료!",
|
||||||
|
error: "실패",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Promise 토스트
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Lucide Icons 섹션 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Lucide Icons (아이콘)</CardTitle>
|
||||||
|
<CardDescription>자주 사용하는 아이콘들</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-6 gap-4 sm:grid-cols-8 md:grid-cols-12">
|
||||||
|
{[
|
||||||
|
{ Icon: Plus, name: "Plus" },
|
||||||
|
{ Icon: Trash2, name: "Trash2" },
|
||||||
|
{ Icon: Search, name: "Search" },
|
||||||
|
{ Icon: User, name: "User" },
|
||||||
|
{ Icon: Check, name: "Check" },
|
||||||
|
{ Icon: ChevronDown, name: "ChevronDown" },
|
||||||
|
{ Icon: AlertCircle, name: "AlertCircle" },
|
||||||
|
{ Icon: Info, name: "Info" },
|
||||||
|
{ Icon: Loader2, name: "Loader2" },
|
||||||
|
{ Icon: MoreHorizontal, name: "MoreHorizontal" },
|
||||||
|
].map(({ Icon, name }) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className="hover:bg-accent flex flex-col items-center gap-2 rounded-lg border p-3"
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
<span className="text-muted-foreground text-[10px]">{name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 컬러 팔레트 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Color Palette (색상 팔레트)</CardTitle>
|
||||||
|
<CardDescription>프로젝트 색상 시스템</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-primary h-20 rounded-lg" />
|
||||||
|
<p className="text-xs font-medium">Primary</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-primary</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-secondary h-20 rounded-lg" />
|
||||||
|
<p className="text-xs font-medium">Secondary</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-secondary</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-destructive h-20 rounded-lg" />
|
||||||
|
<p className="text-xs font-medium">Destructive</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-destructive</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-muted h-20 rounded-lg" />
|
||||||
|
<p className="text-xs font-medium">Muted</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-muted</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-accent h-20 rounded-lg" />
|
||||||
|
<p className="text-xs font-medium">Accent</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-accent</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-card h-20 rounded-lg border" />
|
||||||
|
<p className="text-xs font-medium">Card</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-card</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-popover h-20 rounded-lg border" />
|
||||||
|
<p className="text-xs font-medium">Popover</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-popover</code>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-background h-20 rounded-lg border" />
|
||||||
|
<p className="text-xs font-medium">Background</p>
|
||||||
|
<code className="text-muted-foreground text-[10px]">bg-background</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-muted-foreground text-center text-sm">
|
||||||
|
모든 컴포넌트는{" "}
|
||||||
|
<a
|
||||||
|
href="https://ui.shadcn.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground underline"
|
||||||
|
>
|
||||||
|
shadcn/ui
|
||||||
|
</a>
|
||||||
|
를 기반으로 구축되었습니다.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,9 +63,7 @@ export default function ScreenViewPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initComponents = async () => {
|
const initComponents = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("🚀 할당된 화면에서 컴포넌트 시스템 초기화 시작...");
|
|
||||||
await initializeComponents();
|
await initializeComponents();
|
||||||
console.log("✅ 할당된 화면에서 컴포넌트 시스템 초기화 완료");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
|
console.error("❌ 할당된 화면에서 컴포넌트 시스템 초기화 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -159,16 +157,6 @@ export default function ScreenViewPage() {
|
||||||
const scaleY = containerHeight / designHeight;
|
const scaleY = containerHeight / designHeight;
|
||||||
const newScale = Math.min(scaleX, scaleY);
|
const newScale = Math.min(scaleX, scaleY);
|
||||||
|
|
||||||
console.log("📏 캔버스 스케일 계산:", {
|
|
||||||
designWidth,
|
|
||||||
designHeight,
|
|
||||||
containerWidth,
|
|
||||||
containerHeight,
|
|
||||||
scaleX,
|
|
||||||
scaleY,
|
|
||||||
finalScale: newScale,
|
|
||||||
});
|
|
||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -285,31 +273,22 @@ export default function ScreenViewPage() {
|
||||||
flowSelectedData={flowSelectedData}
|
flowSelectedData={flowSelectedData}
|
||||||
flowSelectedStepId={flowSelectedStepId}
|
flowSelectedStepId={flowSelectedStepId}
|
||||||
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => {
|
||||||
console.log("🔍 [page.tsx] 플로우 선택된 데이터 받음:", {
|
|
||||||
dataCount: selectedData.length,
|
|
||||||
selectedData,
|
|
||||||
stepId,
|
|
||||||
});
|
|
||||||
setFlowSelectedData(selectedData);
|
setFlowSelectedData(selectedData);
|
||||||
setFlowSelectedStepId(stepId);
|
setFlowSelectedStepId(stepId);
|
||||||
console.log("🔍 [page.tsx] 상태 업데이트 완료");
|
|
||||||
}}
|
}}
|
||||||
refreshKey={tableRefreshKey}
|
refreshKey={tableRefreshKey}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
console.log("🔄 테이블 새로고침 요청됨");
|
|
||||||
setTableRefreshKey((prev) => prev + 1);
|
setTableRefreshKey((prev) => prev + 1);
|
||||||
setSelectedRowsData([]); // 선택 해제
|
setSelectedRowsData([]); // 선택 해제
|
||||||
}}
|
}}
|
||||||
flowRefreshKey={flowRefreshKey}
|
flowRefreshKey={flowRefreshKey}
|
||||||
onFlowRefresh={() => {
|
onFlowRefresh={() => {
|
||||||
console.log("🔄 플로우 새로고침 요청됨");
|
|
||||||
setFlowRefreshKey((prev) => prev + 1);
|
setFlowRefreshKey((prev) => prev + 1);
|
||||||
setFlowSelectedData([]); // 선택 해제
|
setFlowSelectedData([]); // 선택 해제
|
||||||
setFlowSelectedStepId(null);
|
setFlowSelectedStepId(null);
|
||||||
}}
|
}}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
console.log("📝 폼 데이터 변경:", fieldName, "=", value);
|
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -353,7 +332,6 @@ export default function ScreenViewPage() {
|
||||||
}}
|
}}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
console.log("📝 폼 데이터 변경 (자식):", fieldName, "=", value);
|
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,145 +1,158 @@
|
||||||
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */
|
/* ===== 서명용 손글씨 폰트 ===== */
|
||||||
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
|
@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 "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
/* ===== Dark Mode Variant ===== */
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
/* ===== Tailwind Theme Extensions ===== */
|
||||||
--color-background: var(--background);
|
@theme {
|
||||||
--color-foreground: var(--foreground);
|
/* Color System - HSL Format (shadcn/ui Standard) */
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
|
||||||
|
/* Chart Colors */
|
||||||
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
|
--color-chart-3: hsl(var(--chart-3));
|
||||||
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
|
||||||
|
/* Sidebar Colors */
|
||||||
|
--color-sidebar: hsl(var(--sidebar-background));
|
||||||
|
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||||
|
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||||
|
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||||
|
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||||
|
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||||
|
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||||
|
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||||
|
|
||||||
|
/* Font Families */
|
||||||
--font-sans: var(--font-inter);
|
--font-sans: var(--font-inter);
|
||||||
--font-mono: var(--font-jetbrains-mono);
|
--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-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== CSS Variables (shadcn/ui Official) ===== */
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
/* Light Theme Colors - HSL Format */
|
||||||
--background: oklch(1 0 0);
|
--background: 0 0% 100%;
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: 222.2 84% 4.9%;
|
||||||
--card: oklch(1 0 0);
|
--card: 0 0% 100%;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: oklch(1 0 0);
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: 222.2 47.4% 11.2%;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: 210 40% 96.1%;
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: 210 40% 96.1%;
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: 0 84.2% 60.2%;
|
||||||
--success: oklch(0.647 0.176 142.5);
|
--destructive-foreground: 210 40% 98%;
|
||||||
--warning: oklch(0.808 0.171 85.6);
|
--border: 214.3 31.8% 91.4%;
|
||||||
--border: oklch(0.922 0 0);
|
--input: 214.3 31.8% 91.4%;
|
||||||
--input: oklch(0.922 0 0);
|
--ring: 222.2 84% 4.9%;
|
||||||
--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);
|
|
||||||
|
|
||||||
/* Z-Index 계층 구조 */
|
/* Chart Colors */
|
||||||
--z-background: 1;
|
--chart-1: 12 76% 61%;
|
||||||
--z-layout: 10;
|
--chart-2: 173 58% 39%;
|
||||||
--z-content: 50;
|
--chart-3: 197 37% 24%;
|
||||||
--z-floating: 100;
|
--chart-4: 43 74% 66%;
|
||||||
--z-modal: 1000;
|
--chart-5: 27 87% 67%;
|
||||||
--z-tooltip: 2000;
|
|
||||||
--z-critical: 3000;
|
/* Border Radius */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
|
||||||
|
/* Sidebar Colors */
|
||||||
|
--sidebar-background: 0 0% 98%;
|
||||||
|
--sidebar-foreground: 240 5.3% 26.1%;
|
||||||
|
--sidebar-primary: 240 5.9% 10%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
|
--sidebar-accent: 240 4.8% 95.9%;
|
||||||
|
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||||
|
--sidebar-border: 220 13% 91%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Dark Theme ===== */
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: 210 40% 98%;
|
||||||
--card: oklch(0.205 0 0);
|
--card: 222.2 84% 4.9%;
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: oklch(0.922 0 0);
|
--primary: 210 40% 98%;
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: 217.2 32.6% 17.5%;
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: 217.2 32.6% 17.5%;
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: 210 40% 98%;
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: 0 62.8% 30.6%;
|
||||||
--success: oklch(0.697 0.17 142.5);
|
--destructive-foreground: 210 40% 98%;
|
||||||
--warning: oklch(0.808 0.171 85.6);
|
--border: 217.2 32.6% 17.5%;
|
||||||
--border: oklch(1 0 0 / 10%);
|
--input: 217.2 32.6% 17.5%;
|
||||||
--input: oklch(1 0 0 / 15%);
|
--ring: 212.7 26.8% 83.9%;
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
/* Chart Colors - Dark */
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-1: 220 70% 50%;
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-2: 160 60% 45%;
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-3: 30 80% 55%;
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-4: 280 65% 60%;
|
||||||
--sidebar: oklch(0.205 0 0);
|
--chart-5: 340 75% 55%;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
/* Sidebar Colors - Dark */
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-background: 222.2 84% 4.9%;
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-primary: 217.2 91.2% 59.8%;
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
/* ===== Base Styles ===== */
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
border-color: hsl(var(--border));
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dialog 오버레이 커스터마이징 - 어두운 배경 */
|
body {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Dialog/Modal Overlay ===== */
|
||||||
|
/* Radix UI Dialog Overlay - 60% 불투명도 배경 */
|
||||||
[data-radix-dialog-overlay],
|
[data-radix-dialog-overlay],
|
||||||
.fixed.inset-0.z-50.bg-black {
|
.fixed.inset-0.z-50.bg-black {
|
||||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
|
@ -151,3 +164,150 @@
|
||||||
background-color: rgba(0, 0, 0, 0.6) !important;
|
background-color: rgba(0, 0, 0, 0.6) !important;
|
||||||
backdrop-filter: none !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 ===== */
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export default function RootLayout({
|
||||||
<div id="root" className="h-full">
|
<div id="root" className="h-full">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<RegistryProvider>{children}</RegistryProvider>
|
<RegistryProvider>{children}</RegistryProvider>
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" />
|
||||||
<ScreenModal />
|
<ScreenModal />
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
{/* Portal 컨테이너 */}
|
{/* Portal 컨테이너 */}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface ScreenModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
screenId: number | null;
|
screenId: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
|
description?: string;
|
||||||
size: "sm" | "md" | "lg" | "xl";
|
size: "sm" | "md" | "lg" | "xl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
screenId: null,
|
screenId: null,
|
||||||
title: "",
|
title: "",
|
||||||
|
description: "",
|
||||||
size: "md",
|
size: "md",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,21 +95,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 전역 모달 이벤트 리스너
|
// 전역 모달 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenModal = (event: CustomEvent) => {
|
const handleOpenModal = (event: CustomEvent) => {
|
||||||
const { screenId, title, size } = event.detail;
|
const { screenId, title, description, size } = event.detail;
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
screenId,
|
screenId,
|
||||||
title,
|
title,
|
||||||
|
description: description || "",
|
||||||
size,
|
size,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
console.log("🚪 ScreenModal 닫기 이벤트 수신");
|
|
||||||
setModalState({
|
setModalState({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
screenId: null,
|
screenId: null,
|
||||||
title: "",
|
title: "",
|
||||||
|
description: "",
|
||||||
size: "md",
|
size: "md",
|
||||||
});
|
});
|
||||||
setScreenData(null);
|
setScreenData(null);
|
||||||
|
|
@ -215,6 +218,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
||||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
|
||||||
|
{modalState.description && !loading && (
|
||||||
|
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
|
||||||
|
)}
|
||||||
{loading && (
|
{loading && (
|
||||||
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,421 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { CustomCalendar } from "@/components/ui/custom-calendar";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { AlertCircle, CheckCircle, Calendar as CalendarIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
category: string;
|
||||||
|
priority: string;
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
category?: string;
|
||||||
|
startDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExampleFormDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
category: "",
|
||||||
|
priority: "medium",
|
||||||
|
description: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 폼 유효성 검사
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: FormErrors = {};
|
||||||
|
|
||||||
|
// 이름 검증
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
newErrors.name = "이름을 입력해주세요";
|
||||||
|
} else if (formData.name.length < 2) {
|
||||||
|
newErrors.name = "이름은 2자 이상이어야 합니다";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이메일 검증
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
newErrors.email = "이메일을 입력해주세요";
|
||||||
|
} else if (!emailRegex.test(formData.email)) {
|
||||||
|
newErrors.email = "올바른 이메일 형식이 아닙니다";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전화번호 검증
|
||||||
|
const phoneRegex = /^[0-9-]+$/;
|
||||||
|
if (formData.phone && !phoneRegex.test(formData.phone)) {
|
||||||
|
newErrors.phone = "올바른 전화번호 형식이 아닙니다";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 검증
|
||||||
|
if (!formData.category) {
|
||||||
|
newErrors.category = "카테고리를 선택해주세요";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 검증
|
||||||
|
if (formData.startDate && formData.endDate) {
|
||||||
|
if (formData.startDate > formData.endDate) {
|
||||||
|
newErrors.startDate = "시작일은 종료일보다 이전이어야 합니다";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// API 호출 시뮬레이션 (실제로는 API 호출)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
console.log("폼 데이터:", formData);
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
// 폼 초기화
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
category: "",
|
||||||
|
priority: "medium",
|
||||||
|
description: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 필드 변경
|
||||||
|
const handleChange = (field: keyof FormData, value: any) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// 해당 필드의 에러 제거
|
||||||
|
if (errors[field as keyof FormErrors]) {
|
||||||
|
setErrors((prev) => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[field as keyof FormErrors];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 취소
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
category: "",
|
||||||
|
priority: "medium",
|
||||||
|
description: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
setErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 트리거 버튼 */}
|
||||||
|
<Button onClick={() => setIsOpen(true)} className="gap-2">
|
||||||
|
예시 폼 열기
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 폼 Dialog */}
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">사용자 정보 등록</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
아래 정보를 입력하여 새로운 사용자를 등록하세요. 필수 항목은 * 표시되어 있습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 폼 컨텐츠 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 이름 (필수) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-xs sm:text-sm">
|
||||||
|
이름 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleChange("name", e.target.value)}
|
||||||
|
placeholder="홍길동"
|
||||||
|
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.name && "border-destructive")}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-destructive flex items-center gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{errors.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이메일 (필수) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-xs sm:text-sm">
|
||||||
|
이메일 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleChange("email", e.target.value)}
|
||||||
|
placeholder="example@email.com"
|
||||||
|
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.email && "border-destructive")}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-destructive flex items-center gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{errors.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!errors.email && formData.email && formData.email.includes("@") && (
|
||||||
|
<p className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||||
|
올바른 형식입니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전화번호 (선택) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone" className="text-xs sm:text-sm">
|
||||||
|
전화번호
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => handleChange("phone", e.target.value)}
|
||||||
|
placeholder="010-1234-5678"
|
||||||
|
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.phone && "border-destructive")}
|
||||||
|
/>
|
||||||
|
{errors.phone && (
|
||||||
|
<p className="text-destructive flex items-center gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{errors.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 & 우선순위 (같은 줄) */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{/* 카테고리 (필수) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||||
|
카테고리 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select value={formData.category} onValueChange={(value) => handleChange("category", value)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="category"
|
||||||
|
className={cn("h-8 text-xs sm:h-10 sm:text-sm", errors.category && "border-destructive")}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="customer">고객</SelectItem>
|
||||||
|
<SelectItem value="partner">파트너</SelectItem>
|
||||||
|
<SelectItem value="supplier">공급업체</SelectItem>
|
||||||
|
<SelectItem value="employee">직원</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.category && (
|
||||||
|
<p className="text-destructive flex items-center gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{errors.category}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우선순위 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="priority" className="text-xs sm:text-sm">
|
||||||
|
우선순위
|
||||||
|
</Label>
|
||||||
|
<Select value={formData.priority} onValueChange={(value) => handleChange("priority", value)}>
|
||||||
|
<SelectTrigger id="priority" className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">낮음</SelectItem>
|
||||||
|
<SelectItem value="medium">보통</SelectItem>
|
||||||
|
<SelectItem value="high">높음</SelectItem>
|
||||||
|
<SelectItem value="urgent">긴급</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시작일 & 종료일 (같은 줄) */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{/* 시작일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">시작일</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full justify-start text-left text-xs font-normal sm:h-10 sm:text-sm",
|
||||||
|
!formData.startDate && "text-muted-foreground",
|
||||||
|
errors.startDate && "border-destructive",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formData.startDate ? (
|
||||||
|
formData.startDate.toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span>날짜 선택</span>
|
||||||
|
)}
|
||||||
|
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<CustomCalendar
|
||||||
|
mode="single"
|
||||||
|
selected={formData.startDate}
|
||||||
|
onSelect={(date) => handleChange("startDate", date)}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{errors.startDate && (
|
||||||
|
<p className="text-destructive flex items-center gap-1 text-xs">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{errors.startDate}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종료일 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs sm:text-sm">종료일</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full justify-start text-left text-xs font-normal sm:h-10 sm:text-sm",
|
||||||
|
!formData.endDate && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formData.endDate ? (
|
||||||
|
formData.endDate.toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span>날짜 선택</span>
|
||||||
|
)}
|
||||||
|
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0">
|
||||||
|
<CustomCalendar
|
||||||
|
mode="single"
|
||||||
|
selected={formData.endDate}
|
||||||
|
onSelect={(date) => handleChange("endDate", date)}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||||
|
설명
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => handleChange("description", e.target.value)}
|
||||||
|
placeholder="추가 정보를 입력하세요..."
|
||||||
|
className="min-h-[80px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">{formData.description.length} / 500자</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 활성화 상태 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="isActive" className="text-xs font-medium sm:text-sm">
|
||||||
|
활성화 상태
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">사용자 계정을 활성화합니다</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="isActive"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onCheckedChange={(checked) => handleChange("isActive", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "처리 중..." : "등록"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -224,6 +224,17 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
<Label htmlFor="screenName">화면명</Label>
|
<Label htmlFor="screenName">화면명</Label>
|
||||||
<Input id="screenName" value={screenName} onChange={(e) => setScreenName(e.target.value)} />
|
<Input id="screenName" value={screenName} onChange={(e) => setScreenName(e.target.value)} />
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="screenCode">화면 코드</Label>
|
<Label htmlFor="screenCode">화면 코드</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,383 +1,363 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
import { X, Save, RotateCcw } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
||||||
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
|
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ComponentData } from "@/lib/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
|
||||||
interface EditModalProps {
|
interface EditModalState {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
screenId: number | null;
|
||||||
screenId?: number;
|
title: string;
|
||||||
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
description?: string;
|
||||||
editData?: any;
|
modalSize: "sm" | "md" | "lg" | "xl";
|
||||||
|
editData: Record<string, any>;
|
||||||
onSave?: () => void;
|
onSave?: () => void;
|
||||||
onDataChange?: (formData: Record<string, any>) => void; // 폼 데이터 변경 콜백 추가
|
|
||||||
modalTitle?: string; // 모달 제목
|
|
||||||
modalDescription?: string; // 모달 설명
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface EditModalProps {
|
||||||
* 편집 모달 컴포넌트
|
className?: string;
|
||||||
* 선택된 데이터를 폼 화면에 로드하여 편집할 수 있게 해주는 모달
|
}
|
||||||
*/
|
|
||||||
export const EditModal: React.FC<EditModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
screenId,
|
|
||||||
modalSize = "lg",
|
|
||||||
editData,
|
|
||||||
onSave,
|
|
||||||
onDataChange,
|
|
||||||
modalTitle,
|
|
||||||
modalDescription,
|
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [formData, setFormData] = useState<any>({});
|
|
||||||
const [originalData, setOriginalData] = useState<any>({}); // 부분 업데이트용 원본 데이터
|
|
||||||
const [screenData, setScreenData] = useState<any>(null);
|
|
||||||
const [components, setComponents] = useState<ComponentData[]>([]);
|
|
||||||
|
|
||||||
// 컴포넌트 기반 동적 크기 계산
|
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||||
const calculateModalSize = () => {
|
const [modalState, setModalState] = useState<EditModalState>({
|
||||||
|
isOpen: false,
|
||||||
|
screenId: null,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
modalSize: "md",
|
||||||
|
editData: {},
|
||||||
|
onSave: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [screenData, setScreenData] = useState<{
|
||||||
|
components: ComponentData[];
|
||||||
|
screenInfo: any;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [screenDimensions, setScreenDimensions] = useState<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
offsetX?: number;
|
||||||
|
offsetY?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
|
||||||
|
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||||
if (components.length === 0) {
|
if (components.length === 0) {
|
||||||
return { width: 600, height: 400 }; // 기본 크기
|
return {
|
||||||
|
width: 400,
|
||||||
|
height: 300,
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백
|
// 모든 컴포넌트의 경계 찾기
|
||||||
|
let minX = Infinity;
|
||||||
|
let minY = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
|
||||||
const maxHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 최소한의 여백만 추가
|
components.forEach((component) => {
|
||||||
|
const x = parseFloat(component.position?.x?.toString() || "0");
|
||||||
|
const y = parseFloat(component.position?.y?.toString() || "0");
|
||||||
|
const width = parseFloat(component.size?.width?.toString() || "100");
|
||||||
|
const height = parseFloat(component.size?.height?.toString() || "40");
|
||||||
|
|
||||||
console.log(`🎯 계산된 모달 크기: ${maxWidth}px x ${maxHeight}px`);
|
minX = Math.min(minX, x);
|
||||||
console.log(
|
minY = Math.min(minY, y);
|
||||||
"📍 컴포넌트 위치들:",
|
maxX = Math.max(maxX, x + width);
|
||||||
components.map((c) => ({ x: c.position?.x, y: c.position?.y, w: c.size?.width, h: c.size?.height })),
|
maxY = Math.max(maxY, y + height);
|
||||||
);
|
});
|
||||||
return { width: maxWidth, height: maxHeight };
|
|
||||||
|
// 실제 컨텐츠 크기 계산
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const contentHeight = maxY - minY;
|
||||||
|
|
||||||
|
// 적절한 여백 추가
|
||||||
|
const paddingX = 40;
|
||||||
|
const paddingY = 40;
|
||||||
|
|
||||||
|
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
||||||
|
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
||||||
|
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
||||||
|
offsetX: Math.max(0, minX - paddingX / 2),
|
||||||
|
offsetY: Math.max(0, minY - paddingY / 2),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const dynamicSize = calculateModalSize();
|
// 전역 모달 이벤트 리스너
|
||||||
|
|
||||||
// EditModal 전용 닫기 이벤트 리스너
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCloseEditModal = () => {
|
const handleOpenEditModal = (event: CustomEvent) => {
|
||||||
console.log("🚪 EditModal: closeEditModal 이벤트 수신");
|
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
|
||||||
onClose();
|
console.log("🚀 EditModal 열기 이벤트 수신:", {
|
||||||
|
screenId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
modalSize,
|
||||||
|
editData,
|
||||||
|
});
|
||||||
|
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
screenId,
|
||||||
|
title,
|
||||||
|
description: description || "",
|
||||||
|
modalSize: modalSize || "lg",
|
||||||
|
editData: editData || {},
|
||||||
|
onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 편집 데이터로 폼 데이터 초기화
|
||||||
|
setFormData(editData || {});
|
||||||
|
setOriginalData(editData || {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseEditModal = () => {
|
||||||
|
console.log("🚪 EditModal 닫기 이벤트 수신");
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("openEditModal", handleOpenEditModal as EventListener);
|
||||||
window.addEventListener("closeEditModal", handleCloseEditModal);
|
window.addEventListener("closeEditModal", handleCloseEditModal);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
|
||||||
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, []);
|
||||||
|
|
||||||
// DialogContent 크기 강제 적용
|
// 화면 데이터 로딩
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && dynamicSize) {
|
if (modalState.isOpen && modalState.screenId) {
|
||||||
// 모달이 렌더링된 후 DOM 직접 조작으로 크기 강제 적용
|
loadScreenData(modalState.screenId);
|
||||||
setTimeout(() => {
|
|
||||||
const dialogContent = document.querySelector('[role="dialog"] > div');
|
|
||||||
const modalContent = document.querySelector('[role="dialog"] [class*="overflow-auto"]');
|
|
||||||
|
|
||||||
if (dialogContent) {
|
|
||||||
const targetWidth = dynamicSize.width;
|
|
||||||
const targetHeight = dynamicSize.height;
|
|
||||||
|
|
||||||
console.log(`🔧 DialogContent 크기 강제 적용: ${targetWidth}px x ${targetHeight}px`);
|
|
||||||
|
|
||||||
dialogContent.style.width = `${targetWidth}px`;
|
|
||||||
dialogContent.style.height = `${targetHeight}px`;
|
|
||||||
dialogContent.style.minWidth = `${targetWidth}px`;
|
|
||||||
dialogContent.style.minHeight = `${targetHeight}px`;
|
|
||||||
dialogContent.style.maxWidth = "95vw";
|
|
||||||
dialogContent.style.maxHeight = "95vh";
|
|
||||||
dialogContent.style.padding = "0";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스크롤 완전 제거
|
|
||||||
if (modalContent) {
|
|
||||||
modalContent.style.overflow = "hidden";
|
|
||||||
console.log("🚫 스크롤 완전 비활성화");
|
|
||||||
}
|
|
||||||
}, 100); // 100ms 지연으로 렌더링 완료 후 실행
|
|
||||||
}
|
}
|
||||||
}, [isOpen, dynamicSize]);
|
}, [modalState.isOpen, modalState.screenId]);
|
||||||
|
|
||||||
// 편집 데이터가 변경되면 폼 데이터 및 원본 데이터 초기화
|
const loadScreenData = async (screenId: number) => {
|
||||||
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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
console.log("💾 편집 데이터 저장:", formData);
|
|
||||||
|
|
||||||
// TODO: 실제 저장 API 호출
|
console.log("화면 데이터 로딩 시작:", screenId);
|
||||||
// const result = await DynamicFormApi.updateFormData({
|
|
||||||
// screenId,
|
|
||||||
// data: formData,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 임시: 저장 성공 시뮬레이션
|
// 화면 정보와 레이아웃 데이터 로딩
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
const [screenInfo, layoutData] = await Promise.all([
|
||||||
|
screenApi.getScreen(screenId),
|
||||||
|
screenApi.getLayout(screenId),
|
||||||
|
]);
|
||||||
|
|
||||||
toast.success("수정이 완료되었습니다.");
|
console.log("API 응답:", { screenInfo, layoutData });
|
||||||
onSave?.();
|
|
||||||
onClose();
|
if (screenInfo && layoutData) {
|
||||||
|
const components = layoutData.components || [];
|
||||||
|
|
||||||
|
// 화면의 실제 크기 계산
|
||||||
|
const dimensions = calculateScreenDimensions(components);
|
||||||
|
setScreenDimensions(dimensions);
|
||||||
|
|
||||||
|
setScreenData({
|
||||||
|
components,
|
||||||
|
screenInfo: screenInfo,
|
||||||
|
});
|
||||||
|
console.log("화면 데이터 설정 완료:", {
|
||||||
|
componentsCount: components.length,
|
||||||
|
dimensions,
|
||||||
|
screenInfo,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error("화면 데이터가 없습니다");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 저장 실패:", error);
|
console.error("화면 데이터 로딩 오류:", error);
|
||||||
toast.error("저장 중 오류가 발생했습니다.");
|
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
|
||||||
|
handleClose();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기화 처리
|
const handleClose = () => {
|
||||||
const handleReset = () => {
|
setModalState({
|
||||||
if (editData) {
|
isOpen: false,
|
||||||
setFormData({ ...editData });
|
screenId: null,
|
||||||
toast.info("초기값으로 되돌렸습니다.");
|
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 || "데이터 수정 중 오류가 발생했습니다.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 모달 크기 클래스 매핑
|
// 모달 크기 설정 - ScreenModal과 동일
|
||||||
const getModalSizeClass = () => {
|
const getModalStyle = () => {
|
||||||
switch (modalSize) {
|
if (!screenDimensions) {
|
||||||
case "sm":
|
return {
|
||||||
return "max-w-md";
|
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||||
case "md":
|
style: {},
|
||||||
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!screenId) {
|
const modalStyle = getModalStyle();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent
|
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
||||||
className="p-0"
|
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||||
style={{
|
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
|
||||||
// 실제 컨텐츠 크기 그대로 적용 (패딩/여백 제거)
|
{modalState.description && !loading && (
|
||||||
width: dynamicSize.width,
|
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
|
||||||
height: dynamicSize.height,
|
)}
|
||||||
minWidth: dynamicSize.width,
|
{loading && (
|
||||||
minHeight: dynamicSize.height,
|
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
|
||||||
maxWidth: "95vw",
|
)}
|
||||||
maxHeight: "95vh",
|
</DialogHeader>
|
||||||
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 ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-500"></div>
|
<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>
|
<p className="text-muted-foreground">화면을 불러오는 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : screenData && components.length > 0 ? (
|
) : screenData ? (
|
||||||
// 원본 화면과 동일한 레이아웃으로 렌더링
|
|
||||||
<div
|
<div
|
||||||
className="relative bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
// 실제 컨텐츠 크기 그대로 적용 (여백 제거)
|
width: screenDimensions?.width || 800,
|
||||||
width: dynamicSize.width,
|
height: screenDimensions?.height || 600,
|
||||||
height: dynamicSize.height,
|
transformOrigin: "center center",
|
||||||
overflow: "hidden",
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 화면 컴포넌트들 원본 레이아웃 유지하여 렌더링 */}
|
{screenData.components.map((component) => {
|
||||||
<div className="relative" style={{ minHeight: "300px" }}>
|
// 컴포넌트 위치를 offset만큼 조정
|
||||||
{components.map((component, index) => (
|
const offsetX = screenDimensions?.offsetX || 0;
|
||||||
<div
|
const offsetY = screenDimensions?.offsetY || 0;
|
||||||
|
|
||||||
|
const adjustedComponent = {
|
||||||
|
...component,
|
||||||
|
position: {
|
||||||
|
...component.position,
|
||||||
|
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||||
|
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InteractiveScreenViewerDynamic
|
||||||
key={component.id}
|
key={component.id}
|
||||||
style={{
|
component={adjustedComponent}
|
||||||
position: "absolute",
|
allComponents={screenData.components}
|
||||||
top: component.position?.y || 0,
|
formData={formData}
|
||||||
left: component.position?.x || 0,
|
onFormDataChange={(fieldName, value) => {
|
||||||
width: component.size?.width || 200,
|
console.log(`🎯 EditModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||||
height: component.size?.height || 40,
|
console.log("📋 현재 formData:", formData);
|
||||||
zIndex: component.position?.z || 1000 + index, // 모달 내부에서 충분히 높은 z-index
|
setFormData((prev) => {
|
||||||
|
const newFormData = {
|
||||||
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
};
|
||||||
|
console.log("📝 EditModal 업데이트된 formData:", newFormData);
|
||||||
|
return newFormData;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
screenInfo={{
|
||||||
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시) */}
|
id: modalState.screenId!,
|
||||||
{component.type === "widget" ? (
|
tableName: screenData.screenInfo?.tableName,
|
||||||
<InteractiveScreenViewer
|
}}
|
||||||
component={component}
|
onSave={handleSave}
|
||||||
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||||
<p className="text-gray-500">화면을 불러올 수 없습니다.</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-400">화면 ID: {screenId}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -385,3 +365,5 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default EditModal;
|
||||||
|
|
|
||||||
|
|
@ -229,21 +229,21 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
||||||
const labelStyle = widget.style || {};
|
const labelStyle = widget.style || {};
|
||||||
const labelElement = (
|
const labelElement = (
|
||||||
<label
|
<label
|
||||||
className={`mb-1 block text-sm font-medium ${hasError ? "text-destructive" : ""}`}
|
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
fontSize: labelStyle.labelFontSize || "14px",
|
fontSize: labelStyle.labelFontSize || "14px",
|
||||||
color: hasError ? "#ef4444" : labelStyle.labelColor || "#212121",
|
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
|
||||||
fontWeight: labelStyle.labelFontWeight || "500",
|
fontWeight: labelStyle.labelFontWeight || "500",
|
||||||
fontFamily: labelStyle.labelFontFamily,
|
fontFamily: labelStyle.labelFontFamily,
|
||||||
textAlign: labelStyle.labelTextAlign || "left",
|
textAlign: labelStyle.labelTextAlign || "left",
|
||||||
backgroundColor: labelStyle.labelBackgroundColor,
|
backgroundColor: labelStyle.labelBackgroundColor,
|
||||||
padding: labelStyle.labelPadding,
|
padding: labelStyle.labelPadding,
|
||||||
borderRadius: labelStyle.labelBorderRadius,
|
borderRadius: labelStyle.labelBorderRadius,
|
||||||
marginBottom: labelStyle.labelMarginBottom || "4px",
|
marginBottom: labelStyle.labelMarginBottom || "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{widget.label}
|
{widget.label}
|
||||||
{widget.required && <span className="ml-1 text-orange-500">*</span>}
|
{(widget.required || widget.componentConfig?.required) && <span className="text-destructive ml-1">*</span>}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={comp.id} className="space-y-1">
|
<div key={comp.id} className="space-y-2">
|
||||||
{renderLabel()}
|
{renderLabel()}
|
||||||
{renderByWebType()}
|
{renderByWebType()}
|
||||||
{renderFieldValidation()}
|
{renderFieldValidation()}
|
||||||
|
|
|
||||||
|
|
@ -470,11 +470,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
maxLength={config?.maxLength}
|
maxLength={config?.maxLength}
|
||||||
pattern={getPatternByFormat(config?.format || "none")}
|
pattern={getPatternByFormat(config?.format || "none")}
|
||||||
className={`w-full ${isAutoInput ? "bg-gray-50 text-gray-700" : ""}`}
|
className={`w-full ${isAutoInput ? "bg-gray-50 text-gray-700" : ""}`}
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
minHeight: "100%",
|
|
||||||
maxHeight: "100%"
|
|
||||||
}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1838,12 +1833,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
{shouldShowLabel && (
|
{shouldShowLabel && (
|
||||||
<div className="block mb-3" style={labelStyle}>
|
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
<div className="inline-flex items-center bg-gray-100 px-3 py-1 rounded-lg text-sm font-semibold">
|
{labelText}
|
||||||
{labelText}
|
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||||
{component.required && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>}
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||||
|
|
|
||||||
|
|
@ -2576,10 +2576,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}),
|
}),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
|
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
|
||||||
labelFontSize: "12px",
|
labelFontSize: "14px",
|
||||||
labelColor: "#212121",
|
labelColor: "#000000", // 순수한 검정
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
labelMarginBottom: "6px",
|
labelMarginBottom: "8px",
|
||||||
},
|
},
|
||||||
componentConfig: {
|
componentConfig: {
|
||||||
type: componentId, // text-input, number-input 등
|
type: componentId, // text-input, number-input 등
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
// 로컬 상태 관리 (실시간 입력 반영)
|
// 로컬 상태 관리 (실시간 입력 반영)
|
||||||
const [localInputs, setLocalInputs] = useState({
|
const [localInputs, setLocalInputs] = useState({
|
||||||
text: config.text !== undefined ? config.text : "버튼",
|
text: config.text !== undefined ? config.text : "버튼",
|
||||||
modalTitle: config.action?.modalTitle || "",
|
modalTitle: String(config.action?.modalTitle || ""),
|
||||||
editModalTitle: config.action?.editModalTitle || "",
|
modalDescription: String(config.action?.modalDescription || ""),
|
||||||
editModalDescription: config.action?.editModalDescription || "",
|
editModalTitle: String(config.action?.editModalTitle || ""),
|
||||||
targetUrl: config.action?.targetUrl || "",
|
editModalDescription: String(config.action?.editModalDescription || ""),
|
||||||
|
targetUrl: String(config.action?.targetUrl || ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
const [screens, setScreens] = useState<ScreenOption[]>([]);
|
||||||
|
|
@ -86,10 +87,11 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
setLocalInputs({
|
setLocalInputs({
|
||||||
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
|
text: latestConfig.text !== undefined ? latestConfig.text : "버튼",
|
||||||
modalTitle: latestAction.modalTitle || "",
|
modalTitle: String(latestAction.modalTitle || ""),
|
||||||
editModalTitle: latestAction.editModalTitle || "",
|
modalDescription: String(latestAction.modalDescription || ""),
|
||||||
editModalDescription: latestAction.editModalDescription || "",
|
editModalTitle: String(latestAction.editModalTitle || ""),
|
||||||
targetUrl: latestAction.targetUrl || "",
|
editModalDescription: String(latestAction.editModalDescription || ""),
|
||||||
|
targetUrl: String(latestAction.targetUrl || ""),
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [component.id]);
|
}, [component.id]);
|
||||||
|
|
@ -288,6 +290,21 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label htmlFor="modal-size">모달 크기</Label>
|
<Label htmlFor="modal-size">모달 크기</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -383,7 +383,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
|
checked={widget.required === true || selectedComponent.componentConfig?.required === true}
|
||||||
onCheckedChange={(checked) => handleUpdate("componentConfig.required", checked)}
|
onCheckedChange={(checked) => {
|
||||||
|
handleUpdate("required", checked);
|
||||||
|
handleUpdate("componentConfig.required", checked);
|
||||||
|
}}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<Label className="text-xs">필수</Label>
|
<Label className="text-xs">필수</Label>
|
||||||
|
|
@ -393,7 +396,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
checked={widget.readonly === true || selectedComponent.componentConfig?.readonly === true}
|
||||||
onCheckedChange={(checked) => handleUpdate("componentConfig.readonly", checked)}
|
onCheckedChange={(checked) => {
|
||||||
|
handleUpdate("readonly", checked);
|
||||||
|
handleUpdate("componentConfig.readonly", checked);
|
||||||
|
}}
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
/>
|
/>
|
||||||
<Label className="text-xs">읽기전용</Label>
|
<Label className="text-xs">읽기전용</Label>
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,6 @@ export function FlowWidget({
|
||||||
try {
|
try {
|
||||||
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
|
// 스텝 카운트는 항상 업데이트 (선택된 스텝 유무와 관계없이)
|
||||||
const countsResponse = await getAllStepCounts(flowId);
|
const countsResponse = await getAllStepCounts(flowId);
|
||||||
console.log("📊 스텝 카운트 API 응답:", countsResponse);
|
|
||||||
|
|
||||||
if (countsResponse.success && countsResponse.data) {
|
if (countsResponse.success && countsResponse.data) {
|
||||||
// Record 형태로 변환
|
// Record 형태로 변환
|
||||||
|
|
@ -248,7 +247,6 @@ export function FlowWidget({
|
||||||
Object.assign(countsMap, countsResponse.data);
|
Object.assign(countsMap, countsResponse.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 스텝 카운트 업데이트:", countsMap);
|
|
||||||
setStepCounts(countsMap);
|
setStepCounts(countsMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,12 +256,6 @@ export function FlowWidget({
|
||||||
|
|
||||||
// 컬럼 라벨 조회
|
// 컬럼 라벨 조회
|
||||||
const labelsResponse = await getStepColumnLabels(flowId, selectedStepId);
|
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) {
|
if (labelsResponse.success && labelsResponse.data) {
|
||||||
setColumnLabels(labelsResponse.data);
|
setColumnLabels(labelsResponse.data);
|
||||||
}
|
}
|
||||||
|
|
@ -412,12 +404,6 @@ export function FlowWidget({
|
||||||
try {
|
try {
|
||||||
// 컬럼 라벨 조회
|
// 컬럼 라벨 조회
|
||||||
const labelsResponse = await getStepColumnLabels(flowId!, firstStep.id);
|
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) {
|
if (labelsResponse.success && labelsResponse.data) {
|
||||||
setColumnLabels(labelsResponse.data);
|
setColumnLabels(labelsResponse.data);
|
||||||
}
|
}
|
||||||
|
|
@ -453,7 +439,6 @@ export function FlowWidget({
|
||||||
// flowRefreshKey가 변경될 때마다 스텝 데이터 새로고침
|
// flowRefreshKey가 변경될 때마다 스텝 데이터 새로고침
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (flowRefreshKey !== undefined && flowRefreshKey > 0 && flowId) {
|
if (flowRefreshKey !== undefined && flowRefreshKey > 0 && flowId) {
|
||||||
console.log("🔄 플로우 새로고침 실행, flowRefreshKey:", flowRefreshKey);
|
|
||||||
refreshStepData();
|
refreshStepData();
|
||||||
}
|
}
|
||||||
}, [flowRefreshKey]);
|
}, [flowRefreshKey]);
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export const TextWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
disabled={readonly || isAutoInput}
|
disabled={readonly || isAutoInput}
|
||||||
required={required}
|
required={required}
|
||||||
className={`h-full w-full ${borderClass}`}
|
className={`w-full ${borderClass}`}
|
||||||
maxLength={config?.maxLength}
|
maxLength={config?.maxLength}
|
||||||
minLength={config?.minLength}
|
minLength={config?.minLength}
|
||||||
pattern={config?.pattern}
|
pattern={config?.pattern}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||||
nav_button_previous: "absolute left-1",
|
nav_button_previous: "absolute left-1",
|
||||||
nav_button_next: "absolute right-1",
|
nav_button_next: "absolute right-1",
|
||||||
table: "w-full border-collapse space-y-1",
|
table: "w-full border-collapse space-y-1",
|
||||||
head_row: "flex",
|
head_row: "flex w-full",
|
||||||
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-9 h-9 font-normal text-[0.8rem] inline-flex items-center justify-center",
|
||||||
row: "flex w-full mt-2",
|
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",
|
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"),
|
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface CustomCalendarProps {
|
||||||
|
selected?: Date;
|
||||||
|
onSelect?: (date: Date | undefined) => void;
|
||||||
|
className?: string;
|
||||||
|
mode?: "single";
|
||||||
|
size?: "sm" | "default" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
|
||||||
|
const MONTHS = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CustomCalendar({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
className,
|
||||||
|
mode = "single",
|
||||||
|
size = "default",
|
||||||
|
}: CustomCalendarProps) {
|
||||||
|
// 크기별 클래스 정의
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: {
|
||||||
|
cell: "h-7 w-7 text-xs",
|
||||||
|
header: "text-xs",
|
||||||
|
day: "text-[0.7rem]",
|
||||||
|
nav: "h-6 w-6",
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
cell: "h-9 w-9 text-sm",
|
||||||
|
header: "text-[0.8rem]",
|
||||||
|
day: "text-sm",
|
||||||
|
nav: "h-7 w-7",
|
||||||
|
},
|
||||||
|
lg: {
|
||||||
|
cell: "h-11 w-11 text-base",
|
||||||
|
header: "text-sm",
|
||||||
|
day: "text-base",
|
||||||
|
nav: "h-8 w-8",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentSize = sizeClasses[size];
|
||||||
|
const [currentDate, setCurrentDate] = React.useState(selected || new Date());
|
||||||
|
const [viewYear, setViewYear] = React.useState(currentDate.getFullYear());
|
||||||
|
const [viewMonth, setViewMonth] = React.useState(currentDate.getMonth());
|
||||||
|
|
||||||
|
const getDaysInMonth = (year: number, month: number) => {
|
||||||
|
return new Date(year, month + 1, 0).getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||||
|
return new Date(year, month, 1).getDay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCalendarDays = () => {
|
||||||
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||||||
|
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
||||||
|
const daysInPrevMonth = getDaysInMonth(viewYear, viewMonth - 1);
|
||||||
|
|
||||||
|
const days: Array<{
|
||||||
|
date: number;
|
||||||
|
month: "prev" | "current" | "next";
|
||||||
|
fullDate: Date;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Previous month days
|
||||||
|
for (let i = firstDay - 1; i >= 0; i--) {
|
||||||
|
const date = daysInPrevMonth - i;
|
||||||
|
days.push({
|
||||||
|
date,
|
||||||
|
month: "prev",
|
||||||
|
fullDate: new Date(viewYear, viewMonth - 1, date),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current month days
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push({
|
||||||
|
date: i,
|
||||||
|
month: "current",
|
||||||
|
fullDate: new Date(viewYear, viewMonth, i),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next month days
|
||||||
|
const remainingDays = 42 - days.length; // 6 rows * 7 days
|
||||||
|
for (let i = 1; i <= remainingDays; i++) {
|
||||||
|
days.push({
|
||||||
|
date: i,
|
||||||
|
month: "next",
|
||||||
|
fullDate: new Date(viewYear, viewMonth + 1, i),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevMonth = () => {
|
||||||
|
if (viewMonth === 0) {
|
||||||
|
setViewMonth(11);
|
||||||
|
setViewYear(viewYear - 1);
|
||||||
|
} else {
|
||||||
|
setViewMonth(viewMonth - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMonth = () => {
|
||||||
|
if (viewMonth === 11) {
|
||||||
|
setViewMonth(0);
|
||||||
|
setViewYear(viewYear + 1);
|
||||||
|
} else {
|
||||||
|
setViewMonth(viewMonth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (date: Date) => {
|
||||||
|
if (onSelect) {
|
||||||
|
onSelect(date);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return (
|
||||||
|
date.getDate() === today.getDate() &&
|
||||||
|
date.getMonth() === today.getMonth() &&
|
||||||
|
date.getFullYear() === today.getFullYear()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSelected = (date: Date) => {
|
||||||
|
if (!selected) return false;
|
||||||
|
return (
|
||||||
|
date.getDate() === selected.getDate() &&
|
||||||
|
date.getMonth() === selected.getMonth() &&
|
||||||
|
date.getFullYear() === selected.getFullYear()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const calendarDays = generateCalendarDays();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("p-3", className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn("bg-transparent p-0 opacity-50 hover:opacity-100", currentSize.nav)}
|
||||||
|
onClick={handlePrevMonth}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 월 선택 */}
|
||||||
|
<Select value={viewMonth.toString()} onValueChange={(value) => setViewMonth(parseInt(value))}>
|
||||||
|
<SelectTrigger className={cn("w-[110px] font-medium", currentSize.header)}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MONTHS.map((month, index) => (
|
||||||
|
<SelectItem key={index} value={index.toString()}>
|
||||||
|
{month}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 연도 선택 */}
|
||||||
|
<Select value={viewYear.toString()} onValueChange={(value) => setViewYear(parseInt(value))}>
|
||||||
|
<SelectTrigger className={cn("w-[80px] font-medium", currentSize.header)}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Array.from({ length: 100 }, (_, i) => {
|
||||||
|
const year = new Date().getFullYear() - 50 + i;
|
||||||
|
return (
|
||||||
|
<SelectItem key={year} value={year.toString()}>
|
||||||
|
{year}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn("bg-transparent p-0 opacity-50 hover:opacity-100", currentSize.nav)}
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days of week */}
|
||||||
|
<div className="mb-2 grid grid-cols-7 gap-0">
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center justify-center font-normal",
|
||||||
|
currentSize.cell,
|
||||||
|
currentSize.day,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-0">
|
||||||
|
{calendarDays.map((day, index) => {
|
||||||
|
const isOutside = day.month !== "current";
|
||||||
|
const isTodayDate = isToday(day.fullDate);
|
||||||
|
const isSelectedDate = isSelected(day.fullDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant="ghost"
|
||||||
|
className={cn(
|
||||||
|
"p-0 font-normal",
|
||||||
|
currentSize.cell,
|
||||||
|
isOutside && "text-muted-foreground opacity-50",
|
||||||
|
isTodayDate && !isSelectedDate && "bg-accent text-accent-foreground",
|
||||||
|
isSelectedDate && "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => handleDateClick(day.fullDate)}
|
||||||
|
>
|
||||||
|
{day.date}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomCalendar.displayName = "CustomCalendar";
|
||||||
|
|
@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"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",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ function SelectTrigger({
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-8 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-48 items-center justify-between gap-2 rounded-md border bg-transparent text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=default]:px-3 data-[size=default]:py-2 data-[size=sm]:h-9 data-[size=sm]:px-3 data-[size=sm]:py-1 data-[size=xs]:h-6 data-[size=xs]:px-2 data-[size=xs]:py-0 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,7 @@ export class DynamicFormApi {
|
||||||
*/
|
*/
|
||||||
static async saveFormData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
|
static async saveFormData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
|
||||||
try {
|
try {
|
||||||
console.log("💾 폼 데이터 저장 요청:", formData);
|
|
||||||
|
|
||||||
const response = await apiClient.post("/dynamic-form/save", formData);
|
const response = await apiClient.post("/dynamic-form/save", formData);
|
||||||
|
|
||||||
console.log("✅ 폼 데이터 저장 성공:", response.data);
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
|
|
@ -64,11 +60,7 @@ export class DynamicFormApi {
|
||||||
*/
|
*/
|
||||||
static async saveData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
|
static async saveData(formData: DynamicFormData): Promise<ApiResponse<SaveFormDataResponse>> {
|
||||||
try {
|
try {
|
||||||
console.log("🚀 개선된 폼 데이터 저장 요청:", formData);
|
|
||||||
|
|
||||||
const response = await apiClient.post("/dynamic-form/save-enhanced", formData);
|
const response = await apiClient.post("/dynamic-form/save-enhanced", formData);
|
||||||
|
|
||||||
console.log("✅ 개선된 폼 데이터 저장 성공:", response.data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
|
console.error("❌ 개선된 폼 데이터 저장 실패:", error);
|
||||||
|
|
@ -400,11 +392,7 @@ export class DynamicFormApi {
|
||||||
*/
|
*/
|
||||||
static async getTablePrimaryKeys(tableName: string): Promise<ApiResponse<string[]>> {
|
static async getTablePrimaryKeys(tableName: string): Promise<ApiResponse<string[]>> {
|
||||||
try {
|
try {
|
||||||
console.log("🔑 테이블 기본키 조회 요청:", tableName);
|
|
||||||
|
|
||||||
const response = await apiClient.get(`/dynamic-form/table/${tableName}/primary-keys`);
|
const response = await apiClient.get(`/dynamic-form/table/${tableName}/primary-keys`);
|
||||||
|
|
||||||
console.log("✅ 테이블 기본키 조회 성공:", response.data);
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: response.data.data,
|
data: response.data.data,
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
|
|
||||||
// 🔍 디버깅: props 확인
|
|
||||||
|
|
||||||
// 🆕 플로우 단계별 표시 제어
|
// 🆕 플로우 단계별 표시 제어
|
||||||
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
|
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
|
||||||
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);
|
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { ComponentRegistryItem } from "../../types";
|
||||||
|
|
||||||
|
export const formLayoutRegistry: ComponentRegistryItem = {
|
||||||
|
name: "form-layout",
|
||||||
|
displayName: "Form Layout",
|
||||||
|
description: "shadcn/ui 스타일의 자동 폼 레이아웃 - 필드를 추가하고 정갈한 입력 폼을 만드세요",
|
||||||
|
category: "form",
|
||||||
|
icon: "FileText",
|
||||||
|
component: () => import("./FormLayoutComponent").then((m) => m.FormLayoutComponent),
|
||||||
|
configPanel: () => import("./FormLayoutConfigPanel").then((m) => m.FormLayoutConfigPanel),
|
||||||
|
defaultProps: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "field-example-1",
|
||||||
|
name: "name",
|
||||||
|
label: "이름",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "이름을 입력하세요",
|
||||||
|
required: true,
|
||||||
|
gridColumn: "half",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field-example-2",
|
||||||
|
name: "email",
|
||||||
|
label: "이메일",
|
||||||
|
type: "email",
|
||||||
|
placeholder: "example@domain.com",
|
||||||
|
required: true,
|
||||||
|
gridColumn: "half",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "field-example-3",
|
||||||
|
name: "description",
|
||||||
|
label: "설명",
|
||||||
|
type: "textarea",
|
||||||
|
placeholder: "상세 설명을 입력하세요",
|
||||||
|
required: false,
|
||||||
|
gridColumn: "full",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
submitButtonText: "제출",
|
||||||
|
cancelButtonText: "취소",
|
||||||
|
columns: 2,
|
||||||
|
},
|
||||||
|
previewImage: "/registry/form-layout-preview.png",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -610,12 +610,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
|
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`}
|
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) => {
|
onClick={(e) => {
|
||||||
console.log("🖱️ Input 클릭됨:", {
|
|
||||||
componentId: component.id,
|
|
||||||
disabled: componentConfig.disabled,
|
|
||||||
readOnly: componentConfig.readonly,
|
|
||||||
autoGenEnabled: testAutoGeneration.enabled,
|
|
||||||
});
|
|
||||||
handleClick(e);
|
handleClick(e);
|
||||||
}}
|
}}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
|
|
@ -633,16 +627,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
|
|
||||||
// isInteractive 모드에서는 formData 업데이트
|
// isInteractive 모드에서는 formData 업데이트
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
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);
|
onFormDataChange(component.columnName, newValue);
|
||||||
console.log(`✅ onFormDataChange 호출 완료`);
|
|
||||||
} else {
|
} else {
|
||||||
console.log("❌ TextInputComponent onFormDataChange 조건 미충족:", {
|
console.log("❌ TextInputComponent onFormDataChange 조건 미충족:", {
|
||||||
isInteractive,
|
isInteractive,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface ButtonActionConfig {
|
||||||
|
|
||||||
// 모달/팝업 관련
|
// 모달/팝업 관련
|
||||||
modalTitle?: string;
|
modalTitle?: string;
|
||||||
|
modalDescription?: string;
|
||||||
modalSize?: "sm" | "md" | "lg" | "xl";
|
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||||
popupWidth?: number;
|
popupWidth?: number;
|
||||||
popupHeight?: number;
|
popupHeight?: number;
|
||||||
|
|
@ -109,10 +110,10 @@ export class ButtonActionExecutor {
|
||||||
return this.handleNavigate(config, context);
|
return this.handleNavigate(config, context);
|
||||||
|
|
||||||
case "modal":
|
case "modal":
|
||||||
return this.handleModal(config, context);
|
return await this.handleModal(config, context);
|
||||||
|
|
||||||
case "edit":
|
case "edit":
|
||||||
return this.handleEdit(config, context);
|
return await this.handleEdit(config, context);
|
||||||
|
|
||||||
case "control":
|
case "control":
|
||||||
return this.handleControl(config, context);
|
return this.handleControl(config, context);
|
||||||
|
|
@ -175,14 +176,6 @@ export class ButtonActionExecutor {
|
||||||
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
|
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
|
||||||
const isUpdate = false; // 현재는 항상 INSERT로 처리
|
const isUpdate = false; // 현재는 항상 INSERT로 처리
|
||||||
|
|
||||||
console.log("💾 저장 모드 판단 (DB 기반):", {
|
|
||||||
tableName,
|
|
||||||
formData,
|
|
||||||
primaryKeys,
|
|
||||||
primaryKeyValue,
|
|
||||||
isUpdate: isUpdate ? "UPDATE" : "INSERT",
|
|
||||||
});
|
|
||||||
|
|
||||||
let saveResult;
|
let saveResult;
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
|
@ -208,7 +201,6 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// INSERT 처리
|
// INSERT 처리
|
||||||
console.log("🆕 INSERT 모드로 저장:", { formData });
|
|
||||||
|
|
||||||
// 🆕 자동으로 작성자 정보 추가
|
// 🆕 자동으로 작성자 정보 추가
|
||||||
const writerValue = context.userId || context.userName || "unknown";
|
const writerValue = context.userId || context.userName || "unknown";
|
||||||
|
|
@ -233,8 +225,6 @@ export class ButtonActionExecutor {
|
||||||
throw new Error(saveResult.message || "저장에 실패했습니다.");
|
throw new Error(saveResult.message || "저장에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ 저장 성공:", saveResult);
|
|
||||||
|
|
||||||
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
|
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
|
||||||
if (config.enableDataflowControl && config.dataflowConfig) {
|
if (config.enableDataflowControl && config.dataflowConfig) {
|
||||||
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
|
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
|
||||||
|
|
@ -262,7 +252,6 @@ export class ButtonActionExecutor {
|
||||||
*/
|
*/
|
||||||
private static extractPrimaryKeyValueFromDB(formData: Record<string, any>, primaryKeys: string[]): any {
|
private static extractPrimaryKeyValueFromDB(formData: Record<string, any>, primaryKeys: string[]): any {
|
||||||
if (!primaryKeys || primaryKeys.length === 0) {
|
if (!primaryKeys || primaryKeys.length === 0) {
|
||||||
console.log("🔍 DB에서 기본키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,9 +272,6 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본키 컬럼이 formData에 없는 경우
|
// 기본키 컬럼이 formData에 없는 경우
|
||||||
console.log(`❌ 기본키 컬럼 '${primaryKeyColumn}'이 formData에 없습니다. INSERT 모드로 처리됩니다.`);
|
|
||||||
console.log("📋 DB 기본키 컬럼들:", primaryKeys);
|
|
||||||
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,8 +305,6 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 키를 찾지 못한 경우
|
// 기본 키를 찾지 못한 경우
|
||||||
console.log("🔍 추측 기반으로 기본 키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
|
|
||||||
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,7 +503,7 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 모달 액션 처리
|
* 모달 액션 처리
|
||||||
*/
|
*/
|
||||||
private static handleModal(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
// 모달 열기 로직
|
// 모달 열기 로직
|
||||||
console.log("모달 열기:", {
|
console.log("모달 열기:", {
|
||||||
title: config.modalTitle,
|
title: config.modalTitle,
|
||||||
|
|
@ -528,11 +512,25 @@ export class ButtonActionExecutor {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.targetScreenId) {
|
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", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
screenId: config.targetScreenId,
|
screenId: config.targetScreenId,
|
||||||
title: config.modalTitle || "화면",
|
title: config.modalTitle || "화면",
|
||||||
|
description: description,
|
||||||
size: config.modalSize || "md",
|
size: config.modalSize || "md",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -634,7 +632,7 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 편집 액션 처리
|
* 편집 액션 처리
|
||||||
*/
|
*/
|
||||||
private static handleEdit(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
const { selectedRowsData, flowSelectedData } = context;
|
const { selectedRowsData, flowSelectedData } = context;
|
||||||
|
|
||||||
// 플로우 선택 데이터 우선 사용
|
// 플로우 선택 데이터 우선 사용
|
||||||
|
|
@ -671,7 +669,7 @@ export class ButtonActionExecutor {
|
||||||
const rowData = dataToEdit[0];
|
const rowData = dataToEdit[0];
|
||||||
console.log("📝 단일 항목 편집:", rowData);
|
console.log("📝 단일 항목 편집:", rowData);
|
||||||
|
|
||||||
this.openEditForm(config, rowData, context);
|
await this.openEditForm(config, rowData, context);
|
||||||
} else {
|
} else {
|
||||||
// 다중 항목 편집 - 현재는 단일 편집만 지원
|
// 다중 항목 편집 - 현재는 단일 편집만 지원
|
||||||
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
|
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
|
||||||
|
|
@ -688,13 +686,17 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 편집 폼 열기 (단일 항목)
|
* 편집 폼 열기 (단일 항목)
|
||||||
*/
|
*/
|
||||||
private static openEditForm(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
|
private static async openEditForm(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
rowData: any,
|
||||||
|
context: ButtonActionContext,
|
||||||
|
): Promise<void> {
|
||||||
const editMode = config.editMode || "modal";
|
const editMode = config.editMode || "modal";
|
||||||
|
|
||||||
switch (editMode) {
|
switch (editMode) {
|
||||||
case "modal":
|
case "modal":
|
||||||
// 모달로 편집 폼 열기
|
// 모달로 편집 폼 열기
|
||||||
this.openEditModal(config, rowData, context);
|
await this.openEditModal(config, rowData, context);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "navigate":
|
case "navigate":
|
||||||
|
|
@ -716,17 +718,36 @@ export class ButtonActionExecutor {
|
||||||
/**
|
/**
|
||||||
* 편집 모달 열기
|
* 편집 모달 열기
|
||||||
*/
|
*/
|
||||||
private static openEditModal(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
|
private static async openEditModal(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
rowData: any,
|
||||||
|
context: ButtonActionContext,
|
||||||
|
): Promise<void> {
|
||||||
console.log("🎭 편집 모달 열기:", {
|
console.log("🎭 편집 모달 열기:", {
|
||||||
targetScreenId: config.targetScreenId,
|
targetScreenId: config.targetScreenId,
|
||||||
modalSize: config.modalSize,
|
modalSize: config.modalSize,
|
||||||
rowData,
|
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", {
|
const modalEvent = new CustomEvent("openEditModal", {
|
||||||
detail: {
|
detail: {
|
||||||
screenId: config.targetScreenId,
|
screenId: config.targetScreenId,
|
||||||
|
title: config.editModalTitle || "데이터 수정",
|
||||||
|
description: description,
|
||||||
modalSize: config.modalSize || "lg",
|
modalSize: config.modalSize || "lg",
|
||||||
editData: rowData,
|
editData: rowData,
|
||||||
onSave: () => {
|
onSave: () => {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"next": "15.4.4",
|
"next": "15.4.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|
@ -11575,9 +11575,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-day-picker": {
|
"node_modules/react-day-picker": {
|
||||||
"version": "9.11.0",
|
"version": "9.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
|
||||||
"integrity": "sha512-L4FYOaPrr3+AEROeP6IG2mCORZZfxJDkJI2df8mv1jyPrNYeccgmFPZDaHyAuPCBCddQFozkxbikj2NhMEYfDQ==",
|
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@date-fns/tz": "^1.4.1",
|
"@date-fns/tz": "^1.4.1",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
"mammoth": "^1.11.0",
|
"mammoth": "^1.11.0",
|
||||||
"next": "15.4.4",
|
"next": "15.4.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-day-picker": "^9.9.0",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dnd": "^16.0.1",
|
"react-dnd": "^16.0.1",
|
||||||
"react-dnd-html5-backend": "^16.0.1",
|
"react-dnd-html5-backend": "^16.0.1",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue