diff options
Diffstat (limited to 'lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx')
| -rw-r--r-- | lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx | 625 |
1 files changed, 625 insertions, 0 deletions
diff --git a/lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx b/lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx new file mode 100644 index 00000000..3c98ee4d --- /dev/null +++ b/lib/basic-contract/gtc-vendor/create-gtc-clause-dialog.tsx @@ -0,0 +1,625 @@ +// create-vendor-gtc-clause-dialog.tsx +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Check, ChevronsUpDown, Loader, Plus, Info } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { createVendorGtcClauseSchema, type CreateVendorGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations" +import { createVendorGtcClause, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { useSession } from "next-auth/react" +import { MarkdownImageEditor } from "./markdown-image-editor" + +interface ClauseImage { + id: string + url: string + fileName: string + size: number +} + +interface CreateVendorGtcClauseDialogProps { + documentId: number + document: any + vendorDocumentId: number + vendorId: number + vendorName?: string + baseClauseId?: number // Optional - if creating a new vendor clause from scratch + baseClause?: GtcClauseTreeView | null // Original clause to modify, if available + parentClause?: GtcClauseTreeView | null + onSuccess?: () => void + open?: boolean + onOpenChange?: (open: boolean) => void + showTrigger?: boolean +} + +export function CreateVendorGtcClauseDialog({ + documentId, + document, + vendorDocumentId, + vendorId, + vendorName, + baseClauseId, + baseClause, + parentClause = null, + onSuccess, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + showTrigger = true +}: CreateVendorGtcClauseDialogProps) { + const [internalOpen, setInternalOpen] = React.useState(false) + + // controlled vs uncontrolled 모드 + const isControlled = controlledOpen !== undefined + const open = isControlled ? controlledOpen : internalOpen + const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen + const [parentClauses, setParentClauses] = React.useState<GtcClauseTreeView[]>([]) + const [isCreatePending, startCreateTransition] = React.useTransition() + const { data: session } = useSession() + const [images, setImages] = React.useState<ClauseImage[]>([]) + + const currentUserId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null + }, [session]) + + React.useEffect(() => { + if (open) { + loadParentClauses() + } + }, [open, documentId]) + + const loadParentClauses = async () => { + try { + const tree = await getGtcClausesTree(documentId) + setParentClauses(flattenTree(tree)) + } catch (error) { + console.error("Error loading parent clauses:", error) + } + } + + const form = useForm<CreateVendorGtcClauseSchema>({ + resolver: zodResolver(createVendorGtcClauseSchema), + defaultValues: { + documentId, + vendorDocumentId, + baseClauseId: baseClauseId || null, + parentId: parentClause?.id || null, + modifiedItemNumber: baseClause?.itemNumber || "", + modifiedCategory: baseClause?.category || "", + modifiedSubtitle: baseClause?.subtitle || "", + modifiedContent: baseClause?.content || "", + isNumberModified: false, + isCategoryModified: false, + isSubtitleModified: false, + isContentModified: false, + sortOrder: 0, + reviewStatus: "draft", + negotiationNote: "", + isExcluded: false, + editReason: "", + }, + }) + + const handleContentImageChange = (content: string, newImages: ClauseImage[]) => { + form.setValue("modifiedContent", content) + setImages(newImages) + } + + async function onSubmit(data: CreateVendorGtcClauseSchema) { + startCreateTransition(async () => { + if (!currentUserId) { + toast.error("로그인이 필요합니다") + return + } + + try { + // 사용자 정보 추가 + const result = await createVendorGtcClause({ + ...data, + images: images, + createdById: currentUserId, + vendorId, // 벤더 ID 추가 + actorName: session?.user?.name || null, + actorEmail: session?.user?.email || null, + }) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + + form.reset() + setImages([]) + setOpen(false) + toast.success("벤더 GTC 조항이 생성되었습니다.") + onSuccess?.() + } catch (error) { + toast.error("벤더 조항 생성 중 오류가 발생했습니다.") + } + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setImages([]) + } + setOpen(nextOpen) + } + + const selectedParent = parentClauses.find(c => c.id === form.watch("parentId")) + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {showTrigger && ( + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + {baseClause + ? "조항 협의 생성" + : parentClause + ? "하위 조항 추가" + : "조항 추가"} + </Button> + </DialogTrigger> + )} + + <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + {baseClause + ? `${baseClause.subtitle} 조항 협의 생성` + : parentClause + ? "하위 벤더 조항 생성" + : "새 벤더 조항 생성"} + </DialogTitle> + <DialogDescription> + {vendorName + ? `${vendorName}과(와)의 GTC 조항 협의 내용을 입력하세요.` + : '벤더별 GTC 조항 정보를 입력하세요.'} + </DialogDescription> + </DialogHeader> + + {/* 기존 조항 정보 (있는 경우) */} + {baseClause && ( + <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0 mb-4"> + <div className="font-medium mb-1">원본 조항 정보</div> + <div className="text-muted-foreground space-y-1"> + <div>채번: {baseClause.itemNumber}</div> + {baseClause.category && <div>분류: {baseClause.category}</div>} + <div>소제목: {baseClause.subtitle}</div> + {baseClause.content && <div>내용: {baseClause.content.substring(0, 80)}...</div>} + </div> + </div> + )} + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <div className="flex-1 overflow-y-auto px-1"> + <div className="space-y-4 py-4"> + {/* 협의 상태 */} + <FormField + control={form.control} + name="reviewStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>협의 상태</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="협의 상태를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="draft">초안</SelectItem> + <SelectItem value="pending">협의 대기</SelectItem> + <SelectItem value="reviewing">협의 중</SelectItem> + <SelectItem value="approved">승인됨</SelectItem> + <SelectItem value="rejected">거부됨</SelectItem> + <SelectItem value="revised">수정됨</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제외 여부 */} + <FormField + control={form.control} + name="isExcluded" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel> + 이 조항을 벤더 계약에서 제외 + </FormLabel> + <FormDescription> + 체크하면 최종 계약서에서 이 조항이 제외됩니다 + </FormDescription> + </div> + </FormItem> + )} + /> + + {/* 부모 조항 선택 (벤더 조항에만 필요한 경우) */} + {!baseClause && ( + <FormField + control={form.control} + name="parentId" + render={({ field }) => { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + return ( + <FormItem> + <FormLabel>부모 조항 (선택사항)</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedParent + ? `${selectedParent.itemNumber} - ${selectedParent.subtitle}` + : "부모 조항을 선택하세요... (최상위 조항인 경우 선택 안함)"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="부모 조항 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>조항을 찾을 수 없습니다.</CommandEmpty> + <CommandGroup> + <CommandItem + value="none" + onSelect={() => { + field.onChange(null) + setPopoverOpen(false) + }} + > + 최상위 조항 + <Check + className={cn( + "ml-auto h-4 w-4", + !field.value ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + + {parentClauses.map((clause) => ( + <CommandItem + key={clause.id} + value={`${clause.itemNumber} - ${clause.subtitle}`} + onSelect={() => { + field.onChange(clause.id) + setPopoverOpen(false) + }} + > + <div className="flex items-center w-full"> + <span style={{ marginLeft: `${clause.depth * 12}px` }}> + {`${clause.itemNumber} - ${clause.subtitle}`} + </span> + </div> + <Check + className={cn( + "ml-auto h-4 w-4", + selectedParent?.id === clause.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + )} + + {/* 채번 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isNumberModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 채번 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isNumberModified") && ( + <FormField + control={form.control} + name="modifiedItemNumber" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 채번 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 분류 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isCategoryModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 분류 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isCategoryModified") && ( + <FormField + control={form.control} + name="modifiedCategory" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 분류 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 소제목 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isSubtitleModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 소제목 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isSubtitleModified") && ( + <FormField + control={form.control} + name="modifiedSubtitle" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="수정할 소제목 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 내용 수정 */} + <div className="space-y-2 border rounded-lg p-3"> + <FormField + control={form.control} + name="isContentModified" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <FormLabel className="font-normal cursor-pointer"> + 상세항목 수정 + </FormLabel> + </FormItem> + )} + /> + + {form.watch("isContentModified") && ( + <FormField + control={form.control} + name="modifiedContent" + render={({ field }) => ( + <FormItem> + <FormControl> + <MarkdownImageEditor + content={field.value || ""} + images={images} + onChange={handleContentImageChange} + placeholder="수정할 내용을 입력하세요..." + rows={6} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 협의 노트 */} + <FormField + control={form.control} + name="negotiationNote" + render={({ field }) => ( + <FormItem> + <FormLabel>협의 메모</FormLabel> + <FormControl> + <Textarea + placeholder="협의 과정에서의 메모나 특이사항을 기록하세요..." + {...field} + rows={3} + /> + </FormControl> + <FormDescription> + 벤더와의 협의 내용이나 변경 사유를 기록합니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 편집 사유 */} + <FormField + control={form.control} + name="editReason" + render={({ field }) => ( + <FormItem> + <FormLabel>편집 사유 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="조항 생성 사유를 입력하세요..." + {...field} + rows={2} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4"> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isCreatePending} + > + 취소 + </Button> + <Button type="submit" disabled={isCreatePending}> + {isCreatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 생성 + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} + +// 트리를 평면 배열로 변환하는 유틸리티 함수 +function flattenTree(tree: any[]): any[] { + const result: any[] = [] + + function traverse(nodes: any[]) { + for (const node of nodes) { + result.push(node) + if (node.children && node.children.length > 0) { + traverse(node.children) + } + } + } + + traverse(tree) + return result +}
\ No newline at end of file |
