diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
| commit | de2ac5a2860bc25180971e7a11f852d9d44675b7 (patch) | |
| tree | b931c363f2cb19e177a0a7b17190d5de2a82d709 /lib/legal-review/status | |
| parent | 6c549b0f264e9be4d60af38f9efc05b189d6849f (diff) | |
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'lib/legal-review/status')
| -rw-r--r-- | lib/legal-review/status/create-legal-work-dialog.tsx | 501 | ||||
| -rw-r--r-- | lib/legal-review/status/delete-legal-works-dialog.tsx | 152 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-table copy.tsx | 583 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-table.tsx | 548 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-work-detail-dialog.tsx | 409 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-work-filter-sheet.tsx | 897 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-works-columns.tsx | 222 | ||||
| -rw-r--r-- | lib/legal-review/status/legal-works-toolbar-actions.tsx | 286 | ||||
| -rw-r--r-- | lib/legal-review/status/request-review-dialog.tsx | 976 | ||||
| -rw-r--r-- | lib/legal-review/status/update-legal-work-dialog.tsx | 385 |
10 files changed, 4959 insertions, 0 deletions
diff --git a/lib/legal-review/status/create-legal-work-dialog.tsx b/lib/legal-review/status/create-legal-work-dialog.tsx new file mode 100644 index 00000000..72f2a68b --- /dev/null +++ b/lib/legal-review/status/create-legal-work-dialog.tsx @@ -0,0 +1,501 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Loader2, Check, ChevronsUpDown, Calendar, User } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" +import { cn } from "@/lib/utils" +import { getVendorsForSelection } from "@/lib/b-rfq/service" +import { createLegalWork } from "../service" +import { useSession } from "next-auth/react" + +interface CreateLegalWorkDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSuccess?: () => void + onDataChange?: () => void +} + +// legalWorks 테이블에 맞춘 단순화된 폼 스키마 +const createLegalWorkSchema = z.object({ + category: z.enum(["CP", "GTC", "기타"]), + vendorId: z.number().min(1, "벤더를 선택해주세요"), + isUrgent: z.boolean().default(false), + requestDate: z.string().min(1, "답변요청일을 선택해주세요"), + expectedAnswerDate: z.string().optional(), + reviewer: z.string().min(1, "검토요청자를 입력해주세요"), +}) + +type CreateLegalWorkFormValues = z.infer<typeof createLegalWorkSchema> + +interface Vendor { + id: number + vendorName: string + vendorCode: string + country: string + taxId: string + status: string +} + +export function CreateLegalWorkDialog({ + open, + onOpenChange, + onSuccess, + onDataChange +}: CreateLegalWorkDialogProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [vendorsLoading, setVendorsLoading] = React.useState(false) + const [vendorOpen, setVendorOpen] = React.useState(false) + const { data: session } = useSession() + + const userName = React.useMemo(() => { + return session?.user?.name || ""; + }, [session]); + + const userEmail = React.useMemo(() => { + return session?.user?.email || ""; + }, [session]); + + const defaultReviewer = React.useMemo(() => { + if (userName && userEmail) { + return `${userName} (${userEmail})`; + } else if (userName) { + return userName; + } else if (userEmail) { + return userEmail; + } + return ""; + }, [userName, userEmail]); + + const loadVendors = React.useCallback(async () => { + setVendorsLoading(true) + try { + const vendorList = await getVendorsForSelection() + setVendors(vendorList) + } catch (error) { + console.error("Failed to load vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setVendorsLoading(false) + } + }, []) + + // 오늘 날짜 + 7일 후를 기본 답변요청일로 설정 + const getDefaultRequestDate = () => { + const date = new Date() + date.setDate(date.getDate() + 7) + return date.toISOString().split('T')[0] + } + + // 답변요청일 + 3일 후를 기본 답변예정일로 설정 + const getDefaultExpectedDate = (requestDate: string) => { + if (!requestDate) return "" + const date = new Date(requestDate) + date.setDate(date.getDate() + 3) + return date.toISOString().split('T')[0] + } + + const form = useForm<CreateLegalWorkFormValues>({ + resolver: zodResolver(createLegalWorkSchema), + defaultValues: { + category: "CP", + vendorId: 0, + isUrgent: false, + requestDate: getDefaultRequestDate(), + expectedAnswerDate: "", + reviewer: defaultReviewer, + }, + }) + + React.useEffect(() => { + if (open) { + loadVendors() + } + }, [open, loadVendors]) + + // 세션 정보가 로드되면 검토요청자 필드 업데이트 + React.useEffect(() => { + if (defaultReviewer) { + form.setValue("reviewer", defaultReviewer) + } + }, [defaultReviewer, form]) + + // 답변요청일 변경시 답변예정일 자동 설정 + const requestDate = form.watch("requestDate") + React.useEffect(() => { + if (requestDate) { + const expectedDate = getDefaultExpectedDate(requestDate) + form.setValue("expectedAnswerDate", expectedDate) + } + }, [requestDate, form]) + + // 폼 제출 - 서버 액션 적용 + async function onSubmit(data: CreateLegalWorkFormValues) { + console.log("Form submitted with data:", data) + setIsSubmitting(true) + + try { + // legalWorks 테이블에 맞춘 데이터 구조 + const legalWorkData = { + ...data, + // status는 서버에서 "검토요청"으로 설정 + // consultationDate는 서버에서 오늘 날짜로 설정 + // hasAttachment는 서버에서 false로 설정 + } + + const result = await createLegalWork(legalWorkData) + + if (result.success) { + toast.success(result.data?.message || "법무업무가 성공적으로 등록되었습니다.") + onOpenChange(false) + form.reset({ + category: "CP", + vendorId: 0, + isUrgent: false, + requestDate: getDefaultRequestDate(), + expectedAnswerDate: "", + reviewer: defaultReviewer, + }) + onSuccess?.() + onDataChange?.() + router.refresh() + } else { + toast.error(result.error || "등록 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("Error creating legal work:", error) + toast.error("등록 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + form.reset({ + category: "CP", + vendorId: 0, + isUrgent: false, + requestDate: getDefaultRequestDate(), + expectedAnswerDate: "", + reviewer: defaultReviewer, + }) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-2xl h-[80vh] p-0 flex flex-col"> + {/* 고정 헤더 */} + <div className="flex-shrink-0 p-6 border-b"> + <DialogHeader> + <DialogTitle>법무업무 신규 등록</DialogTitle> + <DialogDescription> + 새로운 법무업무를 등록합니다. 상세한 검토 요청은 등록 후 별도로 진행할 수 있습니다. + </DialogDescription> + </DialogHeader> + </div> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1 min-h-0" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto p-6"> + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + {/* 구분 */} + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="CP">CP</SelectItem> + <SelectItem value="GTC">GTC</SelectItem> + <SelectItem value="기타">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 긴급여부 */} + <FormField + control={form.control} + name="isUrgent" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base">긴급 요청</FormLabel> + <div className="text-sm text-muted-foreground"> + 긴급 처리가 필요한 경우 체크 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + </div> + + {/* 벤더 선택 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더</FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + > + {selectedVendor ? ( + <span className="flex items-center gap-2"> + <Badge variant="outline">{selectedVendor.vendorCode}</Badge> + {selectedVendor.vendorName} + </span> + ) : ( + "벤더 선택..." + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="벤더 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + vendor.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span>{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 담당자 및 일정 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Calendar className="h-5 w-5" /> + 담당자 및 일정 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 검토요청자 */} + <FormField + control={form.control} + name="reviewer" + render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-2"> + <User className="h-4 w-4" /> + 검토요청자 + </FormLabel> + <FormControl> + <Input + placeholder={defaultReviewer || "검토요청자 이름을 입력하세요"} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + {/* 답변요청일 */} + <FormField + control={form.control} + name="requestDate" + render={({ field }) => ( + <FormItem> + <FormLabel>답변요청일</FormLabel> + <FormControl> + <Input + type="date" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 답변예정일 */} + <FormField + control={form.control} + name="expectedAnswerDate" + render={({ field }) => ( + <FormItem> + <FormLabel>답변예정일 (선택사항)</FormLabel> + <FormControl> + <Input + type="date" + {...field} + /> + </FormControl> + <div className="text-xs text-muted-foreground"> + 답변요청일 기준으로 자동 설정됩니다 + </div> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 안내 메시지 */} + <Card className="bg-blue-50 border-blue-200"> + <CardContent className="pt-6"> + <div className="flex items-start gap-3"> + <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div> + <div className="space-y-1"> + <p className="text-sm font-medium text-blue-900"> + 법무업무 등록 안내 + </p> + <p className="text-sm text-blue-700"> + 기본 정보 등록 후, 목록에서 해당 업무를 선택하여 상세한 검토 요청을 진행할 수 있습니다. + </p> + <p className="text-xs text-blue-600"> + • 상태: "검토요청"으로 자동 설정<br/> + • 의뢰일: 오늘 날짜로 자동 설정<br/> + • 법무답변자: 나중에 배정 + </p> + </div> + </div> + </CardContent> + </Card> + </div> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 border-t bg-background p-6"> + <div className="flex justify-end gap-3"> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 등록 + </Button> + </div> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/legal-review/status/delete-legal-works-dialog.tsx b/lib/legal-review/status/delete-legal-works-dialog.tsx new file mode 100644 index 00000000..665dafc2 --- /dev/null +++ b/lib/legal-review/status/delete-legal-works-dialog.tsx @@ -0,0 +1,152 @@ +"use client" + +import * as React from "react" +import { type LegalWorksDetailView } from "@/db/schema" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { useRouter } from "next/navigation" + +import { removeLegalWorks } from "../service" + +interface DeleteLegalWorksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + legalWorks: Row<LegalWorksDetailView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteLegalWorksDialog({ + legalWorks, + showTrigger = true, + onSuccess, + ...props +}: DeleteLegalWorksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const router = useRouter() + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeLegalWorks({ + ids: legalWorks.map((work) => work.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + router.refresh() + toast.success("법무업무가 삭제되었습니다") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({legalWorks.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{legalWorks.length}</span> + 건의 법무업무가 완전히 삭제됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Delete selected legal works" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({legalWorks.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{legalWorks.length}</span> + 건의 법무업무가 완전히 삭제됩니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Delete selected legal works" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-table copy.tsx b/lib/legal-review/status/legal-table copy.tsx new file mode 100644 index 00000000..92abfaf6 --- /dev/null +++ b/lib/legal-review/status/legal-table copy.tsx @@ -0,0 +1,583 @@ +// ============================================================================ +// legal-works-table.tsx - EvaluationTargetsTable을 정확히 복사해서 수정 +// ============================================================================ +"use client"; + +import * as React from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { getLegalWorks } from "../service"; +import { cn } from "@/lib/utils"; +import { useTablePresets } from "@/components/data-table/use-table-presets"; +import { TablePresetManager } from "@/components/data-table/data-table-preset"; +import { getLegalWorksColumns } from "./legal-works-columns"; +import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions"; +import { LegalWorkFilterSheet } from "./legal-work-filter-sheet"; +import { LegalWorksDetailView } from "@/db/schema"; +import { EditLegalWorkSheet } from "./update-legal-work-dialog"; +import { LegalWorkDetailDialog } from "./legal-work-detail-dialog"; +import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"; + +/* -------------------------------------------------------------------------- */ +/* Stats Card */ +/* -------------------------------------------------------------------------- */ +function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) { + const stats = React.useMemo(() => { + const total = data.length; + const pending = data.filter(item => item.status === '검토요청').length; + const assigned = data.filter(item => item.status === '담당자배정').length; + const inProgress = data.filter(item => item.status === '검토중').length; + const completed = data.filter(item => item.status === '답변완료').length; + const urgent = data.filter(item => item.isUrgent).length; + + return { total, pending, assigned, inProgress, completed, urgent }; + }, [data]); + + if (stats.total === 0) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 등록된 법무업무가 없습니다. + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 건수</CardTitle> + <Badge variant="outline">전체</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 긴급 {stats.urgent}건 + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토요청</CardTitle> + <Badge variant="secondary">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">담당자배정</CardTitle> + <Badge variant="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토중</CardTitle> + <Badge variant="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">답변완료</CardTitle> + <Badge variant="default">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* LegalWorksTable */ +/* -------------------------------------------------------------------------- */ +interface LegalWorksTableProps { + promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>; + currentYear?: number; // ✅ EvaluationTargetsTable의 evaluationYear와 동일한 역할 + className?: string; +} + +export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + // ✅ EvaluationTargetsTable과 정확히 동일한 외부 필터 상태 + const [externalFilters, setExternalFilters] = React.useState<any[]>([]); + const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); + + // ✅ EvaluationTargetsTable과 정확히 동일한 필터 핸들러 + const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { + console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); + setExternalFilters(filters); + setExternalJoinOperator(joinOperator); + setIsFilterPanelOpen(false); + }, []); + + const searchString = React.useMemo( + () => searchParams.toString(), + [searchParams] + ); + + const getSearchParam = React.useCallback( + (key: string, def = "") => + new URLSearchParams(searchString).get(key) ?? def, + [searchString] + ); + + // ✅ EvaluationTargetsTable과 정확히 동일한 URL 필터 변경 감지 및 데이터 새로고침 + React.useEffect(() => { + const refetchData = async () => { + try { + setIsDataLoading(true); + + // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 + const currentFilters = getSearchParam("filters"); + const currentJoinOperator = getSearchParam("joinOperator", "and"); + const currentPage = parseInt(getSearchParam("page", "1")); + const currentPerPage = parseInt(getSearchParam("perPage", "10")); + const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; + const currentSearch = getSearchParam("search", ""); + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + // ✅ currentYear 추가 (EvaluationTargetsTable의 evaluationYear와 동일) + currentYear: currentYear + }; + + console.log("=== 새 데이터 요청 ===", searchParams); + + // 서버 액션 직접 호출 + const newData = await getLegalWorks(searchParams); + setTableData(newData); + + console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }; + + // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) + const timeoutId = setTimeout(() => { + // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 + const hasChanges = getSearchParam("filters") || + getSearchParam("search") || + getSearchParam("page") !== "1" || + getSearchParam("perPage") !== "10" || + getSearchParam("sort"); + + if (hasChanges) { + refetchData(); + } + }, 300); // 디바운스 시간 단축 + + return () => clearTimeout(timeoutId); + }, [searchString, currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 정확히 동일한 의존성 + + const refreshData = React.useCallback(async () => { + try { + setIsDataLoading(true); + + // 현재 URL 파라미터로 데이터 새로고침 + const currentFilters = getSearchParam("filters"); + const currentJoinOperator = getSearchParam("joinOperator", "and"); + const currentPage = parseInt(getSearchParam("page", "1")); + const currentPerPage = parseInt(getSearchParam("perPage", "10")); + const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; + const currentSearch = getSearchParam("search", ""); + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + currentYear: currentYear + }; + + const newData = await getLegalWorks(searchParams); + setTableData(newData); + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }, [currentYear, getSearchParam]); // ✅ EvaluationTargetsTable과 동일한 의존성 + + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef<HTMLDivElement>(null); + const [containerTop, setContainerTop] = React.useState(0); + + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + const newTop = rect.top + setContainerTop(prevTop => { + if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 + return newTop + } + return prevTop + }) + } + }, []) + + React.useEffect(() => { + updateContainerBounds(); + + const handleResize = () => { + updateContainerBounds(); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', updateContainerBounds); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', updateContainerBounds); + }; + }, [updateContainerBounds]); + + /* ---------------------- 데이터 상태 관리 ---------------------- */ + // 초기 데이터 설정 + const [initialPromiseData] = React.use(promises); + + // ✅ 테이블 데이터 상태 추가 + const [tableData, setTableData] = React.useState(initialPromiseData); + const [isDataLoading, setIsDataLoading] = React.useState(false); + + const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { + try { + const value = getSearchParam(key); + return value ? JSON.parse(value) : defaultValue; + } catch { + return defaultValue; + } + }, [getSearchParam]); + + const parseSearchParam = <T,>(key: string, defaultValue: T): T => { + return parseSearchParamHelper(key, defaultValue); + }; + + /* ---------------------- 초기 설정 ---------------------------- */ + const initialSettings = React.useMemo(() => ({ + page: parseInt(getSearchParam("page", "1")), + perPage: parseInt(getSearchParam("perPage", "10")), + sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], + filters: parseSearchParam("filters", []), + joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", + search: getSearchParam("search", ""), + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [], + }), [getSearchParam, parseSearchParam]); + + /* --------------------- 프리셋 훅 ------------------------------ */ + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = useTablePresets<LegalWorksDetailView>( + "legal-works-table", + initialSettings + ); + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); + + /* 기본 필터 */ + const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [ + { + id: "category", label: "구분", type: "select", options: [ + { label: "CP", value: "CP" }, + { label: "GTC", value: "GTC" }, + { label: "기타", value: "기타" } + ] + }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "검토요청", value: "검토요청" }, + { label: "담당자배정", value: "담당자배정" }, + { label: "검토중", value: "검토중" }, + { label: "답변완료", value: "답변완료" }, + { label: "재검토요청", value: "재검토요청" }, + { label: "보류", value: "보류" }, + { label: "취소", value: "취소" } + ] + }, + { id: "vendorCode", label: "벤더 코드", type: "text" }, + { id: "vendorName", label: "벤더명", type: "text" }, + { + id: "isUrgent", label: "긴급여부", type: "select", options: [ + { label: "긴급", value: "true" }, + { label: "일반", value: "false" } + ] + }, + { + id: "reviewDepartment", label: "검토부문", type: "select", options: [ + { label: "준법문의", value: "준법문의" }, + { label: "법무검토", value: "법무검토" } + ] + }, + { + id: "inquiryType", label: "문의종류", type: "select", options: [ + { label: "국내계약", value: "국내계약" }, + { label: "국내자문", value: "국내자문" }, + { label: "해외계약", value: "해외계약" }, + { label: "해외자문", value: "해외자문" } + ] + }, + { id: "reviewer", label: "검토요청자", type: "text" }, + { id: "legalResponder", label: "법무답변자", type: "text" }, + { id: "requestDate", label: "답변요청일", type: "date" }, + { id: "consultationDate", label: "의뢰일", type: "date" }, + { id: "expectedAnswerDate", label: "답변예정일", type: "date" }, + { id: "legalCompletionDate", label: "법무완료일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; + + /* current settings */ + const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); + + const initialState = React.useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + /* ----------------------- useDataTable ------------------------ */ + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + /* ---------------------- helper ------------------------------ */ + const getActiveFilterCount = React.useCallback(() => { + try { + // URL에서 현재 필터 수 확인 + const filtersParam = getSearchParam("filters"); + if (filtersParam) { + const filters = JSON.parse(filtersParam); + return Array.isArray(filters) ? filters.length : 0; + } + return 0; + } catch { + return 0; + } + }, [getSearchParam]); + + const FILTER_PANEL_WIDTH = 400; + + /* ---------------------------- JSX ---------------------------- */ + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <LegalWorkFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} + isLoading={false} + /> + </div> + + {/* Main Container */} + <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}> + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + }} + > + {/* Header */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <Button + variant="outline" + size="sm" + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} + {getActiveFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveFilterCount()} + </span> + )} + </Button> + <div className="text-sm text-muted-foreground"> + 총 {tableData.total || tableData.data.length}건 + </div> + </div> + + {/* Stats */} + <div className="px-4"> + <LegalWorksStats data={tableData.data} /> + </div> + + {/* Table */} + <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}> + {isDataLoading && ( + <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> + 필터링 중... + </div> + </div> + )} + <DataTable table={table} className="h-full"> + {/* ✅ EvaluationTargetsTable과 정확히 동일한 DataTableAdvancedToolbar */} + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + debounceMs={300} + shallow={false} + externalFilters={externalFilters} + externalJoinOperator={externalJoinOperator} + onFiltersChange={(filters, joinOperator) => { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} + > + <div className="flex items-center gap-2"> + <TablePresetManager<LegalWorksDetailView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <LegalWorksTableToolbarActions table={table} onRefresh={refreshData} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 편집 다이얼로그 */} + <EditLegalWorkSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + work={rowAction?.row.original ?? null} + onSuccess={() => { + rowAction?.row.toggleSelected(false); + refreshData(); + }} + /> + + <LegalWorkDetailDialog + open={rowAction?.type === "view"} + onOpenChange={(open) => !open && setRowAction(null)} + work={rowAction?.row.original || null} + /> + + <DeleteLegalWorksDialog + open={rowAction?.type === "delete"} + onOpenChange={(open) => !open && setRowAction(null)} + legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null); + refreshData(); + }} + /> + </div> + </div> + </div> + </div> + </> + ); +}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-table.tsx b/lib/legal-review/status/legal-table.tsx new file mode 100644 index 00000000..d68ffa4e --- /dev/null +++ b/lib/legal-review/status/legal-table.tsx @@ -0,0 +1,548 @@ +// ============================================================================ +// components/evaluation-targets-table.tsx (CLIENT COMPONENT) +// ─ 정리된 버전 ─ +// ============================================================================ +"use client"; + +import * as React from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { HelpCircle, PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; // ✅ 확장된 버전 사용 +import { cn } from "@/lib/utils"; +import { useTablePresets } from "@/components/data-table/use-table-presets"; +import { TablePresetManager } from "@/components/data-table/data-table-preset"; +import { LegalWorksDetailView } from "@/db/schema"; +import { LegalWorksTableToolbarActions } from "./legal-works-toolbar-actions"; +import { getLegalWorks } from "../service"; +import { getLegalWorksColumns } from "./legal-works-columns"; +import { LegalWorkFilterSheet } from "./legal-work-filter-sheet"; +import { EditLegalWorkSheet } from "./update-legal-work-dialog"; +import { LegalWorkDetailDialog } from "./legal-work-detail-dialog"; +import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog"; + + +/* -------------------------------------------------------------------------- */ +/* Stats Card */ +/* -------------------------------------------------------------------------- */ +function LegalWorksStats({ data }: { data: LegalWorksDetailView[] }) { + const stats = React.useMemo(() => { + const total = data.length; + const pending = data.filter(item => item.status === '검토요청').length; + const assigned = data.filter(item => item.status === '담당자배정').length; + const inProgress = data.filter(item => item.status === '검토중').length; + const completed = data.filter(item => item.status === '답변완료').length; + const urgent = data.filter(item => item.isUrgent).length; + + return { total, pending, assigned, inProgress, completed, urgent }; + }, [data]); + + if (stats.total === 0) { + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> + <Card className="col-span-full"> + <CardContent className="pt-6 text-center text-sm text-muted-foreground"> + 등록된 법무업무가 없습니다. + </CardContent> + </Card> + </div> + ); + } + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5 mb-6"> + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">총 건수</CardTitle> + <Badge variant="outline">전체</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + 긴급 {stats.urgent}건 + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토요청</CardTitle> + <Badge variant="secondary">대기</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-blue-600">{stats.pending.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.pending / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">담당자배정</CardTitle> + <Badge variant="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-yellow-600">{stats.assigned.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.assigned / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">검토중</CardTitle> + <Badge variant="secondary">진행</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.inProgress.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.inProgress / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">답변완료</CardTitle> + <Badge variant="default">완료</Badge> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.completed.toLocaleString()}</div> + <div className="text-xs text-muted-foreground mt-1"> + {stats.total ? Math.round((stats.completed / stats.total) * 100) : 0}% of total + </div> + </CardContent> + </Card> + </div> + ); +} + +/* -------------------------------------------------------------------------- */ +/* EvaluationTargetsTable */ +/* -------------------------------------------------------------------------- */ +interface LegalWorksTableProps { + promises: Promise<[Awaited<ReturnType<typeof getLegalWorks>>]>; + currentYear: number; + className?: string; +} + +export function LegalWorksTable({ promises, currentYear = new Date().getFullYear(), className }: LegalWorksTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<LegalWorksDetailView> | null>(null); + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false); + const searchParams = useSearchParams(); + + // ✅ 외부 필터 상태 (폼에서 전달받은 필터) + const [externalFilters, setExternalFilters] = React.useState<any[]>([]); + const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); + + // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 + const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { + console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); + setExternalFilters(filters); + setExternalJoinOperator(joinOperator); + // 필터 적용 후 패널 닫기 + setIsFilterPanelOpen(false); + }, []); + + + const searchString = React.useMemo( + () => searchParams.toString(), + [searchParams] + ); + + const getSearchParam = React.useCallback( + (key: string, def = "") => + new URLSearchParams(searchString).get(key) ?? def, + [searchString] + ); + + + // ✅ URL 필터 변경 감지 및 데이터 새로고침 + React.useEffect(() => { + const refetchData = async () => { + try { + setIsDataLoading(true); + + // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 + const currentFilters = getSearchParam("filters"); + const currentJoinOperator = getSearchParam("joinOperator", "and"); + const currentPage = parseInt(getSearchParam("page", "1")); + const currentPerPage = parseInt(getSearchParam("perPage", "10")); + const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; + const currentSearch = getSearchParam("search", ""); + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + currentYear: currentYear + }; + + console.log("=== 새 데이터 요청 ===", searchParams); + + // 서버 액션 직접 호출 + const newData = await getLegalWorks(searchParams); + setTableData(newData); + + console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }; + + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ + + // 필터나 검색 파라미터가 변경되면 데이터 새로고침 (디바운스 적용) + const timeoutId = setTimeout(() => { + // 필터, 검색, 페이지네이션, 정렬 중 하나라도 변경되면 새로고침 + const hasChanges = getSearchParam("filters") || + getSearchParam("search") || + getSearchParam("page") !== "1" || + getSearchParam("perPage") !== "10" || + getSearchParam("sort"); + + if (hasChanges) { + refetchData(); + } + }, 300); // 디바운스 시간 단축 + + return () => clearTimeout(timeoutId); + }, [searchString, currentYear, getSearchParam]); + + const refreshData = React.useCallback(async () => { + try { + setIsDataLoading(true); + + // 현재 URL 파라미터로 데이터 새로고침 + const currentFilters = getSearchParam("filters"); + const currentJoinOperator = getSearchParam("joinOperator", "and"); + const currentPage = parseInt(getSearchParam("page", "1")); + const currentPerPage = parseInt(getSearchParam("perPage", "10")); + const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }]; + const currentSearch = getSearchParam("search", ""); + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + currentYear: currentYear + }; + + const newData = await getLegalWorks(searchParams); + setTableData(newData); + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건"); + } catch (error) { + console.error("데이터 새로고침 오류:", error); + } finally { + setIsDataLoading(false); + } + }, [currentYear, getSearchParam]); + + /* --------------------------- layout refs --------------------------- */ + const containerRef = React.useRef<HTMLDivElement>(null); + const [containerTop, setContainerTop] = React.useState(0); + + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + const newTop = rect.top + setContainerTop(prevTop => { + if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트 + return newTop + } + return prevTop + }) + } + }, []) + React.useEffect(() => { + updateContainerBounds(); + + const handleResize = () => { + updateContainerBounds(); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('scroll', updateContainerBounds); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('scroll', updateContainerBounds); + }; + }, [updateContainerBounds]); + + /* ---------------------- 데이터 상태 관리 ---------------------- */ + // 초기 데이터 설정 + const [initialPromiseData] = React.use(promises); + + // ✅ 테이블 데이터 상태 추가 + const [tableData, setTableData] = React.useState(initialPromiseData); + const [isDataLoading, setIsDataLoading] = React.useState(false); + + + + const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => { + try { + const value = getSearchParam(key); + return value ? JSON.parse(value) : defaultValue; + } catch { + return defaultValue; + } + }, [getSearchParam]); + + const parseSearchParam = <T,>(key: string, defaultValue: T): T => { + return parseSearchParamHelper(key, defaultValue); + }; + + /* ---------------------- 초기 설정 ---------------------------- */ + const initialSettings = React.useMemo(() => ({ + page: parseInt(getSearchParam("page", "1")), + perPage: parseInt(getSearchParam("perPage", "10")), + sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }], + filters: parseSearchParam("filters", []), + joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and", + search: getSearchParam("search", ""), + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [], + }), [getSearchParam, parseSearchParam]); + + /* --------------------- 프리셋 훅 ------------------------------ */ + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + getCurrentSettings, + } = useTablePresets<LegalWorksDetailView>( + "legal-review-table", + initialSettings + ); + + + + /* --------------------- 컬럼 ------------------------------ */ + const columns = React.useMemo(() => getLegalWorksColumns({ setRowAction }), [setRowAction]); + + /* 기본 필터 */ + const filterFields: DataTableFilterField<LegalWorksDetailView>[] = [ + { id: "vendorCode", label: "벤더 코드" }, + { id: "vendorName", label: "벤더명" }, + { id: "status", label: "상태" }, + ]; + + /* 고급 필터 */ + const advancedFilterFields: DataTableAdvancedFilterField<LegalWorksDetailView>[] = [ + ]; + + /* current settings */ + const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]); + + const initialState = React.useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + /* ----------------------- useDataTable ------------------------ */ + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (row) => String(row.id), + shallow: false, + clearOnDefault: true, + }); + + /* ---------------------- helper ------------------------------ */ + const getActiveFilterCount = React.useCallback(() => { + try { + // URL에서 현재 필터 수 확인 + const filtersParam = getSearchParam("filters"); + if (filtersParam) { + const filters = JSON.parse(filtersParam); + return Array.isArray(filters) ? filters.length : 0; + } + return 0; + } catch { + return 0; + } + }, [getSearchParam]); + + const FILTER_PANEL_WIDTH = 400; + + /* ---------------------------- JSX ---------------------------- */ + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <LegalWorkFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 + isLoading={false} + /> + </div> + + {/* Main Container */} + <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}> + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%", + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px", + }} + > + {/* Header */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <Button + variant="outline" + size="sm" + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />} + {getActiveFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveFilterCount()} + </span> + )} + </Button> + <div className="text-sm text-muted-foreground"> + 총 {tableData.total || tableData.data.length}건 + </div> + </div> + + {/* Stats */} + <div className="px-4"> + <LegalWorksStats data={tableData.data} /> + + </div> + + {/* Table */} + <div className="flex-1 overflow-hidden relative" style={{ height: "calc(100vh - 500px)" }}> + {isDataLoading && ( + <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> + 필터링 중... + </div> + </div> + )} + <DataTable table={table} className="h-full"> + {/* ✅ 확장된 DataTableAdvancedToolbar 사용 */} + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + debounceMs={300} + shallow={false} + externalFilters={externalFilters} + externalJoinOperator={externalJoinOperator} + onFiltersChange={(filters, joinOperator) => { + console.log("=== 필터 변경 감지 ===", filters, joinOperator); + }} + > + <div className="flex items-center gap-2"> + <TablePresetManager<LegalWorksDetailView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <LegalWorksTableToolbarActions table={table}onRefresh={refreshData} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 다이얼로그들 */} + <EditLegalWorkSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + work={rowAction?.row.original || null} + onSuccess={() => { + rowAction?.row.toggleSelected(false); + refreshData(); + }} + /> + + <LegalWorkDetailDialog + open={rowAction?.type === "view"} + onOpenChange={(open) => !open && setRowAction(null)} + work={rowAction?.row.original || null} + /> + + <DeleteLegalWorksDialog + open={rowAction?.type === "delete"} + onOpenChange={(open) => !open && setRowAction(null)} + legalWorks={rowAction?.row.original ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null); + refreshData(); + }} + /> + + </div> + </div> + </div> + </div> + </> + ); +}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-work-detail-dialog.tsx b/lib/legal-review/status/legal-work-detail-dialog.tsx new file mode 100644 index 00000000..23ceccb2 --- /dev/null +++ b/lib/legal-review/status/legal-work-detail-dialog.tsx @@ -0,0 +1,409 @@ +"use client"; + +import * as React from "react"; +import { + Eye, + FileText, + Building, + User, + Calendar, + Clock, + MessageSquare, + CheckCircle, + ShieldCheck, +} from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { formatDate } from "@/lib/utils"; +import { LegalWorksDetailView } from "@/db/schema"; + +// ----------------------------------------------------------------------------- +// TYPES +// ----------------------------------------------------------------------------- + +type LegalWorkData = LegalWorksDetailView; + +interface LegalWorkDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + work: LegalWorkData | null; +} + +// ----------------------------------------------------------------------------- +// HELPERS +// ----------------------------------------------------------------------------- + +// 상태별 배지 스타일 +const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "검토요청": + return "bg-blue-100 text-blue-800 border-blue-200"; + case "담당자배정": + return "bg-yellow-100 text-yellow-800 border-yellow-200"; + case "검토중": + return "bg-orange-100 text-orange-800 border-orange-200"; + case "답변완료": + return "bg-green-100 text-green-800 border-green-200"; + case "재검토요청": + return "bg-purple-100 text-purple-800 border-purple-200"; + case "보류": + return "bg-gray-100 text-gray-800 border-gray-200"; + case "취소": + return "bg-red-100 text-red-800 border-red-200"; + default: + return "bg-gray-100 text-gray-800 border-gray-200"; + } +}; + +export function LegalWorkDetailDialog({ + open, + onOpenChange, + work, +}: LegalWorkDetailDialogProps) { + if (!work) return null; + + // --------------------------------------------------------------------------- + // CONDITIONAL FLAGS + // --------------------------------------------------------------------------- + + const isLegalReview = work.reviewDepartment === "법무검토"; + const isCompliance = work.reviewDepartment === "준법문의"; + + const isDomesticContract = work.inquiryType === "국내계약"; + const isDomesticAdvisory = work.inquiryType === "국내자문"; + const isOverseasContract = work.inquiryType === "해외계약"; + const isOverseasAdvisory = work.inquiryType === "해외자문"; + + const isContractTypeActive = + isDomesticContract || isOverseasContract || isOverseasAdvisory; + const isDomesticContractFieldsActive = isDomesticContract; + const isFactualRelationActive = isDomesticAdvisory || isOverseasAdvisory; + const isOverseasFieldsActive = isOverseasContract || isOverseasAdvisory; + + // --------------------------------------------------------------------------- + // RENDER + // --------------------------------------------------------------------------- + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col"> + {/* 헤더 */} + <div className="flex-shrink-0 p-6 border-b"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5" /> 법무업무 상세보기 + </DialogTitle> + <DialogDescription> + 법무업무 #{work.id}의 상세 정보를 확인합니다. + </DialogDescription> + </DialogHeader> + </div> + + {/* 본문 */} + <ScrollArea className="flex-1 p-6"> + <div className="space-y-6"> + {/* 1. 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <FileText className="h-5 w-5" /> 기본 정보 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-6 text-sm"> + <div className="space-y-4"> + <div className="flex items-center gap-2"> + <span className="font-medium text-muted-foreground">업무 ID:</span> + <Badge variant="outline">#{work.id}</Badge> + </div> + <div className="flex items-center gap-2"> + <span className="font-medium text-muted-foreground">구분:</span> + <Badge + variant={ + work.category === "CP" + ? "default" + : work.category === "GTC" + ? "secondary" + : "outline" + } + > + {work.category} + </Badge> + {work.isUrgent && ( + <Badge variant="destructive" className="text-xs"> + 긴급 + </Badge> + )} + </div> + <div className="flex items-center gap-2"> + <span className="font-medium text-muted-foreground">상태:</span> + <Badge + className={getStatusBadgeVariant(work.status)} + variant="outline" + > + {work.status} + </Badge> + </div> + </div> + <div className="space-y-4"> + <div className="flex items-center gap-2"> + <Building className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium text-muted-foreground">벤더:</span> + <span> + {work.vendorCode} - {work.vendorName} + </span> + </div> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium text-muted-foreground">의뢰일:</span> + <span>{formatDate(work.consultationDate, "KR")}</span> + </div> + <div className="flex items-center gap-2"> + <Clock className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium text-muted-foreground">답변요청일:</span> + <span>{formatDate(work.requestDate, "KR")}</span> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 2. 담당자 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <User className="h-5 w-5" /> 담당자 정보 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-6 text-sm"> + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">검토요청자</span> + <p>{work.reviewer || "미지정"}</p> + </div> + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">법무답변자</span> + <p>{work.legalResponder || "미배정"}</p> + </div> + {work.expectedAnswerDate && ( + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">답변예정일</span> + <p>{formatDate(work.expectedAnswerDate, "KR")}</p> + </div> + )} + {work.legalCompletionDate && ( + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">법무완료일</span> + <p>{formatDate(work.legalCompletionDate, "KR")}</p> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 3. 법무업무 상세 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <ShieldCheck className="h-5 w-5" /> 법무업무 상세 정보 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4 text-sm"> + <div className="grid grid-cols-2 gap-6"> + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">검토부문</span> + <Badge variant="outline">{work.reviewDepartment}</Badge> + </div> + {work.inquiryType && ( + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">문의종류</span> + <Badge variant="secondary">{work.inquiryType}</Badge> + </div> + )} + {isCompliance && ( + <div className="space-y-2 col-span-2"> + <span className="font-medium text-muted-foreground">공개여부</span> + <Badge variant={work.isPublic ? "default" : "outline"}> + {work.isPublic ? "공개" : "비공개"} + </Badge> + </div> + )} + </div> + + {/* 법무검토 전용 필드 */} + {isLegalReview && ( + <> + {work.contractProjectName && ( + <> + <Separator className="my-2" /> + <div className="space-y-2"> + <span className="font-medium text-muted-foreground"> + 계약명 / 프로젝트명 + </span> + <p>{work.contractProjectName}</p> + </div> + </> + )} + + {/* 계약서 종류 */} + {isContractTypeActive && work.contractType && ( + <div className="space-y-2"> + <span className="font-medium text-muted-foreground">계약서 종류</span> + <Badge variant="outline" className="max-w-max"> + {work.contractType} + </Badge> + </div> + )} + + {/* 국내계약 전용 필드 */} + {isDomesticContractFieldsActive && ( + <div className="grid grid-cols-2 gap-6 mt-4"> + {work.contractCounterparty && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground"> + 계약상대방 + </span> + <p>{work.contractCounterparty}</p> + </div> + )} + {work.counterpartyType && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground"> + 계약상대방 구분 + </span> + <p>{work.counterpartyType}</p> + </div> + )} + {work.contractPeriod && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">계약기간</span> + <p>{work.contractPeriod}</p> + </div> + )} + {work.contractAmount && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">계약금액</span> + <p>{work.contractAmount}</p> + </div> + )} + </div> + )} + + {/* 사실관계 */} + {isFactualRelationActive && work.factualRelation && ( + <div className="space-y-2 mt-4"> + <span className="font-medium text-muted-foreground">사실관계</span> + <p className="whitespace-pre-wrap">{work.factualRelation}</p> + </div> + )} + + {/* 해외 전용 필드 */} + {isOverseasFieldsActive && ( + <div className="grid grid-cols-2 gap-6 mt-4"> + {work.projectNumber && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">프로젝트번호</span> + <p>{work.projectNumber}</p> + </div> + )} + {work.shipownerOrderer && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">선주 / 발주처</span> + <p>{work.shipownerOrderer}</p> + </div> + )} + {work.projectType && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">프로젝트종류</span> + <p>{work.projectType}</p> + </div> + )} + {work.governingLaw && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">준거법</span> + <p>{work.governingLaw}</p> + </div> + )} + </div> + )} + </> + )} + </CardContent> + </Card> + + {/* 4. 요청 내용 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <MessageSquare className="h-5 w-5" /> 요청 내용 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4 text-sm"> + {work.title && ( + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">제목</span> + <p className="font-medium">{work.title}</p> + </div> + )} + <Separator /> + <div className="space-y-1"> + <span className="font-medium text-muted-foreground">상세 내용</span> + <div className="bg-muted/30 rounded-lg p-4"> + {work.requestContent ? ( + <div className="prose prose-sm max-w-none"> + <div + dangerouslySetInnerHTML={{ __html: work.requestContent }} + /> + </div> + ) : ( + <p className="italic text-muted-foreground">요청 내용이 없습니다.</p> + )} + </div> + </div> + {work.attachmentCount > 0 && ( + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4" /> 첨부파일 {work.attachmentCount}개 + </div> + )} + </CardContent> + </Card> + + {/* 5. 답변 내용 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <CheckCircle className="h-5 w-5" /> 답변 내용 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4 text-sm"> + <div className="bg-green-50 border border-green-200 rounded-lg p-4"> + {work.responseContent ? ( + <div className="prose prose-sm max-w-none"> + <div + dangerouslySetInnerHTML={{ __html: work.responseContent }} + /> + </div> + ) : ( + <p className="italic text-muted-foreground"> + 아직 답변이 등록되지 않았습니다. + </p> + )} + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + </DialogContent> + </Dialog> + ); +} diff --git a/lib/legal-review/status/legal-work-filter-sheet.tsx b/lib/legal-review/status/legal-work-filter-sheet.tsx new file mode 100644 index 00000000..4ac877a9 --- /dev/null +++ b/lib/legal-review/status/legal-work-filter-sheet.tsx @@ -0,0 +1,897 @@ +"use client" + +import { useTransition, useState } from "react" +import { useRouter } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { LEGAL_WORK_FILTER_OPTIONS } from "@/types/legal" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 법무업무 필터 스키마 정의 +const legalWorkFilterSchema = z.object({ + category: z.string().optional(), + status: z.string().optional(), + isUrgent: z.string().optional(), + reviewDepartment: z.string().optional(), + inquiryType: z.string().optional(), + reviewer: z.string().optional(), + legalResponder: z.string().optional(), + vendorCode: z.string().optional(), + vendorName: z.string().optional(), + requestDateFrom: z.string().optional(), + requestDateTo: z.string().optional(), + consultationDateFrom: z.string().optional(), + consultationDateTo: z.string().optional(), +}) + +type LegalWorkFilterFormValues = z.infer<typeof legalWorkFilterSchema> + +interface LegalWorkFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void; + isLoading?: boolean; +} + +export function LegalWorkFilterSheet({ + isOpen, + onClose, + onFiltersApply, + isLoading = false +}: LegalWorkFilterSheetProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [joinOperator, setJoinOperator] = useState<"and" | "or">("and") + + // 폼 상태 초기화 + const form = useForm<LegalWorkFilterFormValues>({ + resolver: zodResolver(legalWorkFilterSchema), + defaultValues: { + category: "", + status: "", + isUrgent: "", + reviewDepartment: "", + inquiryType: "", + reviewer: "", + legalResponder: "", + vendorCode: "", + vendorName: "", + requestDateFrom: "", + requestDateTo: "", + consultationDateFrom: "", + consultationDateTo: "", + }, + }) + + // ✅ 폼 제출 핸들러 - 필터 배열 생성 및 전달 + async function onSubmit(data: LegalWorkFilterFormValues) { + startTransition(async () => { + try { + const newFilters = [] + + // 구분 필터 + if (data.category?.trim()) { + newFilters.push({ + id: "category", + value: data.category.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 상태 필터 + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 긴급여부 필터 + if (data.isUrgent?.trim()) { + newFilters.push({ + id: "isUrgent", + value: data.isUrgent.trim() === "true", + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 검토부문 필터 + if (data.reviewDepartment?.trim()) { + newFilters.push({ + id: "reviewDepartment", + value: data.reviewDepartment.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 문의종류 필터 + if (data.inquiryType?.trim()) { + newFilters.push({ + id: "inquiryType", + value: data.inquiryType.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 요청자 필터 + if (data.reviewer?.trim()) { + newFilters.push({ + id: "reviewer", + value: data.reviewer.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + // 법무답변자 필터 + if (data.legalResponder?.trim()) { + newFilters.push({ + id: "legalResponder", + value: data.legalResponder.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + // 벤더 코드 필터 + if (data.vendorCode?.trim()) { + newFilters.push({ + id: "vendorCode", + value: data.vendorCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + // 벤더명 필터 + if (data.vendorName?.trim()) { + newFilters.push({ + id: "vendorName", + value: data.vendorName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + // 검토 요청일 범위 필터 + if (data.requestDateFrom?.trim() && data.requestDateTo?.trim()) { + // 범위 필터 (시작일과 종료일 모두 있는 경우) + newFilters.push({ + id: "requestDate", + value: [data.requestDateFrom.trim(), data.requestDateTo.trim()], + type: "date", + operator: "between", + rowId: generateId() + }) + } else if (data.requestDateFrom?.trim()) { + // 시작일만 있는 경우 (이후 날짜) + newFilters.push({ + id: "requestDate", + value: data.requestDateFrom.trim(), + type: "date", + operator: "gte", + rowId: generateId() + }) + } else if (data.requestDateTo?.trim()) { + // 종료일만 있는 경우 (이전 날짜) + newFilters.push({ + id: "requestDate", + value: data.requestDateTo.trim(), + type: "date", + operator: "lte", + rowId: generateId() + }) + } + + // 의뢰일 범위 필터 + if (data.consultationDateFrom?.trim() && data.consultationDateTo?.trim()) { + // 범위 필터 (시작일과 종료일 모두 있는 경우) + newFilters.push({ + id: "consultationDate", + value: [data.consultationDateFrom.trim(), data.consultationDateTo.trim()], + type: "date", + operator: "between", + rowId: generateId() + }) + } else if (data.consultationDateFrom?.trim()) { + // 시작일만 있는 경우 (이후 날짜) + newFilters.push({ + id: "consultationDate", + value: data.consultationDateFrom.trim(), + type: "date", + operator: "gte", + rowId: generateId() + }) + } else if (data.consultationDateTo?.trim()) { + // 종료일만 있는 경우 (이전 날짜) + newFilters.push({ + id: "consultationDate", + value: data.consultationDateTo.trim(), + type: "date", + operator: "lte", + rowId: generateId() + }) + } + + console.log("=== 생성된 필터들 ===", newFilters); + console.log("=== 조인 연산자 ===", joinOperator); + + // ✅ 부모 컴포넌트에 필터 전달 + onFiltersApply(newFilters, joinOperator); + + console.log("=== 필터 적용 완료 ==="); + } catch (error) { + console.error("법무업무 필터 적용 오류:", error); + } + }) + } + + // ✅ 필터 초기화 핸들러 + function handleReset() { + // 1. 폼 초기화 + form.reset({ + category: "", + status: "", + isUrgent: "", + reviewDepartment: "", + inquiryType: "", + reviewer: "", + legalResponder: "", + vendorCode: "", + vendorName: "", + requestDateFrom: "", + requestDateTo: "", + consultationDateFrom: "", + consultationDateTo: "", + }); + + // 2. 조인 연산자 초기화 + setJoinOperator("and"); + + // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정) + const currentUrl = new URL(window.location.href); + const newSearchParams = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 초기화 + newSearchParams.set("filters", JSON.stringify([])); + newSearchParams.set("joinOperator", "and"); + newSearchParams.set("page", "1"); + newSearchParams.delete("search"); // 검색어 제거 + + // URL 업데이트 + router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`); + + // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해) + onFiltersApply([], "and"); + + console.log("=== 필터 완전 초기화 완료 ==="); + } + + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">법무업무 검색 필터</h3> + <Button + variant="ghost" + size="icon" + onClick={onClose} + className="h-8 w-8" + > + <X className="size-4" /> + </Button> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + + {/* 구분 */} + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="구분 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("category", ""); + }} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {LEGAL_WORK_FILTER_OPTIONS.categories.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 상태 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="상태 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {LEGAL_WORK_FILTER_OPTIONS.statuses.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 긴급여부 */} + <FormField + control={form.control} + name="isUrgent" + render={({ field }) => ( + <FormItem> + <FormLabel>긴급여부</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="긴급여부 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("isUrgent", ""); + }} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="true">긴급</SelectItem> + <SelectItem value="false">일반</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 검토부문 */} + <FormField + control={form.control} + name="reviewDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>검토부문</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="검토부문 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("reviewDepartment", ""); + }} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {LEGAL_WORK_FILTER_OPTIONS.reviewDepartments.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 문의종류 */} + <FormField + control={form.control} + name="inquiryType" + render={({ field }) => ( + <FormItem> + <FormLabel>문의종류</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="문의종류 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("inquiryType", ""); + }} + > + <X className="size-3" /> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {LEGAL_WORK_FILTER_OPTIONS.inquiryTypes.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 요청자 */} + <FormField + control={form.control} + name="reviewer" + render={({ field }) => ( + <FormItem> + <FormLabel>요청자</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="요청자명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("reviewer", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 법무답변자 */} + <FormField + control={form.control} + name="legalResponder" + render={({ field }) => ( + <FormItem> + <FormLabel>법무답변자</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="법무답변자명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("legalResponder", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더 코드 */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더 코드</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="벤더 코드 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorCode", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 벤더명 */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="벤더명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorName", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 검토 요청일 범위 */} + <div className="space-y-2"> + <label className="text-sm font-medium">검토 요청일</label> + + {/* 시작일 */} + <FormField + control={form.control} + name="requestDateFrom" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="date" + placeholder="시작일 선택" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("requestDateFrom", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 종료일 */} + <FormField + control={form.control} + name="requestDateTo" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="date" + placeholder="종료일 선택" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("requestDateTo", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 의뢰일 범위 */} + <div className="space-y-2"> + <label className="text-sm font-medium">의뢰일</label> + + {/* 시작일 */} + <FormField + control={form.control} + name="consultationDateFrom" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs text-muted-foreground">시작일</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="date" + placeholder="시작일 선택" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("consultationDateFrom", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 종료일 */} + <FormField + control={form.control} + name="consultationDateTo" + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs text-muted-foreground">종료일</FormLabel> + <FormControl> + <div className="relative"> + <Input + type="date" + placeholder="종료일 선택" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("consultationDateTo", ""); + }} + > + <X className="size-3.5" /> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending} + className="px-4" + > + 초기화 + </Button> + <Button + type="submit" + variant="default" + disabled={isPending || isLoading} + className="px-4 bg-blue-600 hover:bg-blue-700 text-white" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? "조회 중..." : "조회"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/legal-review/status/legal-works-columns.tsx b/lib/legal-review/status/legal-works-columns.tsx new file mode 100644 index 00000000..c94b414d --- /dev/null +++ b/lib/legal-review/status/legal-works-columns.tsx @@ -0,0 +1,222 @@ +// components/legal-works/legal-works-columns.tsx +"use client"; + +import * as React from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Ellipsis, Paperclip } from "lucide-react"; + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import type { DataTableRowAction } from "@/types/table"; +import { formatDate } from "@/lib/utils"; +import { LegalWorksDetailView } from "@/db/schema"; + +// ──────────────────────────────────────────────────────────────────────────── +// 타입 +// ──────────────────────────────────────────────────────────────────────────── +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null> + >; +} + +// ──────────────────────────────────────────────────────────────────────────── +// 헬퍼 +// ──────────────────────────────────────────────────────────────────────────── +const statusVariant = (status: string) => { + const map: Record<string, string> = { + 검토요청: "bg-blue-100 text-blue-800 border-blue-200", + 담당자배정: "bg-yellow-100 text-yellow-800 border-yellow-200", + 검토중: "bg-orange-100 text-orange-800 border-orange-200", + 답변완료: "bg-green-100 text-green-800 border-green-200", + 재검토요청: "bg-purple-100 text-purple-800 border-purple-200", + 보류: "bg-gray-100 text-gray-800 border-gray-200", + 취소: "bg-red-100 text-red-800 border-red-200", + }; + return map[status] ?? "bg-gray-100 text-gray-800 border-gray-200"; +}; + +const categoryBadge = (category: string) => ( + <Badge + variant={ + category === "CP" ? "default" : category === "GTC" ? "secondary" : "outline" + } + > + {category} + </Badge> +); + +const urgentBadge = (isUrgent: boolean) => + isUrgent ? ( + <Badge variant="destructive" className="text-xs px-1 py-0"> + 긴급 + </Badge> + ) : null; + +const header = (title: string) => + ({ column }: { column: any }) => + <DataTableColumnHeaderSimple column={column} title={title} />; + +// ──────────────────────────────────────────────────────────────────────────── +// 기본 컬럼 +// ──────────────────────────────────────────────────────────────────────────── +const BASE_COLUMNS: ColumnDef<LegalWorksDetailView>[] = [ + // 선택 체크박스 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + + // 번호, 구분, 상태 + { + accessorKey: "id", + header: header("No."), + cell: ({ row }) => ( + <div className="w-[60px] text-center font-medium">{row.getValue("id")}</div> + ), + size: 80, + }, + { + accessorKey: "category", + header: header("구분"), + cell: ({ row }) => categoryBadge(row.getValue("category")), + size: 80, + }, + { + accessorKey: "status", + header: header("상태"), + cell: ({ row }) => ( + <Badge className={statusVariant(row.getValue("status"))} variant="outline"> + {row.getValue("status")} + </Badge> + ), + size: 120, + }, + + // 벤더 코드·이름 + { + accessorKey: "vendorCode", + header: header("벤더 코드"), + cell: ({ row }) => <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>, + size: 120, + }, + { + accessorKey: "vendorName", + header: header("벤더명"), + cell: ({ row }) => { + const name = row.getValue<string>("vendorName"); + return ( + <div className="flex items-center gap-2 truncate max-w-[200px]" title={name}> + {urgentBadge(row.original.isUrgent)} + {name} + </div> + ); + }, + size: 200, + }, + + // 날짜·첨부 + { + accessorKey: "requestDate", + header: header("답변요청일"), + cell: ({ row }) => ( + <span className="text-sm">{formatDate(row.getValue("requestDate"), "KR")}</span> + ), + size: 100, + }, + { + accessorKey: "hasAttachment", + header: header("첨부"), + cell: ({ row }) => + row.getValue<boolean>("hasAttachment") ? ( + <Paperclip className="h-4 w-4 text-muted-foreground" /> + ) : ( + <span className="text-muted-foreground">-</span> + ), + size: 60, + enableSorting: false, + }, +]; + +// ──────────────────────────────────────────────────────────────────────────── +// 액션 컬럼 +// ──────────────────────────────────────────────────────────────────────────── +const createActionsColumn = ( + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<LegalWorksDetailView> | null> + > +): ColumnDef<LegalWorksDetailView> => ({ + id: "actions", + enableHiding: false, + size: 40, + minSize: 40, + cell: ({ row }) => ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" /> + </Button> + </DropdownMenuTrigger> + + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem onSelect={() => setRowAction({ row, type: "view" })}> + 상세보기 + </DropdownMenuItem> + {row.original.status === "신규등록" && ( + <> + <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}> + 편집 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}> + 삭제하기 + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ), +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 메인 함수 +// ──────────────────────────────────────────────────────────────────────────── +export function getLegalWorksColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<LegalWorksDetailView>[] { + return [...BASE_COLUMNS, createActionsColumn(setRowAction)]; +} diff --git a/lib/legal-review/status/legal-works-toolbar-actions.tsx b/lib/legal-review/status/legal-works-toolbar-actions.tsx new file mode 100644 index 00000000..82fbc80a --- /dev/null +++ b/lib/legal-review/status/legal-works-toolbar-actions.tsx @@ -0,0 +1,286 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { + Plus, + Send, + Download, + RefreshCw, + FileText, + MessageSquare +} from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { CreateLegalWorkDialog } from "./create-legal-work-dialog" +import { RequestReviewDialog } from "./request-review-dialog" +import { exportTableToExcel } from "@/lib/export" +import { getLegalWorks } from "../service" +import { LegalWorksDetailView } from "@/db/schema" +import { DeleteLegalWorksDialog } from "./delete-legal-works-dialog" + +type LegalWorkData = LegalWorksDetailView + +interface LegalWorksTableToolbarActionsProps { + table: Table<LegalWorkData> + onRefresh?: () => void +} + +export function LegalWorksTableToolbarActions({ + table, + onRefresh +}: LegalWorksTableToolbarActionsProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [createDialogOpen, setCreateDialogOpen] = React.useState(false) + const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) + const router = useRouter() + const { data: session } = useSession() + + // 사용자 ID 가져오기 + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : 1 + }, [session]) + + // 선택된 행들 - 단일 선택만 허용 + const selectedRows = table.getFilteredSelectedRowModel().rows + const hasSelection = selectedRows.length > 0 + const isSingleSelection = selectedRows.length === 1 + const isMultipleSelection = selectedRows.length > 1 + + // 선택된 단일 work + const selectedWork = isSingleSelection ? selectedRows[0].original : null + + // const canDeleateReview = selectedRows.filter(v=>v.status === '신규등록') + + + const deletableRows = React.useMemo(() => { + return selectedRows.filter(row => { + const status = row.original.status + return status ==="신규등록" + }) + }, [selectedRows]) + + const hasDeletableRows = deletableRows.length > 0 + + // 선택된 work의 상태 확인 + const canRequestReview = selectedWork?.status === "신규등록" + const canAssign = selectedWork?.status === "신규등록" + + // ---------------------------------------------------------------- + // 신규 생성 + // ---------------------------------------------------------------- + const handleCreateNew = React.useCallback(() => { + setCreateDialogOpen(true) + }, []) + + // ---------------------------------------------------------------- + // 검토 요청 (단일 선택만) + // ---------------------------------------------------------------- + const handleRequestReview = React.useCallback(() => { + if (!isSingleSelection) { + toast.error("검토요청은 한 건씩만 가능합니다. 하나의 항목만 선택해주세요.") + return + } + + if (!canRequestReview) { + toast.error("신규등록 상태인 항목만 검토요청이 가능합니다.") + return + } + + setReviewDialogOpen(true) + }, [isSingleSelection, canRequestReview]) + + // ---------------------------------------------------------------- + // 다이얼로그 성공 핸들러 + // ---------------------------------------------------------------- + const handleActionSuccess = React.useCallback(() => { + table.resetRowSelection() + onRefresh?.() + router.refresh() + }, [table, onRefresh, router]) + + // ---------------------------------------------------------------- + // 내보내기 핸들러 + // ---------------------------------------------------------------- + const handleExport = React.useCallback(() => { + exportTableToExcel(table, { + filename: "legal-works-list", + excludeColumns: ["select", "actions"], + }) + }, [table]) + + // ---------------------------------------------------------------- + // 새로고침 핸들러 + // ---------------------------------------------------------------- + const handleRefresh = React.useCallback(async () => { + setIsLoading(true) + try { + onRefresh?.() + toast.success("데이터를 새로고침했습니다.") + } catch (error) { + console.error("새로고침 오류:", error) + toast.error("새로고침 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + }, [onRefresh]) + + return ( + <> + <div className="flex items-center gap-2"> + + {hasDeletableRows&&( + <DeleteLegalWorksDialog + legalWorks={deletableRows.map(row => row.original)} + showTrigger={hasDeletableRows} + onSuccess={() => { + table.toggleAllRowsSelected(false) + // onRefresh?.() + }} + /> + )} + {/* 신규 생성 버튼 */} + <Button + variant="default" + size="sm" + className="gap-2" + onClick={handleCreateNew} + disabled={isLoading} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">신규 등록</span> + </Button> + + {/* 유틸리티 버튼들 */} + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isLoading} + className="gap-2" + > + <RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" /> + <span className="hidden sm:inline">새로고침</span> + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">내보내기</span> + </Button> + </div> + + {/* 선택된 항목 액션 버튼들 */} + {hasSelection && ( + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + {/* 다중 선택 경고 메시지 */} + {isMultipleSelection && ( + <div className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded border border-amber-200"> + 검토요청은 한 건씩만 가능합니다 + </div> + )} + + {/* 검토 요청 버튼 (단일 선택시만) */} + {isSingleSelection && ( + <Button + variant="default" + size="sm" + className="gap-2 bg-blue-600 hover:bg-blue-700" + onClick={handleRequestReview} + disabled={isLoading || !canRequestReview} + > + <Send className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + {canRequestReview ? "검토요청" : "검토불가"} + </span> + </Button> + )} + + {/* 추가 액션 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + disabled={isLoading} + > + <MessageSquare className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">추가 작업</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => toast.info("담당자 배정 기능을 준비 중입니다.")} + disabled={!isSingleSelection || !canAssign} + > + <FileText className="size-4 mr-2" /> + 담당자 배정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => toast.info("상태 변경 기능을 준비 중입니다.")} + disabled={!isSingleSelection} + > + <RefreshCw className="size-4 mr-2" /> + 상태 변경 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + )} + + {/* 선택된 항목 정보 표시 */} + {hasSelection && ( + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <div className="text-xs text-muted-foreground"> + {isSingleSelection ? ( + <> + 선택: #{selectedWork?.id} ({selectedWork?.category}) + {selectedWork?.vendorName && ` | ${selectedWork.vendorName}`} + {selectedWork?.status && ` | ${selectedWork.status}`} + </> + ) : ( + `선택: ${selectedRows.length}건 (개별 처리 필요)` + )} + </div> + </div> + )} + </div> + + {/* 다이얼로그들 */} + {/* 신규 생성 다이얼로그 */} + <CreateLegalWorkDialog + open={createDialogOpen} + onOpenChange={setCreateDialogOpen} + onSuccess={handleActionSuccess} + onDataChange={onRefresh} + /> + + {/* 검토 요청 다이얼로그 - 단일 work 전달 */} + {selectedWork && ( + <RequestReviewDialog + open={reviewDialogOpen} + onOpenChange={setReviewDialogOpen} + work={selectedWork} // 단일 객체로 변경 + onSuccess={handleActionSuccess} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/legal-review/status/request-review-dialog.tsx b/lib/legal-review/status/request-review-dialog.tsx new file mode 100644 index 00000000..838752c4 --- /dev/null +++ b/lib/legal-review/status/request-review-dialog.tsx @@ -0,0 +1,976 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Loader2, Send, FileText, Clock, Upload, X, Building, User, Calendar } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" +import TiptapEditor from "@/components/qna/tiptap-editor" +import { canRequestReview, requestReview } from "../service" +import { LegalWorksDetailView } from "@/db/schema" + +type LegalWorkData = LegalWorksDetailView + +interface RequestReviewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + work: LegalWorkData | null + onSuccess?: () => void +} + +// 검토요청 폼 스키마 +const requestReviewSchema = z.object({ + // 기본 검토 설정 + dueDate: z.string().min(1, "검토 완료 희망일을 선택해주세요"), + assignee: z.string().optional(), + notificationMethod: z.enum(["email", "internal", "both"]).default("both"), + + // 법무업무 상세 정보 + reviewDepartment: z.enum(["준법문의", "법무검토"]), + inquiryType: z.enum(["국내계약", "국내자문", "해외계약", "해외자문"]).optional(), + + // 공통 필드 + title: z.string().min(1, "제목을 선택해주세요"), + requestContent: z.string().min(1, "요청내용을 입력해주세요"), + + // 준법문의 전용 필드 + isPublic: z.boolean().default(false), + + // 법무검토 전용 필드들 + contractProjectName: z.string().optional(), + contractType: z.string().optional(), + contractCounterparty: z.string().optional(), + counterpartyType: z.enum(["법인", "개인"]).optional(), + contractPeriod: z.string().optional(), + contractAmount: z.string().optional(), + factualRelation: z.string().optional(), + projectNumber: z.string().optional(), + shipownerOrderer: z.string().optional(), + projectType: z.string().optional(), + governingLaw: z.string().optional(), +}).refine((data) => { + // 법무검토 선택시 문의종류 필수 + if (data.reviewDepartment === "법무검토" && !data.inquiryType) { + return false; + } + return true; +}, { + message: "법무검토 선택시 문의종류를 선택해주세요", + path: ["inquiryType"] +}); + +type RequestReviewFormValues = z.infer<typeof requestReviewSchema> + +export function RequestReviewDialog({ + open, + onOpenChange, + work, + onSuccess +}: RequestReviewDialogProps) { + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [attachments, setAttachments] = React.useState<File[]>([]) + const [editorContent, setEditorContent] = React.useState("") + const [canRequest, setCanRequest] = React.useState(true) + const [requestCheckMessage, setRequestCheckMessage] = React.useState("") + + // work의 category에 따라 기본 reviewDepartment 결정 + const getDefaultReviewDepartment = () => { + return work?.category === "CP" ? "준법문의" : "법무검토" + } + + const form = useForm<RequestReviewFormValues>({ + resolver: zodResolver(requestReviewSchema), + defaultValues: { + dueDate: "", + assignee: "", + notificationMethod: "both", + reviewDepartment: getDefaultReviewDepartment(), + title: getDefaultReviewDepartment() === "준법문의" ? "CP검토" : "GTC검토", + requestContent: "", + isPublic: false, + }, + }) + + // work 변경시 검토요청 가능 여부 확인 + React.useEffect(() => { + if (work && open) { + canRequestReview(work.id).then((result) => { + setCanRequest(result.canRequest) + setRequestCheckMessage(result.reason || "") + }) + + const defaultDepartment = work.category === "CP" ? "준법문의" : "법무검토" + form.setValue("reviewDepartment", defaultDepartment) + } + }, [work, open, form]) + + // 검토부문 감시 + const reviewDepartment = form.watch("reviewDepartment") + const inquiryType = form.watch("inquiryType") + const titleValue = form.watch("title") + + // 조건부 필드 활성화 로직 + const isContractTypeActive = inquiryType && ["국내계약", "해외계약", "해외자문"].includes(inquiryType) + const isDomesticContractFieldsActive = inquiryType === "국내계약" + const isFactualRelationActive = inquiryType && ["국내자문", "해외자문"].includes(inquiryType) + const isOverseasFieldsActive = inquiryType && ["해외계약", "해외자문"].includes(inquiryType) + + // 제목 "기타" 선택 여부 확인 + const isTitleOther = titleValue === "기타" + + // 검토부문 변경시 관련 필드 초기화 + React.useEffect(() => { + if (reviewDepartment === "준법문의") { + form.setValue("inquiryType", undefined) + // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) + const currentTitle = form.getValues("title") + if (currentTitle === "기타" || !currentTitle || currentTitle === "GTC검토") { + form.setValue("title", "CP검토") + } + // 법무검토 전용 필드들 초기화 + form.setValue("contractProjectName", "") + form.setValue("contractType", "") + form.setValue("contractCounterparty", "") + form.setValue("counterpartyType", undefined) + form.setValue("contractPeriod", "") + form.setValue("contractAmount", "") + form.setValue("factualRelation", "") + form.setValue("projectNumber", "") + form.setValue("shipownerOrderer", "") + form.setValue("projectType", "") + form.setValue("governingLaw", "") + } else { + // 제목 초기화 (기타 상태였거나 값이 없으면 기본값으로) + const currentTitle = form.getValues("title") + if (currentTitle === "기타" || !currentTitle || currentTitle === "CP검토") { + form.setValue("title", "GTC검토") + } + form.setValue("isPublic", false) + } + }, [reviewDepartment, form]) + + // 문의종류 변경시 관련 필드 초기화 + React.useEffect(() => { + if (inquiryType) { + // 계약서 종류 초기화 (옵션이 달라지므로) + form.setValue("contractType", "") + + // 조건에 맞지 않는 필드들 초기화 + if (!isDomesticContractFieldsActive) { + form.setValue("contractCounterparty", "") + form.setValue("counterpartyType", undefined) + form.setValue("contractPeriod", "") + form.setValue("contractAmount", "") + } + + if (!isFactualRelationActive) { + form.setValue("factualRelation", "") + } + + if (!isOverseasFieldsActive) { + form.setValue("projectNumber", "") + form.setValue("shipownerOrderer", "") + form.setValue("projectType", "") + form.setValue("governingLaw", "") + } + } + }, [inquiryType, isDomesticContractFieldsActive, isFactualRelationActive, isOverseasFieldsActive, form]) + + // 에디터 내용이 변경될 때 폼에 반영 + React.useEffect(() => { + form.setValue("requestContent", editorContent) + }, [editorContent, form]) + + // 첨부파일 처리 + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = Array.from(event.target.files || []) + setAttachments(prev => [...prev, ...files]) + } + + const removeAttachment = (index: number) => { + setAttachments(prev => prev.filter((_, i) => i !== index)) + } + + // 폼 제출 + async function onSubmit(data: RequestReviewFormValues) { + if (!work) return + + console.log("Request review data:", data) + console.log("Work to review:", work) + console.log("Attachments:", attachments) + setIsSubmitting(true) + + try { + const result = await requestReview(work.id, data, attachments) + + if (result.success) { + toast.success(result.data?.message || `법무업무 #${work.id}에 대한 검토요청이 완료되었습니다.`) + onOpenChange(false) + handleReset() + onSuccess?.() + } else { + toast.error(result.error || "검토요청 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("Error requesting review:", error) + toast.error("검토요청 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 폼 리셋 함수 + const handleReset = () => { + const defaultDepartment = getDefaultReviewDepartment() + form.reset({ + dueDate: "", + assignee: "", + notificationMethod: "both", + reviewDepartment: defaultDepartment, + title: defaultDepartment === "준법문의" ? "CP검토" : "GTC검토", + requestContent: "", + isPublic: false, + }) + setAttachments([]) + setEditorContent("") + } + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + handleReset() + } + } + + // 제목 옵션 (검토부문에 따라 다름) + const getTitleOptions = () => { + if (reviewDepartment === "준법문의") { + return [ + { value: "CP검토", label: "CP검토" }, + { value: "기타", label: "기타 (직접입력)" } + ] + } else { + return [ + { value: "GTC검토", label: "GTC검토" }, + { value: "기타", label: "기타 (직접입력)" } + ] + } + } + + // 계약서 종류 옵션 (문의종류에 따라 다름) + const getContractTypeOptions = () => { + if (inquiryType === "국내계약") { + return [ + { value: "공사도급계약", label: "공사도급계약" }, + { value: "제작납품계약", label: "제작납품계약" }, + { value: "자재매매계약", label: "자재매매계약" }, + { value: "용역위탁계약", label: "용역위탁계약" }, + { value: "기술사용 및 개발계약", label: "기술사용 및 개발계약" }, + { value: "운송 및 자재관리 계약", label: "운송 및 자재관리 계약" }, + { value: "자문 등 위임계약", label: "자문 등 위임계약" }, + { value: "양해각서", label: "양해각서" }, + { value: "양수도 계약", label: "양수도 계약" }, + { value: "합의서", label: "합의서" }, + { value: "공동도급(운영)협약서", label: "공동도급(운영)협약서" }, + { value: "협정서", label: "협정서" }, + { value: "약정서", label: "약정서" }, + { value: "협의서", label: "협의서" }, + { value: "기타", label: "기타" }, + { value: "비밀유지계약서", label: "비밀유지계약서" }, + { value: "분양계약서", label: "분양계약서" }, + ] + } else { + // 해외계약/해외자문 + return [ + { value: "Shipbuilding Contract", label: "Shipbuilding Contract" }, + { value: "Offshore Contract (EPCI, FEED)", label: "Offshore Contract (EPCI, FEED)" }, + { value: "Supplementary / Addendum", label: "Supplementary / Addendum" }, + { value: "Subcontract / GTC / PTC / PO", label: "Subcontract / GTC / PTC / PO" }, + { value: "Novation / Assignment", label: "Novation / Assignment" }, + { value: "NDA (Confidential, Secrecy)", label: "NDA (Confidential, Secrecy)" }, + { value: "Warranty", label: "Warranty" }, + { value: "Waiver and Release", label: "Waiver and Release" }, + { value: "Bond (PG, RG, Advanced Payment)", label: "Bond (PG, RG, Advanced Payment)" }, + { value: "MOU / LOI / LOA", label: "MOU / LOI / LOA" }, + { value: "Power of Attorney (POA)", label: "Power of Attorney (POA)" }, + { value: "Commission Agreement", label: "Commission Agreement" }, + { value: "Consortium Agreement", label: "Consortium Agreement" }, + { value: "JV / JDP Agreement", label: "JV / JDP Agreement" }, + { value: "Engineering Service Contract", label: "Engineering Service Contract" }, + { value: "Consultancy Service Agreement", label: "Consultancy Service Agreement" }, + { value: "Purchase / Lease Agreement", label: "Purchase / Lease Agreement" }, + { value: "Financial / Loan / Covenant", label: "Financial / Loan / Covenant" }, + { value: "Other Contract / Agreement", label: "Other Contract / Agreement" }, + ] + } + } + + // 프로젝트 종류 옵션 + const getProjectTypeOptions = () => { + return [ + { value: "BARGE VESSEL", label: "BARGE VESSEL" }, + { value: "BULK CARRIER", label: "BULK CARRIER" }, + { value: "CHEMICAL CARRIER", label: "CHEMICAL CARRIER" }, + { value: "FULL CONTAINER", label: "FULL CONTAINER" }, + { value: "CRUDE OIL TANKER", label: "CRUDE OIL TANKER" }, + { value: "CRUISE SHIP", label: "CRUISE SHIP" }, + { value: "DRILL SHIP", label: "DRILL SHIP" }, + { value: "FIELD DEVELOPMENT SHIP", label: "FIELD DEVELOPMENT SHIP" }, + { value: "FLOATING PRODUCTION STORAGE OFFLOADING", label: "FLOATING PRODUCTION STORAGE OFFLOADING" }, + { value: "CAR-FERRY & PASSENGER VESSEL", label: "CAR-FERRY & PASSENGER VESSEL" }, + { value: "FLOATING STORAGE OFFLOADING", label: "FLOATING STORAGE OFFLOADING" }, + { value: "HEAVY DECK CARGO", label: "HEAVY DECK CARGO" }, + { value: "PRODUCT OIL TANKER", label: "PRODUCT OIL TANKER" }, + { value: "HIGH SPEED LINER", label: "HIGH SPEED LINER" }, + { value: "JACK-UP", label: "JACK-UP" }, + { value: "LIQUEFIED NATURAL GAS CARRIER", label: "LIQUEFIED NATURAL GAS CARRIER" }, + { value: "LIQUEFIED PETROLEUM GAS CARRIER", label: "LIQUEFIED PETROLEUM GAS CARRIER" }, + { value: "MULTIPURPOSE CARGO CARRIER", label: "MULTIPURPOSE CARGO CARRIER" }, + { value: "ORE-BULK-OIL CARRIER", label: "ORE-BULK-OIL CARRIER" }, + { value: "OIL TANKER", label: "OIL TANKER" }, + { value: "OTHER VESSEL", label: "OTHER VESSEL" }, + { value: "PURE CAR CARRIER", label: "PURE CAR CARRIER" }, + { value: "PRODUCT CARRIER", label: "PRODUCT CARRIER" }, + { value: "PLATFORM", label: "PLATFORM" }, + { value: "PUSHER", label: "PUSHER" }, + { value: "REEFER TRANSPORT VESSEL", label: "REEFER TRANSPORT VESSEL" }, + { value: "ROLL-ON ROLL-OFF VESSEL", label: "ROLL-ON ROLL-OFF VESSEL" }, + { value: "SEMI RIG", label: "SEMI RIG" }, + { value: "SUPPLY ANCHOR HANDLING VESSEL", label: "SUPPLY ANCHOR HANDLING VESSEL" }, + { value: "SHUTTLE TANKER", label: "SHUTTLE TANKER" }, + { value: "SUPPLY VESSEL", label: "SUPPLY VESSEL" }, + { value: "TOPSIDE", label: "TOPSIDE" }, + { value: "TUG SUPPLY VESSEL", label: "TUG SUPPLY VESSEL" }, + { value: "VERY LARGE CRUDE OIL CARRIER", label: "VERY LARGE CRUDE OIL CARRIER" }, + { value: "WELL INTERVENTION SHIP", label: "WELL INTERVENTION SHIP" }, + { value: "WIND TURBINE INSTALLATION VESSEL", label: "WIND TURBINE INSTALLATION VESSEL" }, + { value: "기타", label: "기타" }, + ] + } + + if (!work) { + return null + } + + // 검토요청 불가능한 경우 안내 메시지 + if (!canRequest) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2 text-amber-600"> + <FileText className="h-5 w-5" /> + 검토요청 불가 + </DialogTitle> + <DialogDescription className="pt-4"> + {requestCheckMessage} + </DialogDescription> + </DialogHeader> + <div className="flex justify-end pt-4"> + <Button onClick={() => onOpenChange(false)}>확인</Button> + </div> + </DialogContent> + </Dialog> + ) + } + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-4xl h-[90vh] p-0 flex flex-col"> + {/* 고정 헤더 */} + <div className="flex-shrink-0 p-6 border-b"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Send className="h-5 w-5" /> + 검토요청 발송 + </DialogTitle> + <DialogDescription> + 법무업무 #{work.id}에 대한 상세한 검토를 요청합니다. + </DialogDescription> + </DialogHeader> + </div> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1 min-h-0" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto p-6"> + <div className="space-y-6"> + {/* 선택된 업무 정보 */} + <Card className="bg-blue-50 border-blue-200"> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 검토 대상 업무 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <span className="font-medium">업무 ID:</span> + <Badge variant="outline">#{work.id}</Badge> + </div> + <div className="flex items-center gap-2"> + <span className="font-medium">구분:</span> + <Badge variant={work.category === "CP" ? "default" : "secondary"}> + {work.category} + </Badge> + {work.isUrgent && ( + <Badge variant="destructive" className="text-xs"> + 긴급 + </Badge> + )} + </div> + <div className="flex items-center gap-2"> + <Building className="h-4 w-4" /> + <span className="font-medium">벤더:</span> + <span>{work.vendorCode} - {work.vendorName}</span> + </div> + </div> + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <User className="h-4 w-4" /> + <span className="font-medium">요청자:</span> + <span>{work.reviewer || "미지정"}</span> + </div> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4" /> + <span className="font-medium">답변요청일:</span> + <span>{work.requestDate || "미설정"}</span> + </div> + <div className="flex items-center gap-2"> + <span className="font-medium">상태:</span> + <Badge variant="outline">{work.status}</Badge> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 기본 설정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 검토 완료 희망일 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-2"> + <Clock className="h-4 w-4" /> + 검토 완료 희망일 + </FormLabel> + <FormControl> + <Input type="date" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 법무업무 상세 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">법무업무 상세 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 검토부문 */} + <FormField + control={form.control} + name="reviewDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>검토부문</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="검토부문 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="준법문의">준법문의</SelectItem> + <SelectItem value="법무검토">법무검토</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 문의종류 (법무검토 선택시만) */} + {reviewDepartment === "법무검토" && ( + <FormField + control={form.control} + name="inquiryType" + render={({ field }) => ( + <FormItem> + <FormLabel>문의종류</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="문의종류 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="국내계약">국내계약</SelectItem> + <SelectItem value="국내자문">국내자문</SelectItem> + <SelectItem value="해외계약">해외계약</SelectItem> + <SelectItem value="해외자문">해외자문</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 제목 - 조건부 렌더링 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + {!isTitleOther ? ( + // Select 모드 + <Select + onValueChange={(value) => { + field.onChange(value) + if (value !== "기타") { + // 기타가 아닌 값으로 변경시 해당 값으로 설정 + form.setValue("title", value) + } + }} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="제목 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {getTitleOptions().map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + // Input 모드 (기타 선택시) + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-xs">기타</Badge> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + const defaultTitle = reviewDepartment === "준법문의" ? "CP검토" : "GTC검토" + form.setValue("title", defaultTitle) + }} + className="h-6 text-xs" + > + 선택 모드로 돌아가기 + </Button> + </div> + <FormControl> + <Input + placeholder="제목을 직접 입력하세요" + value={field.value === "기타" ? "" : field.value} + onChange={(e) => field.onChange(e.target.value)} + autoFocus + /> + </FormControl> + </div> + )} + <FormMessage /> + </FormItem> + )} + /> + + {/* 준법문의 전용 필드들 */} + {reviewDepartment === "준법문의" && ( + <FormField + control={form.control} + name="isPublic" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base">공개여부</FormLabel> + <div className="text-sm text-muted-foreground"> + 준법문의 공개 설정 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + )} + + {/* 법무검토 전용 필드들 */} + {reviewDepartment === "법무검토" && ( + <div className="space-y-4"> + {/* 계약명/프로젝트명 */} + <FormField + control={form.control} + name="contractProjectName" + render={({ field }) => ( + <FormItem> + <FormLabel>계약명/프로젝트명</FormLabel> + <FormControl> + <Input placeholder="계약명 또는 프로젝트명 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 계약서 종류 - 조건부 활성화 */} + {isContractTypeActive && ( + <FormField + control={form.control} + name="contractType" + render={({ field }) => ( + <FormItem> + <FormLabel>계약서 종류</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="계약서 종류 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {getContractTypeOptions().map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 국내계약 전용 필드들 */} + {isDomesticContractFieldsActive && ( + <div className="grid grid-cols-2 gap-4"> + {/* 계약상대방 */} + <FormField + control={form.control} + name="contractCounterparty" + render={({ field }) => ( + <FormItem> + <FormLabel>계약상대방</FormLabel> + <FormControl> + <Input placeholder="계약상대방 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 계약상대방 구분 */} + <FormField + control={form.control} + name="counterpartyType" + render={({ field }) => ( + <FormItem> + <FormLabel>계약상대방 구분</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="법인">법인</SelectItem> + <SelectItem value="개인">개인</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 계약기간 */} + <FormField + control={form.control} + name="contractPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel>계약기간</FormLabel> + <FormControl> + <Input placeholder="계약기간 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 계약금액 */} + <FormField + control={form.control} + name="contractAmount" + render={({ field }) => ( + <FormItem> + <FormLabel>계약금액</FormLabel> + <FormControl> + <Input placeholder="계약금액 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + )} + + {/* 사실관계 - 조건부 활성화 */} + {isFactualRelationActive && ( + <FormField + control={form.control} + name="factualRelation" + render={({ field }) => ( + <FormItem> + <FormLabel>사실관계</FormLabel> + <FormControl> + <Textarea + placeholder="사실관계를 상세히 입력해주세요" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 해외 관련 필드들 - 조건부 활성화 */} + {isOverseasFieldsActive && ( + <div className="grid grid-cols-2 gap-4"> + {/* 프로젝트번호 */} + <FormField + control={form.control} + name="projectNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트번호</FormLabel> + <FormControl> + <Input placeholder="프로젝트번호 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선주/발주처 */} + <FormField + control={form.control} + name="shipownerOrderer" + render={({ field }) => ( + <FormItem> + <FormLabel>선주/발주처</FormLabel> + <FormControl> + <Input placeholder="선주/발주처 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트종류 */} + <FormField + control={form.control} + name="projectType" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트종류</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="프로젝트종류 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {getProjectTypeOptions().map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 준거법 */} + <FormField + control={form.control} + name="governingLaw" + render={({ field }) => ( + <FormItem> + <FormLabel>준거법</FormLabel> + <FormControl> + <Input placeholder="준거법 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + )} + </div> + )} + + {/* 요청내용 - TiptapEditor로 교체 */} + <FormField + control={form.control} + name="requestContent" + render={({ field }) => ( + <FormItem> + <FormLabel>요청내용</FormLabel> + <FormControl> + <div className="min-h-[250px]"> + <TiptapEditor + content={editorContent} + setContent={setEditorContent} + disabled={isSubmitting} + height="250px" + /> + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 첨부파일 */} + <div className="space-y-2"> + <FormLabel>첨부파일</FormLabel> + <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-4"> + <input + type="file" + multiple + onChange={handleFileChange} + className="hidden" + id="file-upload" + /> + <label + htmlFor="file-upload" + className="flex flex-col items-center justify-center cursor-pointer" + > + <Upload className="h-8 w-8 text-muted-foreground mb-2" /> + <span className="text-sm text-muted-foreground"> + 파일을 선택하거나 여기로 드래그하세요 + </span> + </label> + </div> + + {/* 선택된 파일 목록 */} + {attachments.length > 0 && ( + <div className="space-y-2"> + {attachments.map((file, index) => ( + <div key={index} className="flex items-center justify-between bg-muted/50 p-2 rounded"> + <span className="text-sm truncate">{file.name}</span> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeAttachment(index)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + )} + </div> + </CardContent> + </Card> + </div> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 border-t p-6"> + <div className="flex justify-end gap-3"> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting} + className="bg-blue-600 hover:bg-blue-700" + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 발송 중... + </> + ) : ( + <> + <Send className="mr-2 h-4 w-4" /> + 검토요청 발송 + </> + )} + </Button> + </div> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/legal-review/status/update-legal-work-dialog.tsx b/lib/legal-review/status/update-legal-work-dialog.tsx new file mode 100644 index 00000000..d9157d3c --- /dev/null +++ b/lib/legal-review/status/update-legal-work-dialog.tsx @@ -0,0 +1,385 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Loader2, Check, ChevronsUpDown, Edit } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Switch } from "@/components/ui/switch" +import { ScrollArea } from "@/components/ui/scroll-area" +import { cn } from "@/lib/utils" +import { getVendorsForSelection } from "@/lib/b-rfq/service" +import { LegalWorksDetailView } from "@/db/schema" +// import { updateLegalWork } from "../service" + +type LegalWorkData = LegalWorksDetailView + +interface EditLegalWorkSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + work: LegalWorkData | null + onSuccess?: () => void + onDataChange?: () => void +} + +// 편집용 폼 스키마 (신규등록 상태에서만 기본 정보만 편집) +const editLegalWorkSchema = z.object({ + category: z.enum(["CP", "GTC", "기타"]), + vendorId: z.number().min(1, "벤더를 선택해주세요"), + isUrgent: z.boolean().default(false), + requestDate: z.string().min(1, "답변요청일을 선택해주세요"), +}) + +type EditLegalWorkFormValues = z.infer<typeof editLegalWorkSchema> + +interface Vendor { + id: number + vendorName: string + vendorCode: string + country: string + taxId: string + status: string +} + +export function EditLegalWorkSheet({ + open, + onOpenChange, + work, + onSuccess, + onDataChange +}: EditLegalWorkSheetProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [vendorsLoading, setVendorsLoading] = React.useState(false) + const [vendorOpen, setVendorOpen] = React.useState(false) + + const loadVendors = React.useCallback(async () => { + setVendorsLoading(true) + try { + const vendorList = await getVendorsForSelection() + setVendors(vendorList) + } catch (error) { + console.error("Failed to load vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setVendorsLoading(false) + } + }, []) + + const form = useForm<EditLegalWorkFormValues>({ + resolver: zodResolver(editLegalWorkSchema), + defaultValues: { + category: "CP", + vendorId: 0, + isUrgent: false, + requestDate: "", + }, + }) + + // work 데이터가 변경될 때 폼 값 업데이트 + React.useEffect(() => { + if (work && open) { + form.reset({ + category: work.category as "CP" | "GTC" | "기타", + vendorId: work.vendorId || 0, + isUrgent: work.isUrgent || false, + requestDate: work.requestDate ? new Date(work.requestDate).toISOString().split('T')[0] : "", + }) + } + }, [work, open, form]) + + React.useEffect(() => { + if (open) { + loadVendors() + } + }, [open, loadVendors]) + + // 폼 제출 + async function onSubmit(data: EditLegalWorkFormValues) { + if (!work) return + + console.log("Updating legal work with data:", data) + setIsSubmitting(true) + + try { + const result = await updateLegalWork(work.id, data) + + if (result.success) { + toast.success(result.data?.message || "법무업무가 성공적으로 수정되었습니다.") + onOpenChange(false) + onSuccess?.() + onDataChange?.() + router.refresh() + } else { + toast.error(result.error || "수정 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("Error updating legal work:", error) + toast.error("수정 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 시트 닫기 핸들러 + const handleOpenChange = (openState: boolean) => { + onOpenChange(openState) + if (!openState) { + form.reset() + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) + + if (!work) { + return null + } + + return ( + <Sheet open={open} onOpenChange={handleOpenChange}> + <SheetContent className="w-[600px] sm:w-[800px] p-0 flex flex-col" style={{maxWidth:900}}> + {/* 고정 헤더 */} + <SheetHeader className="flex-shrink-0 p-6 border-b"> + <SheetTitle className="flex items-center gap-2"> + <Edit className="h-5 w-5" /> + 법무업무 편집 + </SheetTitle> + <SheetDescription> + 법무업무 #{work.id}의 기본 정보를 수정합니다. (신규등록 상태에서만 편집 가능) + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1 min-h-0" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <ScrollArea className="flex-1 p-6"> + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 구분 */} + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="CP">CP</SelectItem> + <SelectItem value="GTC">GTC</SelectItem> + <SelectItem value="기타">기타</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 긴급여부 */} + <FormField + control={form.control} + name="isUrgent" + render={({ field }) => ( + <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4"> + <div className="space-y-0.5"> + <FormLabel className="text-base">긴급 요청</FormLabel> + <div className="text-sm text-muted-foreground"> + 긴급 처리가 필요한 경우 체크 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + </FormItem> + )} + /> + + {/* 벤더 선택 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더</FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + > + {selectedVendor ? ( + <span className="flex items-center gap-2"> + <Badge variant="outline">{selectedVendor.vendorCode}</Badge> + {selectedVendor.vendorName} + </span> + ) : ( + "벤더 선택..." + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="벤더 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + vendor.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span>{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 답변요청일 */} + <FormField + control={form.control} + name="requestDate" + render={({ field }) => ( + <FormItem> + <FormLabel>답변요청일</FormLabel> + <FormControl> + <Input type="date" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 안내 메시지 */} + <Card className="bg-blue-50 border-blue-200"> + <CardContent className="pt-6"> + <div className="flex items-start gap-3"> + <div className="h-2 w-2 rounded-full bg-blue-500 mt-2"></div> + <div className="space-y-1"> + <p className="text-sm font-medium text-blue-900"> + 편집 제한 안내 + </p> + <p className="text-sm text-blue-700"> + 기본 정보는 '신규등록' 상태에서만 편집할 수 있습니다. 검토요청이 발송된 후에는 담당자를 통해 변경해야 합니다. + </p> + </div> + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + + {/* 고정 버튼 영역 */} + <SheetFooter className="flex-shrink-0 border-t bg-background p-6"> + <div className="flex justify-end gap-3 w-full"> + <SheetClose asChild> + <Button + type="button" + variant="outline" + disabled={isSubmitting} + > + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 저장 + </Button> + </div> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
