스타일 수정중

This commit is contained in:
kjs 2025-10-30 12:03:50 +09:00
parent 244f04a199
commit 556354219a
28 changed files with 3210 additions and 321 deletions

View File

@ -229,14 +229,6 @@ export class DynamicFormService {
...actualData ...actualData
} = data; } = data;
console.log("🔍 [dynamicFormService] 받은 데이터:", {
전체데이터: data,
writer,
company_code,
created_by,
updated_by,
});
// 기본 데이터 준비 // 기본 데이터 준비
const dataToInsert: any = { ...actualData }; const dataToInsert: any = { ...actualData };
@ -259,21 +251,12 @@ export class DynamicFormService {
// 작성자 정보 추가 (writer 컬럼 우선, 없으면 created_by/updated_by) // 작성자 정보 추가 (writer 컬럼 우선, 없으면 created_by/updated_by)
if (writer && tableColumns.includes("writer")) { if (writer && tableColumns.includes("writer")) {
console.log(`✅ writer 추가: ${writer}`);
dataToInsert.writer = writer; dataToInsert.writer = writer;
} else {
console.log(`❌ writer 추가 실패:`, {
hasWriter: !!writer,
writerValue: writer,
hasColumn: tableColumns.includes("writer"),
});
} }
if (created_by && tableColumns.includes("created_by")) { if (created_by && tableColumns.includes("created_by")) {
console.log(`✅ created_by 추가: ${created_by}`);
dataToInsert.created_by = created_by; dataToInsert.created_by = created_by;
} }
if (updated_by && tableColumns.includes("updated_by")) { if (updated_by && tableColumns.includes("updated_by")) {
console.log(`✅ updated_by 추가: ${updated_by}`);
dataToInsert.updated_by = updated_by; dataToInsert.updated_by = updated_by;
} }
if (company_code && tableColumns.includes("company_code")) { if (company_code && tableColumns.includes("company_code")) {
@ -299,18 +282,9 @@ export class DynamicFormService {
`⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"` `⚠️ company_code 길이 제한: 앞의 32자로 자름 -> "${processedCompanyCode}"`
); );
} }
console.log(`✅ company_code 추가: ${processedCompanyCode}`);
dataToInsert.company_code = processedCompanyCode; dataToInsert.company_code = processedCompanyCode;
} else {
console.log(`❌ company_code 추가 실패:`, {
hasCompanyCode: !!company_code,
companyCodeValue: company_code,
hasColumn: tableColumns.includes("company_code"),
});
} }
console.log("🔍 [dynamicFormService] 최종 저장 데이터:", dataToInsert);
// 날짜/시간 문자열을 적절한 형태로 변환 // 날짜/시간 문자열을 적절한 형태로 변환
Object.keys(dataToInsert).forEach((key) => { Object.keys(dataToInsert).forEach((key) => {
const value = dataToInsert[key]; const value = dataToInsert[key];

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -26,13 +26,6 @@ export default function ScreenViewPage() {
// 🆕 현재 로그인한 사용자 정보 // 🆕 현재 로그인한 사용자 정보
const { user, userName, companyCode } = useAuth(); const { user, userName, companyCode } = useAuth();
console.log("🔍 [page.tsx] useAuth 결과:", {
user,
userId: user?.userId,
userName,
companyCode,
});
const [screen, setScreen] = useState<ScreenDefinition | null>(null); const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null); const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -70,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);
} }
@ -166,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);
} }
}; };
@ -292,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 }));
}} }}
> >
@ -360,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 }));
}} }}
/> />

View File

@ -1,139 +1,160 @@
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */ /* ===== 서명용 손글씨 폰트 ===== */
@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); /* Border Radius */
--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-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%;
--border: oklch(0.922 0 0); --destructive-foreground: 210 40% 98%;
--input: oklch(0.922 0 0); --border: 214.3 31.8% 91.4%;
--ring: oklch(0.708 0 0); --input: 214.3 31.8% 91.4%;
--chart-1: oklch(0.646 0.222 41.116); --ring: 222.2 84% 4.9%;
--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%;
--border: oklch(1 0 0 / 10%); --destructive-foreground: 210 40% 98%;
--input: oklch(1 0 0 / 15%); --border: 217.2 32.6% 17.5%;
--ring: oklch(0.556 0 0); --input: 217.2 32.6% 17.5%;
--chart-1: oklch(0.488 0.243 264.376); --ring: 212.7 26.8% 83.9%;
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); /* Chart Colors - Dark */
--chart-4: oklch(0.627 0.265 303.9); --chart-1: 220 70% 50%;
--chart-5: oklch(0.645 0.246 16.439); --chart-2: 160 60% 45%;
--sidebar: oklch(0.205 0 0); --chart-3: 30 80% 55%;
--sidebar-foreground: oklch(0.985 0 0); --chart-4: 280 65% 60%;
--sidebar-primary: oklch(0.488 0.243 264.376); --chart-5: 340 75% 55%;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); /* Sidebar Colors - Dark */
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-background: 222.2 84% 4.9%;
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-foreground: 210 40% 98%;
--sidebar-ring: oklch(0.556 0 0); --sidebar-primary: 217.2 91.2% 59.8%;
--sidebar-primary-foreground: 222.2 47.4% 11.2%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
@layer base { /* ===== 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;
@ -145,3 +166,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 ===== */

View File

@ -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 컨테이너 */}

View File

@ -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>
)} )}

View File

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

View File

@ -224,6 +224,17 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
<Label htmlFor="screenName"></Label> <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

View File

@ -1,7 +1,7 @@
"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 { Button } from "@/components/ui/button";
import { X, Save, RotateCcw } from "lucide-react"; import { X, Save, RotateCcw } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -51,9 +51,15 @@ export const EditModal: React.FC<EditModalProps> = ({
const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백 const maxWidth = Math.max(...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)), 500) + 100; // 더 넉넉한 여백
const maxHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 최소한의 여백만 추가 const contentHeight = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)), 400) + 20; // 컨텐츠 높이
console.log(`🎯 계산된 모달 크기: ${maxWidth}px x ${maxHeight}px`); // 헤더 높이 추가 (ScreenModal과 동일)
const headerHeight = 60; // DialogHeader 높이 (타이틀 + 패딩)
const maxHeight = contentHeight + headerHeight;
console.log(
`🎯 계산된 모달 크기: ${maxWidth}px x ${maxHeight}px (컨텐츠: ${contentHeight}px + 헤더: ${headerHeight}px)`,
);
console.log( console.log(
"📍 컴포넌트 위치들:", "📍 컴포넌트 위치들:",
components.map((c) => ({ x: c.position?.x, y: c.position?.y, w: c.size?.width, h: c.size?.height })), components.map((c) => ({ x: c.position?.x, y: c.position?.y, w: c.size?.width, h: c.size?.height })),
@ -123,17 +129,13 @@ export const EditModal: React.FC<EditModalProps> = ({
console.log("📋 originalData 설정 완료:", dataClone); console.log("📋 originalData 설정 완료:", dataClone);
console.log("📋 formData 설정 완료:", dataClone); console.log("📋 formData 설정 완료:", dataClone);
} else { } else {
console.log("⚠️ editData가 없습니다.");
setOriginalData({}); setOriginalData({});
setFormData({}); setFormData({});
} }
}, [editData]); }, [editData]);
// formData 변경 시 로그 // formData 변경 시 로그
useEffect(() => { useEffect(() => {}, [formData]);
console.log("🔄 EditModal formData 상태 변경:", formData);
console.log("🔄 formData 키들:", Object.keys(formData || {}));
}, [formData]);
// 화면 데이터 로드 // 화면 데이터 로드
useEffect(() => { useEffect(() => {
@ -252,7 +254,7 @@ export const EditModal: React.FC<EditModalProps> = ({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent <DialogContent
className="p-0" className="overflow-hidden p-0"
style={{ style={{
// 실제 컨텐츠 크기 그대로 적용 (패딩/여백 제거) // 실제 컨텐츠 크기 그대로 적용 (패딩/여백 제거)
width: dynamicSize.width, width: dynamicSize.width,
@ -265,27 +267,23 @@ export const EditModal: React.FC<EditModalProps> = ({
}} }}
data-radix-portal="true" data-radix-portal="true"
> >
{/* 모달 헤더 (제목/설명이 있으면 표시) */} {/* 모달 헤더 - 항상 표시 (ScreenModal과 동일 구조) */}
{(modalTitle || modalDescription) && ( <DialogHeader className="shrink-0 border-b px-4 py-3">
<DialogHeader className="border-b bg-gray-50 px-6 py-4"> <DialogTitle className="text-base">{modalTitle || "데이터 수정"}</DialogTitle>
<DialogTitle className="text-lg font-semibold">{modalTitle || "수정"}</DialogTitle> {modalDescription && !loading && (
{modalDescription && <p className="mt-1 text-sm text-gray-600">{modalDescription}</p>} <DialogDescription className="text-muted-foreground text-xs">{modalDescription}</DialogDescription>
</DialogHeader>
)} )}
{loading && (
{/* 제목/설명이 없으면 접근성을 위한 숨김 헤더만 표시 */} <DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
{!modalTitle && !modalDescription && (
<DialogHeader className="sr-only">
<DialogTitle></DialogTitle>
</DialogHeader>
)} )}
</DialogHeader>
<div className="flex-1 overflow-hidden"> <div className="flex flex-1 items-center justify-center overflow-auto">
{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 && components.length > 0 ? (

View File

@ -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()}

View File

@ -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 && <span style={{ color: "#f97316", marginLeft: "4px" }}>*</span>} {(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</div> </label>
</div>
)} )}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */} {/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}

View File

@ -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 등

View File

@ -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

View File

@ -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>

View File

@ -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]);

View File

@ -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}

View File

@ -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"),

View File

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

View File

@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} 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,

View File

@ -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}

View File

@ -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,

View File

@ -82,14 +82,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
console.log("🔍 [ButtonPrimaryComponent] Props 확인:", {
userId,
userName,
companyCode,
screenId,
tableName,
});
// 🆕 플로우 단계별 표시 제어 // 🆕 플로우 단계별 표시 제어
const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig; const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig;
const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId); const currentStep = useCurrentFlowStep(flowConfig?.targetFlowComponentId);

View File

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

View File

@ -610,12 +610,6 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")} 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,

View File

@ -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,20 +201,11 @@ 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";
const companyCodeValue = context.companyCode || ""; const companyCodeValue = context.companyCode || "";
console.log("🔍 [buttonActions] 사용자 정보 확인:", {
userId: context.userId,
userName: context.userName,
companyCode: context.companyCode,
writerValue,
companyCodeValue,
});
const dataWithUserInfo = { const dataWithUserInfo = {
...formData, ...formData,
writer: writerValue, writer: writerValue,
@ -230,8 +214,6 @@ export class ButtonActionExecutor {
company_code: companyCodeValue, company_code: companyCodeValue,
}; };
console.log("🔍 [buttonActions] 저장할 데이터:", dataWithUserInfo);
saveResult = await DynamicFormApi.saveFormData({ saveResult = await DynamicFormApi.saveFormData({
screenId, screenId,
tableName, tableName,
@ -243,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);
@ -272,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;
} }
@ -293,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;
} }
@ -329,8 +305,6 @@ export class ButtonActionExecutor {
} }
// 기본 키를 찾지 못한 경우 // 기본 키를 찾지 못한 경우
console.log("🔍 추측 기반으로 기본 키를 찾을 수 없습니다. INSERT 모드로 처리됩니다.");
console.log("📋 사용 가능한 필드들:", Object.keys(formData));
return null; return null;
} }
@ -529,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,
@ -538,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",
}, },
}); });
@ -644,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;
// 플로우 선택 데이터 우선 사용 // 플로우 선택 데이터 우선 사용
@ -681,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("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
@ -698,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":
@ -726,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: () => {

View File

@ -58,7 +58,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",
@ -11433,9 +11433,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",

View File

@ -66,7 +66,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",