summaryrefslogtreecommitdiff
path: root/lib/legal-review/status
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-06 04:23:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-06 04:23:40 +0000
commitde2ac5a2860bc25180971e7a11f852d9d44675b7 (patch)
treeb931c363f2cb19e177a0a7b17190d5de2a82d709 /lib/legal-review/status
parent6c549b0f264e9be4d60af38f9efc05b189d6849f (diff)
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'lib/legal-review/status')
-rw-r--r--lib/legal-review/status/create-legal-work-dialog.tsx501
-rw-r--r--lib/legal-review/status/delete-legal-works-dialog.tsx152
-rw-r--r--lib/legal-review/status/legal-table copy.tsx583
-rw-r--r--lib/legal-review/status/legal-table.tsx548
-rw-r--r--lib/legal-review/status/legal-work-detail-dialog.tsx409
-rw-r--r--lib/legal-review/status/legal-work-filter-sheet.tsx897
-rw-r--r--lib/legal-review/status/legal-works-columns.tsx222
-rw-r--r--lib/legal-review/status/legal-works-toolbar-actions.tsx286
-rw-r--r--lib/legal-review/status/request-review-dialog.tsx976
-rw-r--r--lib/legal-review/status/update-legal-work-dialog.tsx385
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