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