summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/b-rfq/initial/add-initial-rfq-dialog.tsx534
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx386
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-table.tsx263
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx109
-rw-r--r--lib/b-rfq/service.ts218
-rw-r--r--lib/b-rfq/validations.ts100
-rw-r--r--lib/vendor-document-list/import-service.ts4
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx616
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx73
-rw-r--r--lib/vendor-document-list/ship/enhanced-document-sheet.tsx939
-rw-r--r--lib/vendor-document-list/ship/enhanced-documents-table.tsx135
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx258
-rw-r--r--lib/vendor-document-list/ship/revision-upload-dialog.tsx629
-rw-r--r--lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx287
-rw-r--r--lib/vendor-document-list/ship/stage-revision-expanded-content.tsx752
-rw-r--r--lib/vendor-document-list/ship/stage-revision-sheet.tsx86
-rw-r--r--lib/vendor-document-list/ship/swp-workflow-panel.tsx370
-rw-r--r--lib/vendor-document-list/ship/update-doc-sheet.tsx267
18 files changed, 2220 insertions, 3806 deletions
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
new file mode 100644
index 00000000..d0924be2
--- /dev/null
+++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx
@@ -0,0 +1,534 @@
+// add-initial-rfq-dialog.tsx
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } from "lucide-react"
+import { toast } from "sonner"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Checkbox } from "@/components/ui/checkbox"
+import { cn, formatDate } from "@/lib/utils"
+import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service"
+import { Calendar } from "@/components/ui/calendar"
+
+// Initial RFQ 추가 폼 스키마
+const addInitialRfqSchema = z.object({
+ vendorId: z.number({
+ required_error: "벤더를 선택해주세요.",
+ }),
+ initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], {
+ required_error: "초기 RFQ 상태를 선택해주세요.",
+ }).default("DRAFT"),
+ dueDate: z.date({
+ required_error: "마감일을 선택해주세요.",
+ }),
+ validDate: z.date().optional(),
+ incotermsCode: z.string().optional(),
+ gtc: z.string().optional(),
+ gtcValidDate: z.string().optional(),
+ classification: z.string().optional(),
+ sparepart: z.string().optional(),
+ shortList: z.boolean().default(false),
+ returnYn: z.boolean().default(false),
+ cpRequestYn: z.boolean().default(false),
+ prjectGtcYn: z.boolean().default(false),
+ returnRevision: z.number().default(0),
+})
+
+type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema>
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ country: string
+ status: string
+}
+
+interface AddInitialRfqDialogProps {
+ rfqId: number
+ onSuccess?: () => void
+}
+
+export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [vendorsLoading, setVendorsLoading] = React.useState(false)
+ const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false)
+ const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
+ const [incotermsLoading, setIncotermsLoading] = React.useState(false)
+ const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false)
+
+ const form = useForm<AddInitialRfqFormData>({
+ resolver: zodResolver(addInitialRfqSchema),
+ defaultValues: {
+ initialRfqStatus: "DRAFT",
+ shortList: false,
+ returnYn: false,
+ cpRequestYn: false,
+ prjectGtcYn: false,
+ returnRevision: 0,
+ },
+ })
+
+ // 벤더 목록 로드
+ 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)
+ }
+ }, [])
+
+ // Incoterms 목록 로드
+ const loadIncoterms = React.useCallback(async () => {
+ setIncotermsLoading(true)
+ try {
+ const incotermsList = await getIncotermsForSelection()
+ setIncoterms(incotermsList)
+ } catch (error) {
+ console.error("Failed to load incoterms:", error)
+ toast.error("Incoterms 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setIncotermsLoading(false)
+ }
+ }, [])
+
+ // 다이얼로그 열릴 때 벤더 목록 로드
+ React.useEffect(() => {
+ if (open) {
+ if (vendors.length === 0) {
+ loadVendors()
+ }
+ if (incoterms.length === 0) {
+ loadIncoterms()
+ }
+ }
+ }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms])
+
+ // 다이얼로그 닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen && !isSubmitting) {
+ form.reset()
+ }
+ setOpen(newOpen)
+ }
+
+ // 폼 제출
+ const onSubmit = async (data: AddInitialRfqFormData) => {
+ setIsSubmitting(true)
+
+ try {
+ const result = await addInitialRfqRecord({
+ ...data,
+ rfqId,
+ })
+
+ if (result.success) {
+ toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.")
+ form.reset()
+ handleOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "초기 RFQ 추가에 실패했습니다.")
+ }
+
+ } catch (error) {
+ console.error("Submit error:", error)
+ toast.error("초기 RFQ 추가 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 선택된 벤더 정보
+ const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId"))
+ const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode"))
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">초기 RFQ 추가</span>
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>초기 RFQ 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 벤더 선택 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>벤더 선택 *</FormLabel>
+ <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorSearchOpen}
+ className="justify-between"
+ disabled={vendorsLoading}
+ >
+ {selectedVendor ? (
+ <div className="flex items-center gap-2">
+ <Building className="h-4 w-4" />
+ <span className="truncate">
+ {selectedVendor.vendorName} ({selectedVendor.vendorCode})
+ </span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">
+ {vendorsLoading ? "로딩 중..." : "벤더를 선택하세요"}
+ </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="벤더명 또는 코드로 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ field.onChange(vendor.id)
+ setVendorSearchOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <Building className="h-4 w-4" />
+ <div className="flex-1 min-w-0">
+ <div className="font-medium truncate">
+ {vendor.vendorName}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {vendor.vendorCode} • {vendor.country}
+ </div>
+ </div>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ vendor.id === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 날짜 필드들 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>견적 마감일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ formatDate(field.value,"KR")
+ ) : (
+ <span>견적 유효일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="validDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>견적 유효일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ formatDate(field.value,"KR")
+ ) : (
+ <span>견적 유효일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* Incoterms 및 GTC */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>Incoterms *</FormLabel>
+ <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={incotermsSearchOpen}
+ className="justify-between"
+ disabled={incotermsLoading}
+ >
+ {selectedIncoterm ? (
+ <div className="flex items-center gap-2">
+ <Building className="h-4 w-4" />
+ <span className="truncate">
+ {selectedIncoterm.code} ({selectedIncoterm.description})
+ </span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">
+ {incotermsLoading ? "로딩 중..." : "인코텀즈를 선택하세요"}
+ </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="코드 또는 내용으로 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incoterms.map((incoterm) => (
+ <CommandItem
+ key={incoterm.id}
+ value={`${incoterm.code} ${incoterm.description}`}
+ onSelect={() => {
+ field.onChange(vendor.id)
+ setVendorSearchOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <div className="font-medium truncate">
+ {incoterm.code} {incoterm.description}
+ </div>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ incoterm.id === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* GTC 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="gtc"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>GTC</FormLabel>
+ <FormControl>
+ <Input placeholder="GTC 정보" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="gtcValidDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>GTC 유효일</FormLabel>
+ <FormControl>
+ <Input placeholder="GTC 유효일" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 분류 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="classification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선급</FormLabel>
+ <FormControl>
+ <Input placeholder="선급" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="sparepart"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Spare part</FormLabel>
+ <FormControl>
+ <Input placeholder="예비부품 정보" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "추가 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+
diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
new file mode 100644
index 00000000..f7ac0960
--- /dev/null
+++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx
@@ -0,0 +1,386 @@
+// initial-rfq-detail-columns.tsx
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import {
+ Ellipsis, Building, Calendar, Eye,
+ MessageSquare, Settings, CheckCircle2, XCircle
+} from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem,
+ DropdownMenuSeparator, DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+interface GetInitialRfqDetailColumnsProps {
+ onSelectDetail?: (detail: any) => void
+}
+
+export function getInitialRfqDetailColumns({
+ onSelectDetail
+}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<any>[] {
+
+ return [
+ /** ───────────── 체크박스 ───────────── */
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ /** ───────────── RFQ 정보 ───────────── */
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
+ ),
+ cell: ({ row }) => (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800"
+ onClick={() => onSelectDetail?.(row.original)}
+ >
+ {row.getValue("rfqCode") as string}
+ </Button>
+ ),
+ size: 120,
+ },
+ {
+ accessorKey: "rfqStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("rfqStatus") as string
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case "DRAFT": return "secondary"
+ case "Doc. Received": return "outline"
+ case "PIC Assigned": return "default"
+ case "Doc. Confirmed": return "default"
+ case "Init. RFQ Sent": return "default"
+ case "Init. RFQ Answered": return "success"
+ case "TBE started": return "warning"
+ case "TBE finished": return "warning"
+ case "Final RFQ Sent": return "default"
+ case "Quotation Received": return "success"
+ case "Vendor Selected": return "success"
+ default: return "secondary"
+ }
+ }
+ return (
+ <Badge variant={getStatusColor(status) as any}>
+ {status}
+ </Badge>
+ )
+ },
+ size: 140
+ },
+ {
+ accessorKey: "initialRfqStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="초기 RFQ 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.getValue("initialRfqStatus") as string
+ const getInitialStatusColor = (status: string) => {
+ switch (status) {
+ case "PENDING": return "outline"
+ case "SENT": return "default"
+ case "RESPONDED": return "success"
+ case "EXPIRED": return "destructive"
+ case "CANCELLED": return "secondary"
+ default: return "secondary"
+ }
+ }
+ return (
+ <Badge variant={getInitialStatusColor(status) as any}>
+ {status}
+ </Badge>
+ )
+ },
+ size: 120
+ },
+
+ /** ───────────── 벤더 정보 ───────────── */
+ {
+ id: "vendorInfo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 정보" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.original.vendorName as string
+ const vendorCode = row.original.vendorCode as string
+ const vendorCountry = row.original.vendorCountry as string
+ const businessSize = row.original.vendorBusinessSize as string
+
+ return (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <Building className="h-4 w-4 text-muted-foreground" />
+ <div className="font-medium">{vendorName}</div>
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {vendorCode} • {vendorCountry}
+ </div>
+ {businessSize && (
+ <Badge variant="outline" className="text-xs">
+ {businessSize}
+ </Badge>
+ )}
+ </div>
+ )
+ },
+ size: 200,
+ },
+
+ /** ───────────── 날짜 정보 ───────────── */
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => {
+ const dueDate = row.getValue("dueDate") as Date
+ const isOverdue = dueDate && new Date(dueDate) < new Date()
+
+ return dueDate ? (
+ <div className={`flex items-center gap-2 ${isOverdue ? 'text-red-600' : ''}`}>
+ <Calendar className="h-4 w-4" />
+ <div>
+ <div className="font-medium">{formatDate(dueDate)}</div>
+ {isOverdue && (
+ <div className="text-xs text-red-600">지연</div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "validDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효일" />
+ ),
+ cell: ({ row }) => {
+ const validDate = row.getValue("validDate") as Date
+ return validDate ? (
+ <div className="text-sm">
+ {formatDate(validDate)}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ },
+
+ /** ───────────── Incoterms ───────────── */
+ {
+ id: "incoterms",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Incoterms" />
+ ),
+ cell: ({ row }) => {
+ const code = row.original.incotermsCode as string
+ const description = row.original.incotermsDescription as string
+
+ return code ? (
+ <div className="space-y-1">
+ <Badge variant="outline">{code}</Badge>
+ {description && (
+ <div className="text-xs text-muted-foreground max-w-[150px] truncate" title={description}>
+ {description}
+ </div>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 120,
+ },
+
+ /** ───────────── 플래그 정보 ───────────── */
+ {
+ id: "flags",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="플래그" />
+ ),
+ cell: ({ row }) => {
+ const shortList = row.original.shortList as boolean
+ const returnYn = row.original.returnYn as boolean
+ const cpRequestYn = row.original.cpRequestYn as boolean
+ const prjectGtcYn = row.original.prjectGtcYn as boolean
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {shortList && (
+ <Badge variant="secondary" className="text-xs">
+ <CheckCircle2 className="h-3 w-3 mr-1" />
+ Short List
+ </Badge>
+ )}
+ {returnYn && (
+ <Badge variant="outline" className="text-xs">
+ Return
+ </Badge>
+ )}
+ {cpRequestYn && (
+ <Badge variant="outline" className="text-xs">
+ CP Request
+ </Badge>
+ )}
+ {prjectGtcYn && (
+ <Badge variant="outline" className="text-xs">
+ GTC
+ </Badge>
+ )}
+ </div>
+ )
+ },
+ size: 150,
+ },
+
+ /** ───────────── 분류 정보 ───────────── */
+ {
+ id: "classification",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="분류" />
+ ),
+ cell: ({ row }) => {
+ const classification = row.original.classification as string
+ const sparepart = row.original.sparepart as string
+
+ return (
+ <div className="space-y-1">
+ {classification && (
+ <div className="text-sm font-medium max-w-[120px] truncate" title={classification}>
+ {classification}
+ </div>
+ )}
+ {sparepart && (
+ <Badge variant="outline" className="text-xs">
+ {sparepart}
+ </Badge>
+ )}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ /** ───────────── 리비전 정보 ───────────── */
+ {
+ accessorKey: "returnRevision",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="리비전" />
+ ),
+ cell: ({ row }) => {
+ const revision = row.getValue("returnRevision") as number
+ return revision ? (
+ <Badge variant="outline">
+ Rev. {revision}
+ </Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 80,
+ },
+
+ /** ───────────── 등록/수정 정보 ───────────── */
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="등록일" />
+ ),
+ cell: ({ row }) => {
+ const created = row.getValue("createdAt") as Date
+ const updated = row.original.updatedAt as Date
+
+ return (
+ <div className="space-y-1">
+ <div className="text-sm">{formatDate(created)}</div>
+ {updated && new Date(updated) > new Date(created) && (
+ <div className="text-xs text-blue-600">
+ 수정: {formatDate(updated)}
+ </div>
+ )}
+ </div>
+ )
+ },
+ size: 120,
+ },
+
+ /** ───────────── 액션 ───────────── */
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ return (
+ <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" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem onClick={() => onSelectDetail?.(row.original)}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세 보기
+ </DropdownMenuItem>
+ <DropdownMenuItem>
+ <MessageSquare className="mr-2 h-4 w-4" />
+ 벤더 응답 보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem>
+ <Settings className="mr-2 h-4 w-4" />
+ 설정 수정
+ </DropdownMenuItem>
+ <DropdownMenuItem className="text-red-600">
+ <XCircle className="mr-2 h-4 w-4" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx
new file mode 100644
index 00000000..fc8a5bc2
--- /dev/null
+++ b/lib/b-rfq/initial/initial-rfq-detail-table.tsx
@@ -0,0 +1,263 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션
+import { getInitialRfqDetailColumns } from "./initial-rfq-detail-columns"
+import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions"
+
+interface InitialRfqDetailTableProps {
+ promises: Promise<Awaited<ReturnType<typeof getInitialRfqDetail>>>
+ rfqId?: number
+}
+
+export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTableProps) {
+ const { data, pageCount } = React.use(promises)
+
+ // 선택된 상세 정보
+ const [selectedDetail, setSelectedDetail] = React.useState<any>(null)
+
+ const columns = React.useMemo(
+ () => getInitialRfqDetailColumns({
+ onSelectDetail: setSelectedDetail
+ }),
+ []
+ )
+
+ /**
+ * 필터 필드 정의
+ */
+ const filterFields: DataTableFilterField<any>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ placeholder: "RFQ 코드로 검색...",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ placeholder: "벤더명으로 검색...",
+ },
+ {
+ id: "rfqStatus",
+ label: "RFQ 상태",
+ options: [
+ { label: "Draft", value: "DRAFT", count: 0 },
+ { label: "문서 접수", value: "Doc. Received", count: 0 },
+ { label: "담당자 배정", value: "PIC Assigned", count: 0 },
+ { label: "문서 확정", value: "Doc. Confirmed", count: 0 },
+ { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 },
+ { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 },
+ { label: "TBE 시작", value: "TBE started", count: 0 },
+ { label: "TBE 완료", value: "TBE finished", count: 0 },
+ { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 },
+ { label: "견적 접수", value: "Quotation Received", count: 0 },
+ { label: "벤더 선정", value: "Vendor Selected", count: 0 },
+ ],
+ },
+ {
+ id: "initialRfqStatus",
+ label: "초기 RFQ 상태",
+ options: [
+ { label: "대기", value: "PENDING", count: 0 },
+ { label: "발송", value: "SENT", count: 0 },
+ { label: "응답", value: "RESPONDED", count: 0 },
+ { label: "만료", value: "EXPIRED", count: 0 },
+ { label: "취소", value: "CANCELLED", count: 0 },
+ ],
+ },
+ {
+ id: "vendorCountry",
+ label: "벤더 국가",
+ options: [
+ { label: "한국", value: "KR", count: 0 },
+ { label: "중국", value: "CN", count: 0 },
+ { label: "일본", value: "JP", count: 0 },
+ { label: "미국", value: "US", count: 0 },
+ { label: "독일", value: "DE", count: 0 },
+ ],
+ },
+ ]
+
+ /**
+ * 고급 필터 필드
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ type: "text",
+ },
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "vendorCountry",
+ label: "벤더 국가",
+ type: "multi-select",
+ options: [
+ { label: "한국", value: "KR" },
+ { label: "중국", value: "CN" },
+ { label: "일본", value: "JP" },
+ { label: "미국", value: "US" },
+ { label: "독일", value: "DE" },
+ ],
+ },
+ {
+ id: "rfqStatus",
+ label: "RFQ 상태",
+ type: "multi-select",
+ options: [
+ { label: "Draft", value: "DRAFT" },
+ { label: "문서 접수", value: "Doc. Received" },
+ { label: "담당자 배정", value: "PIC Assigned" },
+ { label: "문서 확정", value: "Doc. Confirmed" },
+ { label: "초기 RFQ 발송", value: "Init. RFQ Sent" },
+ { label: "초기 RFQ 응답", value: "Init. RFQ Answered" },
+ { label: "TBE 시작", value: "TBE started" },
+ { label: "TBE 완료", value: "TBE finished" },
+ { label: "최종 RFQ 발송", value: "Final RFQ Sent" },
+ { label: "견적 접수", value: "Quotation Received" },
+ { label: "벤더 선정", value: "Vendor Selected" },
+ ],
+ },
+ {
+ id: "initialRfqStatus",
+ label: "초기 RFQ 상태",
+ type: "multi-select",
+ options: [
+ { label: "대기", value: "PENDING" },
+ { label: "발송", value: "SENT" },
+ { label: "응답", value: "RESPONDED" },
+ { label: "만료", value: "EXPIRED" },
+ { label: "취소", value: "CANCELLED" },
+ ],
+ },
+ {
+ id: "vendorBusinessSize",
+ label: "벤더 규모",
+ type: "multi-select",
+ options: [
+ { label: "대기업", value: "LARGE" },
+ { label: "중기업", value: "MEDIUM" },
+ { label: "소기업", value: "SMALL" },
+ { label: "스타트업", value: "STARTUP" },
+ ],
+ },
+ {
+ id: "incotermsCode",
+ label: "Incoterms",
+ type: "text",
+ },
+ {
+ id: "dueDate",
+ label: "마감일",
+ type: "date",
+ },
+ {
+ id: "validDate",
+ label: "유효일",
+ type: "date",
+ },
+ {
+ id: "shortList",
+ label: "Short List",
+ type: "boolean",
+ },
+ {
+ id: "returnYn",
+ label: "Return 여부",
+ type: "boolean",
+ },
+ {
+ id: "cpRequestYn",
+ label: "CP Request 여부",
+ type: "boolean",
+ },
+ {
+ id: "prjectGtcYn",
+ label: "Project GTC 여부",
+ type: "boolean",
+ },
+ {
+ id: "classification",
+ label: "분류",
+ type: "text",
+ },
+ {
+ id: "sparepart",
+ label: "예비부품",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "등록일",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => originalRow.initialRfqId.toString(),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <div className="space-y-6">
+ {/* 메인 테이블 */}
+ <div className="h-full w-full">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <InitialRfqDetailTableToolbarActions table={table} rfqId={rfqId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+
+ {/* 선택된 상세 정보 패널 (필요시 추가) */}
+ {selectedDetail && (
+ <div className="border rounded-lg p-4">
+ <h3 className="text-lg font-semibold mb-2">
+ 상세 정보: {selectedDetail.rfqCode}
+ </h3>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <strong>벤더:</strong> {selectedDetail.vendorName}
+ </div>
+ <div>
+ <strong>국가:</strong> {selectedDetail.vendorCountry}
+ </div>
+ <div>
+ <strong>마감일:</strong> {formatDate(selectedDetail.dueDate)}
+ </div>
+ <div>
+ <strong>유효일:</strong> {formatDate(selectedDetail.validDate)}
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
new file mode 100644
index 00000000..981659d5
--- /dev/null
+++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx
@@ -0,0 +1,109 @@
+// initial-rfq-detail-toolbar-actions.tsx
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Button } from "@/components/ui/button"
+import {
+ Download,
+ Mail,
+ RefreshCw,
+ Settings,
+ Trash2,
+ FileText
+} from "lucide-react"
+
+interface InitialRfqDetailTableToolbarActionsProps {
+ table: Table<any>
+ rfqId?: number
+}
+
+export function InitialRfqDetailTableToolbarActions({
+ table,
+ rfqId
+}: InitialRfqDetailTableToolbarActionsProps) {
+
+ // 선택된 행들 가져오기
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedDetails = selectedRows.map((row) => row.original)
+ const selectedCount = selectedRows.length
+
+ const handleBulkEmail = () => {
+ console.log("Bulk email to selected vendors:", selectedDetails)
+ // 벌크 이메일 로직 구현
+ }
+
+ const handleBulkDelete = () => {
+ console.log("Bulk delete selected items:", selectedDetails)
+ // 벌크 삭제 로직 구현
+ table.toggleAllRowsSelected(false)
+ }
+
+ const handleExport = () => {
+ console.log("Export data:", selectedCount > 0 ? selectedDetails : "all data")
+ // 데이터 엑스포트 로직 구현
+ }
+
+ const handleRefresh = () => {
+ window.location.reload()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/** 선택된 항목이 있을 때만 표시되는 액션들 */}
+ {selectedCount > 0 && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkEmail}
+ className="h-8"
+ >
+ <Mail className="mr-2 h-4 w-4" />
+ 이메일 발송 ({selectedCount})
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBulkDelete}
+ className="h-8 text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({selectedCount})
+ </Button>
+ </>
+ )}
+
+ {/** 항상 표시되는 액션들 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="h-8"
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 엑스포트
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefresh}
+ className="h-8"
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 새로고침
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8"
+ >
+ <Settings className="mr-2 h-4 w-4" />
+ 설정
+ </Button>
+ </div>
+ )
+}
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts
index e60e446d..0dc61832 100644
--- a/lib/b-rfq/service.ts
+++ b/lib/b-rfq/service.ts
@@ -4,15 +4,16 @@ import { revalidateTag, unstable_cache } from "next/cache"
import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm"
import { filterColumns } from "@/lib/filter-columns"
import db from "@/db/db"
-import { RfqDashboardView, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
+import { Incoterm, RfqDashboardView, Vendor, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정
import { rfqDashboardView } from "@/db/schema" // 뷰 import
import type { SQL } from "drizzle-orm"
-import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations"
+import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { unlink } from "fs/promises"
const tag = {
+ initialRfqDetail:"initial-rfq",
rfqDashboard: 'rfq-dashboard',
rfq: (id: number) => `rfq-${id}`,
rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`,
@@ -1017,4 +1018,217 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) {
message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.",
}
}
+}
+
+
+
+//Initial RFQ
+
+export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: initialRfqDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
+
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: initialRfqDetailView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
+
+ let rfqIdWhere: SQL<unknown> | undefined = undefined;
+ if (rfqId) {
+ rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId);
+ }
+
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s);
+ if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition);
+
+ const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s);
+ if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition);
+
+ const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s);
+ if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition);
+
+ const classificationCondition = ilike(initialRfqDetailView.classification, s);
+ if (classificationCondition) validSearchConditions.push(classificationCondition);
+
+ const sparepartCondition = ilike(initialRfqDetailView.sparepart, s);
+ if (sparepartCondition) validSearchConditions.push(sparepartCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+
+ // 5) 최종 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
+
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (rfqIdWhere) whereConditions.push(rfqIdWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 6) 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(initialRfqDetailView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log(total);
+
+ // 7) 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect;
+ return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(initialRfqDetailView.createdAt));
+ }
+
+ const initialRfqData = await db
+ .select()
+ .from(initialRfqDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: initialRfqData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getInitialRfqDetail:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ { revalidate: 3600, tags: [tag.initialRfqDetail] },
+ )();
+}
+
+export async function getVendorsForSelection() {
+ try {
+ const vendorsData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ country: vendors.country,
+ status: vendors.status,
+ })
+ .from(vendors)
+ // .where(
+ // and(
+ // ne(vendors.status, "BLACKLISTED"),
+ // ne(vendors.status, "REJECTED")
+ // )
+ // )
+ .orderBy(vendors.vendorName)
+
+ return vendorsData.map(vendor => ({
+ id: vendor.id,
+ vendorName: vendor.vendorName || "",
+ vendorCode: vendor.vendorCode || "",
+ country: vendor.country || "",
+ status: vendor.status,
+ }))
+ } catch (error) {
+ console.error("Error fetching vendors:", error)
+ throw new Error("Failed to fetch vendors")
+ }
+}
+
+export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) {
+ try {
+ const [newRecord] = await db
+ .insert(initialRfq)
+ .values({
+ rfqId: data.rfqId,
+ vendorId: data.vendorId,
+ initialRfqStatus: data.initialRfqStatus,
+ dueDate: data.dueDate,
+ validDate: data.validDate,
+ incotermsCode: data.incotermsCode,
+ gtc: data.gtc,
+ gtcValidDate: data.gtcValidDate,
+ classification: data.classification,
+ sparepart: data.sparepart,
+ shortList: data.shortList,
+ returnYn: data.returnYn,
+ cpRequestYn: data.cpRequestYn,
+ prjectGtcYn: data.prjectGtcYn,
+ returnRevision: data.returnRevision,
+ })
+ .returning()
+
+ return {
+ success: true,
+ message: "초기 RFQ가 성공적으로 추가되었습니다.",
+ data: newRecord,
+ }
+ } catch (error) {
+ console.error("Error adding initial RFQ:", error)
+ return {
+ success: false,
+ message: "초기 RFQ 추가에 실패했습니다.",
+ error,
+ }
+ }
+}
+
+export async function getIncotermsForSelection() {
+ try {
+ const incotermData = await db
+ .select({
+ code: incoterms.code,
+ description: incoterms.description,
+ })
+ .from(incoterms)
+ .orderBy(incoterms.code)
+
+ return incotermData
+
+ } catch (error) {
+ console.error("Error fetching incoterms:", error)
+ throw new Error("Failed to fetch incoterms")
+ }
} \ No newline at end of file
diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts
index df95b1d2..15cc9425 100644
--- a/lib/b-rfq/validations.ts
+++ b/lib/b-rfq/validations.ts
@@ -165,3 +165,103 @@ export const deleteAttachmentsSchema = z.object({
})
export type DeleteAttachmentsInput = z.infer<typeof deleteAttachmentsSchema>
+
+
+//Inital RFQ
+export const searchParamsInitialRfqDetailCache = createSearchParamsCache({
+ // 공통 플래그
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 - initialRfqDetailView 기반
+ sort: getSortingStateParser<{
+ rfqId: number;
+ rfqCode: string;
+ rfqStatus: string;
+ initialRfqId: number;
+ initialRfqStatus: string;
+ vendorId: number;
+ vendorCode: string;
+ vendorName: string;
+ vendorCountry: string;
+ vendorBusinessSize: string;
+ dueDate: Date;
+ validDate: Date;
+ incotermsCode: string;
+ incotermsDescription: string;
+ shortList: boolean;
+ returnYn: boolean;
+ cpRequestYn: boolean;
+ prjectGtcYn: boolean;
+ returnRevision: number;
+ gtc: string;
+ gtcValidDate: string;
+ classification: string;
+ sparepart: string;
+ createdAt: Date;
+ updatedAt: Date;
+ }>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색 키워드
+ search: parseAsString.withDefault(""),
+
+ // Initial RFQ Detail 특화 필터
+ rfqCode: parseAsString.withDefault(""),
+ rfqStatus: parseAsStringEnum([
+ "DRAFT",
+ "Doc. Received",
+ "PIC Assigned",
+ "Doc. Confirmed",
+ "Init. RFQ Sent",
+ "Init. RFQ Answered",
+ "TBE started",
+ "TBE finished",
+ "Final RFQ Sent",
+ "Quotation Received",
+ "Vendor Selected"
+ ]),
+ initialRfqStatus: parseAsStringEnum([
+ "PENDING",
+ "SENT",
+ "RESPONDED",
+ "EXPIRED",
+ "CANCELLED"
+ ]),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ vendorCountry: parseAsString.withDefault(""),
+ vendorBusinessSize: parseAsStringEnum([
+ "LARGE",
+ "MEDIUM",
+ "SMALL",
+ "STARTUP"
+ ]),
+ incotermsCode: parseAsString.withDefault(""),
+ dueDateFrom: parseAsString.withDefault(""),
+ dueDateTo: parseAsString.withDefault(""),
+ validDateFrom: parseAsString.withDefault(""),
+ validDateTo: parseAsString.withDefault(""),
+ shortList: parseAsStringEnum(["true", "false"]),
+ returnYn: parseAsStringEnum(["true", "false"]),
+ cpRequestYn: parseAsStringEnum(["true", "false"]),
+ prjectGtcYn: parseAsStringEnum(["true", "false"]),
+ classification: parseAsString.withDefault(""),
+ sparepart: parseAsString.withDefault(""),
+});
+
+export type GetInitialRfqDetailSchema = Awaited<ReturnType<typeof searchParamsInitialRfqDetailCache.parse>>;
+
+
diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts
index d2a14980..344597fa 100644
--- a/lib/vendor-document-list/import-service.ts
+++ b/lib/vendor-document-list/import-service.ts
@@ -183,7 +183,6 @@ class ImportService {
.where(eq(contracts.id, contractId))
.limit(1)
-
return result?.projectCode && result?.vendorCode
? { projectCode: result.projectCode, vendorCode: result.vendorCode }
: null
@@ -608,6 +607,9 @@ class ImportService {
eq(documents.externalSystemType, sourceSystem)
))
+ console.log(contractId, "contractId")
+
+
// 프로젝트 코드와 벤더 코드 조회
const contractInfo = await this.getContractInfoById(contractId)
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
index b80c0869..ad184378 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx
@@ -16,123 +16,36 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
-import { Badge } from "@/components/ui/badge"
import {
Ellipsis,
- Calendar,
- CalendarClock,
- User,
FileText,
Eye,
Edit,
Trash2,
- Building,
- Code,
- Settings
} from "lucide-react"
import { cn } from "@/lib/utils"
import { SimplifiedDocumentsView } from "@/db/schema"
+// DocumentSelectionContext를 import (실제 파일 경로에 맞게 수정 필요)
+// 예: import { DocumentSelectionContext } from "../user-vendor-document-display"
+// 또는: import { DocumentSelectionContext } from "./user-vendor-document-display"
+import { DocumentSelectionContext } from "@/components/ship-vendor-document/user-vendor-document-table-container"
+
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<SimplifiedDocumentsView> | null>>
}
-// 유틸리티 함수들
-const getDrawingKindText = (drawingKind: string) => {
- switch (drawingKind) {
- case 'B3': return 'B3 도면'
- case 'B4': return 'B4 도면'
- case 'B5': return 'B5 도면'
- default: return drawingKind
- }
-}
-
-const getDrawingKindColor = (drawingKind: string) => {
- switch (drawingKind) {
- case 'B3': return 'bg-blue-100 text-blue-800'
- case 'B4': return 'bg-green-100 text-green-800'
- case 'B5': return 'bg-purple-100 text-purple-800'
- default: return 'bg-gray-100 text-gray-800'
- }
-}
-
-// 스테이지별 이름 표시 컴포넌트
-const StageNameDisplay = ({
- stageName,
- drawingKind,
- isFirst = true
-}: {
- stageName: string | null,
- drawingKind: string | null,
- isFirst?: boolean
-}) => {
- if (!stageName) return <span className="text-gray-400">-</span>
-
- const stageType = isFirst ? "1차" : "2차"
- const getExpectedStage = () => {
- if (drawingKind === 'B4') return isFirst ? 'Pre' : 'Work'
- if (drawingKind === 'B3') return isFirst ? 'Approval' : 'Work'
- if (drawingKind === 'B5') return isFirst ? 'First' : 'Second'
- return ''
- }
-
- return (
- <div className="flex flex-col gap-1">
- <div className="text-xs text-gray-500">{stageType} 스테이지</div>
- <div className="text-sm font-medium">{stageName}</div>
- {getExpectedStage() && (
- <div className="text-xs text-gray-400">({getExpectedStage()})</div>
- )}
- </div>
- )
-}
-
-// 날짜 정보 표시 컴포넌트
-const StageDateInfo = ({
- planDate,
- actualDate,
- stageName
-}: {
- planDate: string | null
- actualDate: string | null
- stageName: string | null
-}) => {
- if (!planDate && !actualDate) {
- return <span className="text-gray-400">날짜 미설정</span>
- }
-
- const isCompleted = !!actualDate
- const isLate = actualDate && planDate && new Date(actualDate) > new Date(planDate)
-
+// 날짜 표시 컴포넌트 (간단 버전)
+const DateDisplay = ({ date, isSelected = false }: { date: string | null, isSelected?: boolean }) => {
+ if (!date) return <span className="text-gray-400">-</span>
+
return (
- <div className="flex flex-col gap-1">
- {planDate && (
- <div className="text-sm">
- <span className="text-gray-500">계획: </span>
- <span>{formatDate(planDate)}</span>
- </div>
- )}
- {actualDate && (
- <div className="text-sm">
- <span className="text-gray-500">실제: </span>
- <span className={cn(
- isLate ? "text-red-600 font-medium" : "text-green-600 font-medium"
- )}>
- {formatDate(actualDate)}
- </span>
- </div>
- )}
- {!actualDate && planDate && (
- <div className="text-xs text-orange-600">
- 진행중
- </div>
- )}
- {isCompleted && (
- <div className="text-xs text-green-600">
- ✓ 완료
- </div>
- )}
- </div>
+ <span className={cn(
+ "text-sm",
+ isSelected && "text-blue-600 font-semibold"
+ )}>
+ {formatDate(date)}
+ </span>
)
}
@@ -140,36 +53,28 @@ export function getSimplifiedDocumentColumns({
setRowAction,
}: GetColumnsProps): ColumnDef<SimplifiedDocumentsView>[] {
- // 기본 컬럼들
- const baseColumns: ColumnDef<SimplifiedDocumentsView>[] = [
- // 체크박스 선택
+ const columns: ColumnDef<SimplifiedDocumentsView>[] = [
+ // 라디오 버튼 같은 체크박스 선택
{
id: "select",
header: ({ table }) => (
- <Checkbox
- checked={
- table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
- }
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-0.5"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-0.5"
- />
+ <div className="flex items-center justify-center">
+ <span className="text-xs text-gray-500">선택</span>
+ </div>
),
+ cell: ({ row }) => {
+ const doc = row.original
+
+ return (
+ <SelectCell documentId={doc.documentId} />
+ )
+ },
size: 40,
enableSorting: false,
enableHiding: false,
},
- // 문서번호 + Drawing Kind
+ // 문서번호 (선택된 행 하이라이트 적용)
{
accessorKey: "docNumber",
header: ({ column }) => (
@@ -177,33 +82,19 @@ export function getSimplifiedDocumentColumns({
),
cell: ({ row }) => {
const doc = row.original
+
return (
- <div className="flex flex-col gap-1 items-start">
- <span className="font-mono text-sm font-medium">{doc.docNumber}</span>
- {doc.vendorDocNumber && (
- <span className="font-mono text-xs text-gray-500">
- 벤더: {doc.vendorDocNumber}
- </span>
- )}
- {doc.drawingKind && (
- <Badge
- variant="outline"
- className={cn("text-xs", getDrawingKindColor(doc.drawingKind))}
- >
- {getDrawingKindText(doc.drawingKind)}
- </Badge>
- )}
- </div>
+ <DocNumberCell doc={doc} />
)
},
- size: 140,
+ size: 120,
enableResizing: true,
meta: {
excelHeader: "문서번호"
},
},
- // 문서명 + 프로젝트/벤더 정보
+ // 문서명 (선택된 행 하이라이트 적용)
{
accessorKey: "title",
header: ({ column }) => (
@@ -211,148 +102,136 @@ export function getSimplifiedDocumentColumns({
),
cell: ({ row }) => {
const doc = row.original
+
return (
- <div className="min-w-0 flex-1">
- <div className="font-medium text-gray-900 truncate" title={doc.title}>
- {doc.title}
- </div>
- <div className="flex items-center gap-2 text-sm text-gray-500 mt-1">
- {doc.pic && (
- <span className="text-xs bg-gray-100 px-2 py-0.5 rounded">
- PIC: {doc.pic}
- </span>
- )}
- {doc.projectCode && (
- <div className="flex items-center gap-1">
- <Building className="w-3 h-3" />
- <span>{doc.projectCode}</span>
- </div>
- )}
- {doc.vendorName && (
- <div className="flex items-center gap-1">
- <Code className="w-3 h-3" />
- <span className="truncate max-w-[100px]">{doc.vendorName}</span>
- </div>
- )}
- </div>
- </div>
+ <TitleCell doc={doc} />
)
},
- size: 200,
enableResizing: true,
meta: {
excelHeader: "문서명"
},
},
- // 첫 번째 스테이지 정보
+ // 프로젝트 코드
{
- accessorKey: "firstStageName",
+ accessorKey: "projectCode",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="1차 스테이지" />
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
),
cell: ({ row }) => {
- const doc = row.original
+ const projectCode = row.original.projectCode
+
return (
- <StageNameDisplay
- stageName={doc.firstStageName}
- drawingKind={doc.drawingKind}
- isFirst={true}
- />
+ <ProjectCodeCell projectCode={projectCode} documentId={row.original.documentId} />
)
},
- size: 130,
enableResizing: true,
meta: {
- excelHeader: "1차 스테이지"
+ excelHeader: "프로젝트"
},
},
- // 첫 번째 스테이지 날짜
+ // 1차 스테이지 그룹
{
- accessorKey: "firstStagePlanDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="1차 일정" />
- ),
- cell: ({ row }) => {
- const doc = row.original
+ id: "firstStageGroup",
+ header: ({ table }) => {
+ // 첫 번째 행의 firstStageName을 그룹 헤더로 사용
+ const firstRow = table.getRowModel().rows[0]?.original
+ const stageName = firstRow?.firstStageName || "1차 스테이지"
return (
- <StageDateInfo
- planDate={doc.firstStagePlanDate}
- actualDate={doc.firstStageActualDate}
- stageName={doc.firstStageName}
- />
- )
- },
- size: 140,
- enableResizing: true,
- meta: {
- excelHeader: "1차 일정"
- },
- },
-
- // 두 번째 스테이지 정보
- {
- accessorKey: "secondStageName",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="2차 스테이지" />
- ),
- cell: ({ row }) => {
- const doc = row.original
- return (
- <StageNameDisplay
- stageName={doc.secondStageName}
- drawingKind={doc.drawingKind}
- isFirst={false}
- />
+ <div className="text-center font-medium text-gray-700">
+ {stageName}
+ </div>
)
},
- size: 130,
- enableResizing: true,
- meta: {
- excelHeader: "2차 스테이지"
- },
+ columns: [
+ {
+ accessorKey: "firstStagePlanDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="계획일" />
+ ),
+ cell: ({ row }) => {
+ return <FirstStagePlanDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "1차 계획일"
+ },
+ },
+ {
+ accessorKey: "firstStageActualDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실제일" />
+ ),
+ cell: ({ row }) => {
+ return <FirstStageActualDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "1차 실제일"
+ },
+ },
+ ],
},
- // 두 번째 스테이지 날짜
+ // 2차 스테이지 그룹
{
- accessorKey: "secondStagePlanDate",
- header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="2차 일정" />
- ),
- cell: ({ row }) => {
- const doc = row.original
+ id: "secondStageGroup",
+ header: ({ table }) => {
+ // 첫 번째 행의 secondStageName을 그룹 헤더로 사용
+ const firstRow = table.getRowModel().rows[0]?.original
+ const stageName = firstRow?.secondStageName || "2차 스테이지"
return (
- <StageDateInfo
- planDate={doc.secondStagePlanDate}
- actualDate={doc.secondStageActualDate}
- stageName={doc.secondStageName}
- />
+ <div className="text-center font-medium text-gray-700">
+ {stageName}
+ </div>
)
},
- size: 140,
- enableResizing: true,
- meta: {
- excelHeader: "2차 일정"
- },
+ columns: [
+ {
+ accessorKey: "secondStagePlanDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="계획일" />
+ ),
+ cell: ({ row }) => {
+ return <SecondStagePlanDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "2차 계획일"
+ },
+ },
+ {
+ accessorKey: "secondStageActualDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="실제일" />
+ ),
+ cell: ({ row }) => {
+ return <SecondStageActualDateCell row={row} />
+ },
+ enableResizing: true,
+ meta: {
+ excelHeader: "2차 실제일"
+ },
+ },
+ ],
},
// 첨부파일 수
{
accessorKey: "attachmentCount",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ <DataTableColumnHeaderSimple column={column} title="파일" />
),
cell: ({ row }) => {
const count = row.original.attachmentCount || 0
+
return (
- <div className="flex items-center gap-1">
- <FileText className="w-4 h-4 text-gray-400" />
- <span className="text-sm font-medium">{count}</span>
- </div>
+ <AttachmentCountCell count={count} documentId={row.original.documentId} />
)
},
- size: 80,
+ size: 60,
enableResizing: true,
meta: {
excelHeader: "첨부파일"
@@ -365,12 +244,11 @@ export function getSimplifiedDocumentColumns({
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="업데이트" />
),
- cell: ({ cell }) => (
- <span className="text-sm text-gray-600">
- {formatDateTime(cell.getValue() as Date)}
- </span>
- ),
- size: 140,
+ cell: ({ cell, row }) => {
+ return (
+ <UpdatedAtCell updatedAt={cell.getValue() as Date} documentId={row.original.documentId} />
+ )
+ },
enableResizing: true,
meta: {
excelHeader: "업데이트"
@@ -378,50 +256,208 @@ export function getSimplifiedDocumentColumns({
},
// 액션 버튼
- {
- id: "actions",
- header: () => <span className="sr-only">Actions</span>,
- cell: ({ row }) => {
- const doc = row.original
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">Open menu</span>
- <Ellipsis className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem
- onClick={() => setRowAction({ type: "view", row: doc })}
- >
- <Eye className="mr-2 h-4 w-4" />
- 보기
- </DropdownMenuItem>
- <DropdownMenuItem
- onClick={() => setRowAction({ type: "edit", row: doc })}
- >
- <Edit className="mr-2 h-4 w-4" />
- 편집
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- onClick={() => setRowAction({ type: "delete", row: doc })}
- className="text-red-600"
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제
- <DropdownMenuShortcut>⌫</DropdownMenuShortcut>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
+ // {
+ // id: "actions",
+ // header: () => <span className="sr-only">Actions</span>,
+ // cell: ({ row }) => {
+ // const doc = row.original
+ // return (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">Open menu</span>
+ // <Ellipsis className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem
+ // onClick={() => setRowAction({ type: "view", row: doc })}
+ // >
+ // <Eye className="mr-2 h-4 w-4" />
+ // 보기
+ // </DropdownMenuItem>
+ // <DropdownMenuItem
+ // onClick={() => setRowAction({ type: "edit", row: doc })}
+ // >
+ // <Edit className="mr-2 h-4 w-4" />
+ // 편집
+ // </DropdownMenuItem>
+ // <DropdownMenuSeparator />
+ // <DropdownMenuItem
+ // onClick={() => setRowAction({ type: "delete", row: doc })}
+ // className="text-red-600"
+ // >
+ // <Trash2 className="mr-2 h-4 w-4" />
+ // 삭제
+ // <DropdownMenuShortcut>⌫</DropdownMenuShortcut>
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // )
+ // },
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
]
- return baseColumns
+ return columns
+}
+
+// 개별 셀 컴포넌트들 (Context 사용)
+function SelectCell({ documentId }: { documentId: number }) {
+ const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === documentId;
+
+ return (
+ <div className="flex items-center justify-center">
+ <input
+ type="radio"
+ checked={isSelected}
+ onChange={() => {
+ const newSelection = isSelected ? null : documentId;
+ setSelectedDocumentId(newSelection);
+ }}
+ className="cursor-pointer w-4 h-4"
+ />
+ </div>
+ );
+}
+
+function DocNumberCell({ doc }: { doc: SimplifiedDocumentsView }) {
+ const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === doc.documentId;
+
+ return (
+ <div
+ className={cn(
+ "font-mono text-sm font-medium cursor-pointer px-2 py-1 rounded transition-colors",
+ isSelected
+ ? "text-blue-600 font-bold bg-blue-50"
+ : "hover:bg-gray-50"
+ )}
+ onClick={() => {
+ const newSelection = isSelected ? null : doc.documentId;
+ setSelectedDocumentId(newSelection);
+ }}
+ >
+ {doc.docNumber}
+ </div>
+ );
+}
+
+function TitleCell({ doc }: { doc: SimplifiedDocumentsView }) {
+ const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === doc.documentId;
+
+ return (
+ <div
+ className={cn(
+ "font-medium text-gray-900 truncate max-w-[300px] cursor-pointer px-2 py-1 rounded transition-colors",
+ isSelected
+ ? "text-blue-600 font-bold bg-blue-50"
+ : "hover:bg-gray-50"
+ )}
+ title={doc.title}
+ onClick={() => {
+ const newSelection = isSelected ? null : doc.documentId;
+ setSelectedDocumentId(newSelection);
+ }}
+ >
+ {doc.title}
+ </div>
+ );
+}
+
+function ProjectCodeCell({ projectCode, documentId }: { projectCode: string | null, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === documentId;
+
+ if (!projectCode) return <span className="text-gray-400">-</span>;
+
+ return (
+ <span className={cn(
+ "text-sm font-medium",
+ isSelected && "text-blue-600 font-bold"
+ )}>
+ {projectCode}
+ </span>
+ );
+}
+
+function FirstStagePlanDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === row.original.documentId;
+
+ return <DateDisplay date={row.original.firstStagePlanDate} isSelected={isSelected} />;
+}
+
+function FirstStageActualDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === row.original.documentId;
+ const date = row.original.firstStageActualDate;
+
+ return (
+ <div className={cn(
+ date ? "text-green-600 font-medium" : "",
+ isSelected && date && "text-green-700 font-bold"
+ )}>
+ <DateDisplay date={date} isSelected={isSelected && !date} />
+ {date && <span className="text-xs block">✓ 완료</span>}
+ </div>
+ );
+}
+
+function SecondStagePlanDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === row.original.documentId;
+
+ return <DateDisplay date={row.original.secondStagePlanDate} isSelected={isSelected} />;
+}
+
+function SecondStageActualDateCell({ row }: { row: any }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === row.original.documentId;
+ const date = row.original.secondStageActualDate;
+
+ return (
+ <div className={cn(
+ date ? "text-green-600 font-medium" : "",
+ isSelected && date && "text-green-700 font-bold"
+ )}>
+ <DateDisplay date={date} isSelected={isSelected && !date} />
+ {date && <span className="text-xs block">✓ 완료</span>}
+ </div>
+ );
+}
+
+function AttachmentCountCell({ count, documentId }: { count: number, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === documentId;
+
+ return (
+ <div className="flex items-center justify-center gap-1">
+ <FileText className="w-4 h-4 text-gray-400" />
+ <span className={cn(
+ "text-sm font-medium",
+ isSelected && "text-blue-600 font-bold"
+ )}>
+ {count}
+ </span>
+ </div>
+ );
+}
+
+function UpdatedAtCell({ updatedAt, documentId }: { updatedAt: Date, documentId: number }) {
+ const { selectedDocumentId } = React.useContext(DocumentSelectionContext);
+ const isSelected = selectedDocumentId === documentId;
+
+ return (
+ <span className={cn(
+ "text-sm text-gray-600",
+ isSelected && "text-blue-600 font-semibold"
+ )}>
+ {formatDateTime(updatedAt)}
+ </span>
+ );
} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
index 3960bbce..508d8c91 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
@@ -6,29 +6,18 @@ import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
-import { EnhancedDocumentsView } from "@/db/schema/vendorDocu"
-import { AddDocumentListDialog } from "./add-doc-dialog"
-import { DeleteDocumentsDialog } from "./delete-docs-dialog"
-import { BulkUploadDialog } from "./bulk-upload-dialog"
-import type { EnhancedDocument } from "@/types/enhanced-documents"
+import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu"
import { SendToSHIButton } from "./send-to-shi-button"
import { ImportFromDOLCEButton } from "./import-from-dolce-button"
-import { SWPWorkflowPanel } from "./swp-workflow-panel"
interface EnhancedDocTableToolbarActionsProps {
- table: Table<EnhancedDocument>
+ table: Table<SimplifiedDocumentsView>
projectType: "ship" | "plant"
- selectedPackageId: number
- onNewDocument: () => void
- onBulkAction: (action: string, selectedRows: any[]) => Promise<void>
}
export function EnhancedDocTableToolbarActions({
table,
projectType,
- selectedPackageId,
- onNewDocument,
- onBulkAction
}: EnhancedDocTableToolbarActionsProps) {
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false)
@@ -61,45 +50,15 @@ export function EnhancedDocTableToolbarActions({
return (
<div className="flex items-center gap-2">
- {/* 삭제 버튼 */}
- {table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <DeleteDocumentsDialog
- documents={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
- onSuccess={() => table.toggleAllRowsSelected(false)}
- />
- ) : null}
-
- {/* projectType에 따른 조건부 렌더링 */}
- {projectType === "ship" ? (
+
<>
{/* SHIP: DOLCE에서 목록 가져오기 */}
<ImportFromDOLCEButton
- contractId={selectedPackageId}
+ allDocuments={allDocuments}
onImportComplete={handleImportComplete}
/>
</>
- ) : (
- <>
- {/* PLANT: 수동 문서 추가 */}
- <AddDocumentListDialog
- projectType={projectType}
- contractId={selectedPackageId}
- onSuccess={handleDocumentAdded}
- />
- </>
- )}
-
- {/* 일괄 업로드 버튼 (공통) */}
- <Button
- variant="outline"
- onClick={() => setBulkUploadDialogOpen(true)}
- className="flex items-center gap-2"
- >
- <Files className="w-4 h-4" />
- 일괄 업로드
- </Button>
+
{/* Export 버튼 (공통) */}
<Button
@@ -118,30 +77,14 @@ export function EnhancedDocTableToolbarActions({
</Button>
{/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */}
- <SendToSHIButton
+ {/* <SendToSHIButton
contractId={selectedPackageId}
documents={allDocuments}
onSyncComplete={handleSyncComplete}
projectType={projectType}
- />
-
- {/* SWP 전용 워크플로우 패널 */}
- {projectType === "plant" && (
- <SWPWorkflowPanel
- contractId={selectedPackageId}
- documents={allDocuments}
- onWorkflowUpdate={handleSyncComplete}
- />
- )}
+ /> */}
- {/* 일괄 업로드 다이얼로그 */}
- <BulkUploadDialog
- open={bulkUploadDialogOpen}
- onOpenChange={setBulkUploadDialogOpen}
- documents={allDocuments}
- projectType={projectType}
- contractId={selectedPackageId}
- />
+
</div>
)
} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-document-sheet.tsx b/lib/vendor-document-list/ship/enhanced-document-sheet.tsx
deleted file mode 100644
index 88e342c8..00000000
--- a/lib/vendor-document-list/ship/enhanced-document-sheet.tsx
+++ /dev/null
@@ -1,939 +0,0 @@
-// enhanced-document-sheet.tsx
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-import { useRouter } from "next/navigation"
-import {
- Loader,
- Save,
- Upload,
- Calendar,
- User,
- FileText,
- AlertTriangle,
- CheckCircle,
- Clock,
- Plus,
- X
-} from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@/components/ui/tabs"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Calendar as CalendarComponent } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { cn } from "@/lib/utils"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-import { EnhancedDocumentsView } from "@/db/schema/vendorDocu"
-
-// 드롭존과 파일 관련 컴포넌트들
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import prettyBytes from "pretty-bytes"
-
-// 스키마 정의
-const enhancedDocumentSchema = z.object({
- // 기본 문서 정보
- docNumber: z.string().min(1, "문서번호는 필수입니다"),
- title: z.string().min(1, "제목은 필수입니다"),
- pic: z.string().optional(),
- status: z.string().min(1, "상태는 필수입니다"),
- issuedDate: z.date().optional(),
-
- // 스테이지 관리 (plant 타입에서만 수정 가능)
- stages: z.array(z.object({
- id: z.number().optional(),
- stageName: z.string().min(1, "스테이지명은 필수입니다"),
- stageOrder: z.number(),
- priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"),
- planDate: z.date().optional(),
- assigneeName: z.string().optional(),
- description: z.string().optional(),
- })).optional(),
-
- // 리비전 업로드 (현재 스테이지에 대한)
- newRevision: z.object({
- stage: z.string().optional(),
- revision: z.string().optional(),
- uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"),
- uploaderName: z.string().optional(),
- comment: z.string().optional(),
- attachments: z.array(z.instanceof(File)).optional(),
- }).optional(),
-})
-
-type EnhancedDocumentSchema = z.infer<typeof enhancedDocumentSchema>
-
-// 상태 옵션 정의
-const statusOptions = [
- { value: "ACTIVE", label: "활성" },
- { value: "INACTIVE", label: "비활성" },
- { value: "COMPLETED", label: "완료" },
- { value: "CANCELLED", label: "취소" },
-]
-
-const priorityOptions = [
- { value: "HIGH", label: "높음" },
- { value: "MEDIUM", label: "보통" },
- { value: "LOW", label: "낮음" },
-]
-
-const stageStatusOptions = [
- { value: "PLANNED", label: "계획됨" },
- { value: "IN_PROGRESS", label: "진행중" },
- { value: "SUBMITTED", label: "제출됨" },
- { value: "APPROVED", label: "승인됨" },
- { value: "REJECTED", label: "반려됨" },
- { value: "COMPLETED", label: "완료됨" },
-]
-
-interface EnhancedDocumentSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- document: EnhancedDocumentsView | null
- projectType: "ship" | "plant"
- mode: "view" | "edit" | "upload" | "schedule" | "approve"
-}
-
-export function EnhancedDocumentSheet({
- document,
- projectType,
- mode = "view",
- ...props
-}: EnhancedDocumentSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
- const [uploadProgress, setUploadProgress] = React.useState(0)
- const [activeTab, setActiveTab] = React.useState("info")
- const router = useRouter()
-
- // 권한 계산
- const permissions = React.useMemo(() => {
- const canEdit = projectType === "plant" || mode === "edit"
- const canUpload = mode === "upload" || mode === "edit"
- const canApprove = mode === "approve" && projectType === "ship"
- const canSchedule = mode === "schedule" || (projectType === "plant" && mode === "edit")
-
- return { canEdit, canUpload, canApprove, canSchedule }
- }, [projectType, mode])
-
- const form = useForm<EnhancedDocumentSchema>({
- resolver: zodResolver(enhancedDocumentSchema),
- defaultValues: {
- docNumber: "",
- title: "",
- pic: "",
- status: "ACTIVE",
- issuedDate: undefined,
- stages: [],
- newRevision: {
- stage: "",
- revision: "",
- uploaderType: "vendor",
- uploaderName: "",
- comment: "",
- attachments: [],
- },
- },
- })
-
- // 폼 초기화
- React.useEffect(() => {
- if (document) {
- form.reset({
- docNumber: document.docNumber,
- title: document.title,
- pic: document.pic || "",
- status: document.status,
- issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined,
- stages: document.allStages?.map((stage, index) => ({
- id: stage.id,
- stageName: stage.stageName,
- stageOrder: stage.stageOrder || index,
- priority: stage.priority as "HIGH" | "MEDIUM" | "LOW" || "MEDIUM",
- planDate: stage.planDate ? new Date(stage.planDate) : undefined,
- assigneeName: stage.assigneeName || "",
- description: "",
- })) || [],
- newRevision: {
- stage: document.currentStageName || "",
- revision: "",
- uploaderType: "vendor",
- uploaderName: "",
- comment: "",
- attachments: [],
- },
- })
-
- // 모드에 따른 기본 탭 설정
- if (mode === "upload") {
- setActiveTab("upload")
- } else if (mode === "schedule") {
- setActiveTab("schedule")
- } else if (mode === "approve") {
- setActiveTab("approve")
- }
- }
- }, [document, form, mode])
-
- // 파일 처리
- const handleDropAccepted = (acceptedFiles: File[]) => {
- const newFiles = [...selectedFiles, ...acceptedFiles]
- setSelectedFiles(newFiles)
- form.setValue('newRevision.attachments', newFiles)
- }
-
- const removeFile = (index: number) => {
- const updatedFiles = [...selectedFiles]
- updatedFiles.splice(index, 1)
- setSelectedFiles(updatedFiles)
- form.setValue('newRevision.attachments', updatedFiles)
- }
-
- // 스테이지 추가/제거
- const addStage = () => {
- const currentStages = form.getValues("stages") || []
- const newStage = {
- stageName: "",
- stageOrder: currentStages.length,
- priority: "MEDIUM" as const,
- planDate: undefined,
- assigneeName: "",
- description: "",
- }
- form.setValue("stages", [...currentStages, newStage])
- }
-
- const removeStage = (index: number) => {
- const currentStages = form.getValues("stages") || []
- const updatedStages = currentStages.filter((_, i) => i !== index)
- form.setValue("stages", updatedStages)
- }
-
- // 제출 처리
- function onSubmit(input: EnhancedDocumentSchema) {
- startUpdateTransition(async () => {
- if (!document) return
-
- try {
- // 모드에 따른 다른 처리
- switch (mode) {
- case "edit":
- // 문서 정보 업데이트 + 스테이지 관리
- await updateDocumentInfo(input)
- break
- case "upload":
- // 리비전 업로드
- await uploadRevision(input)
- break
- case "approve":
- // 승인 처리
- await approveRevision(input)
- break
- case "schedule":
- // 스케줄 관리
- await updateSchedule(input)
- break
- }
-
- form.reset()
- setSelectedFiles([])
- props.onOpenChange?.(false)
- toast.success("성공적으로 처리되었습니다")
- router.refresh()
- } catch (error) {
- toast.error("처리 중 오류가 발생했습니다")
- console.error(error)
- }
- })
- }
-
- // 개별 처리 함수들
- const updateDocumentInfo = async (input: EnhancedDocumentSchema) => {
- // 문서 기본 정보 업데이트 API 호출
- console.log("문서 정보 업데이트:", input)
- }
-
- const uploadRevision = async (input: EnhancedDocumentSchema) => {
- if (!input.newRevision?.attachments?.length) {
- throw new Error("파일을 선택해주세요")
- }
-
- // 파일 업로드 처리
- const formData = new FormData()
- formData.append("documentId", String(document?.documentId))
- formData.append("stage", input.newRevision.stage || "")
- formData.append("revision", input.newRevision.revision || "")
- formData.append("uploaderType", input.newRevision.uploaderType)
-
- input.newRevision.attachments.forEach((file) => {
- formData.append("attachments", file)
- })
-
- // API 호출
- console.log("리비전 업로드:", formData)
- }
-
- const approveRevision = async (input: EnhancedDocumentSchema) => {
- // 승인 처리 API 호출
- console.log("리비전 승인:", input)
- }
-
- const updateSchedule = async (input: EnhancedDocumentSchema) => {
- // 스케줄 업데이트 API 호출
- console.log("스케줄 업데이트:", input)
- }
-
- // 제목 및 설명 생성
- const getSheetTitle = () => {
- switch (mode) {
- case "edit": return "문서 정보 수정"
- case "upload": return "리비전 업로드"
- case "approve": return "문서 승인"
- case "schedule": return "일정 관리"
- default: return "문서 상세"
- }
- }
-
- const getSheetDescription = () => {
- const docInfo = document ? `${document.docNumber} - ${document.title}` : ""
- switch (mode) {
- case "edit": return `문서 정보를 수정합니다. ${docInfo}`
- case "upload": return `새 리비전을 업로드합니다. ${docInfo}`
- case "approve": return `문서를 검토하고 승인 처리합니다. ${docInfo}`
- case "schedule": return `문서의 일정을 관리합니다. ${docInfo}`
- default: return docInfo
- }
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-2xl w-full">
- <SheetHeader className="text-left">
- <SheetTitle className="flex items-center gap-2">
- {mode === "upload" && <Upload className="w-5 h-5" />}
- {mode === "approve" && <CheckCircle className="w-5 h-5" />}
- {mode === "schedule" && <Calendar className="w-5 h-5" />}
- {mode === "edit" && <FileText className="w-5 h-5" />}
- {getSheetTitle()}
- </SheetTitle>
- <SheetDescription>
- {getSheetDescription()}
- </SheetDescription>
-
- {/* 프로젝트 타입 및 권한 표시 */}
- <div className="flex items-center gap-2 pt-2">
- <Badge variant={projectType === "ship" ? "default" : "secondary"}>
- {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"}
- </Badge>
- {document?.isOverdue && (
- <Badge variant="destructive" className="flex items-center gap-1">
- <AlertTriangle className="w-3 h-3" />
- 지연
- </Badge>
- )}
- {document?.currentStagePriority === "HIGH" && (
- <Badge variant="destructive">높은 우선순위</Badge>
- )}
- </div>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
- <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
- <TabsList className="grid w-full grid-cols-4">
- <TabsTrigger value="info">기본정보</TabsTrigger>
- <TabsTrigger value="schedule" disabled={!permissions.canSchedule}>
- 일정관리
- </TabsTrigger>
- <TabsTrigger value="upload" disabled={!permissions.canUpload}>
- 리비전업로드
- </TabsTrigger>
- <TabsTrigger value="approve" disabled={!permissions.canApprove}>
- 승인처리
- </TabsTrigger>
- </TabsList>
-
- {/* 기본 정보 탭 */}
- <TabsContent value="info" className="flex-1 space-y-4">
- <ScrollArea className="h-full pr-4">
- <div className="space-y-4">
- <FormField
- control={form.control}
- name="docNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문서번호</FormLabel>
- <FormControl>
- <Input {...field} disabled={!permissions.canEdit} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목</FormLabel>
- <FormControl>
- <Input {...field} disabled={!permissions.canEdit} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="pic"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자 (PIC)</FormLabel>
- <FormControl>
- <Input {...field} disabled={!permissions.canEdit} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>상태</FormLabel>
- <Select
- onValueChange={field.onChange}
- value={field.value}
- disabled={!permissions.canEdit}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {statusOptions.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="issuedDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>발행일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- disabled={!permissions.canEdit}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일", { locale: ko })
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <Calendar className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <CalendarComponent
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) => date > new Date()}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 현재 상태 정보 표시 */}
- {document && (
- <div className="space-y-3 p-4 bg-gray-50 rounded-lg">
- <h4 className="font-medium flex items-center gap-2">
- <Clock className="w-4 h-4" />
- 현재 진행 상황
- </h4>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="text-gray-500">현재 스테이지:</span>
- <p className="font-medium">{document.currentStageName || "-"}</p>
- </div>
- <div>
- <span className="text-gray-500">진행률:</span>
- <p className="font-medium">{document.progressPercentage || 0}%</p>
- </div>
- <div>
- <span className="text-gray-500">최신 리비전:</span>
- <p className="font-medium">{document.latestRevision || "-"}</p>
- </div>
- <div>
- <span className="text-gray-500">담당자:</span>
- <p className="font-medium">{document.currentStageAssigneeName || "-"}</p>
- </div>
- </div>
- </div>
- )}
- </div>
- </ScrollArea>
- </TabsContent>
-
- {/* 일정 관리 탭 */}
- <TabsContent value="schedule" className="flex-1 space-y-4">
- <ScrollArea className="h-full pr-4">
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h4 className="font-medium">스테이지 일정 관리</h4>
- {projectType === "plant" && (
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addStage}
- className="flex items-center gap-1"
- >
- <Plus className="w-4 h-4" />
- 스테이지 추가
- </Button>
- )}
- </div>
-
- {form.watch("stages")?.map((stage, index) => (
- <div key={index} className="p-4 border rounded-lg space-y-3">
- <div className="flex items-center justify-between">
- <h5 className="font-medium">스테이지 {index + 1}</h5>
- {projectType === "plant" && (
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeStage(index)}
- >
- <X className="w-4 h-4" />
- </Button>
- )}
- </div>
-
- <div className="grid grid-cols-2 gap-3">
- <FormField
- control={form.control}
- name={`stages.${index}.stageName`}
- render={({ field }) => (
- <FormItem>
- <FormLabel>스테이지명</FormLabel>
- <FormControl>
- <Input {...field} disabled={projectType === "ship"} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`stages.${index}.priority`}
- render={({ field }) => (
- <FormItem>
- <FormLabel>우선순위</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {priorityOptions.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`stages.${index}.planDate`}
- render={({ field }) => (
- <FormItem>
- <FormLabel>계획일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "MM/dd", { locale: ko })
- ) : (
- <span>날짜 선택</span>
- )}
- <Calendar className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <CalendarComponent
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name={`stages.${index}.assigneeName`}
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자</FormLabel>
- <FormControl>
- <Input {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- ))}
- </div>
- </ScrollArea>
- </TabsContent>
-
- {/* 리비전 업로드 탭 */}
- <TabsContent value="upload" className="flex-1 space-y-4">
- <ScrollArea className="h-full pr-4">
- <div className="space-y-4">
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="newRevision.stage"
- render={({ field }) => (
- <FormItem>
- <FormLabel>스테이지</FormLabel>
- <FormControl>
- <Input {...field} placeholder="예: Issued for Review" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="newRevision.revision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전</FormLabel>
- <FormControl>
- <Input {...field} placeholder="예: A, B, 1, 2..." />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="newRevision.uploaderName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>업로더명 (선택)</FormLabel>
- <FormControl>
- <Input {...field} placeholder="업로더 이름을 입력하세요" />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="newRevision.comment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>코멘트 (선택)</FormLabel>
- <FormControl>
- <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 업로드 드롭존 */}
- <FormField
- control={form.control}
- name="newRevision.attachments"
- render={() => (
- <FormItem>
- <FormLabel>파일 첨부</FormLabel>
- <Dropzone
- maxSize={3e9} // 3GB
- multiple={true}
- onDropAccepted={handleDropAccepted}
- disabled={isUpdatePending}
- >
- <DropzoneZone className="flex justify-center">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
- <DropzoneDescription>
- 또는 클릭하여 파일을 선택하세요
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- </Dropzone>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선택된 파일 목록 */}
- {selectedFiles.length > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h6 className="text-sm font-semibold">
- 선택된 파일 ({selectedFiles.length})
- </h6>
- </div>
- <FileList className="max-h-[200px]">
- {selectedFiles.map((file, index) => (
- <FileListItem key={index} className="p-3">
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.name}</FileListName>
- <FileListDescription>
- {prettyBytes(file.size)}
- </FileListDescription>
- </FileListInfo>
- <FileListAction
- onClick={() => removeFile(index)}
- disabled={isUpdatePending}
- >
- <X className="h-4 w-4" />
- </FileListAction>
- </FileListHeader>
- </FileListItem>
- ))}
- </FileList>
- </div>
- )}
-
- {/* 업로드 진행 상태 */}
- {isUpdatePending && uploadProgress > 0 && (
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <Loader className="h-4 w-4 animate-spin" />
- <span className="text-sm">{uploadProgress}% 업로드 중...</span>
- </div>
- <div className="h-2 w-full bg-muted rounded-full overflow-hidden">
- <div
- className="h-full bg-primary rounded-full transition-all"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- </ScrollArea>
- </TabsContent>
-
- {/* 승인 처리 탭 */}
- <TabsContent value="approve" className="flex-1 space-y-4">
- <ScrollArea className="h-full pr-4">
- <div className="space-y-4">
- <div className="p-4 bg-blue-50 rounded-lg">
- <h4 className="font-medium mb-2 flex items-center gap-2">
- <CheckCircle className="w-4 h-4 text-blue-600" />
- 승인 대상 문서
- </h4>
- <div className="text-sm space-y-1">
- <p><span className="font-medium">문서:</span> {document?.docNumber} - {document?.title}</p>
- <p><span className="font-medium">현재 스테이지:</span> {document?.currentStageName}</p>
- <p><span className="font-medium">최신 리비전:</span> {document?.latestRevision}</p>
- <p><span className="font-medium">업로더:</span> {document?.latestRevisionUploaderName}</p>
- </div>
- </div>
-
- <div className="space-y-3">
- <div className="flex gap-3">
- <Button
- type="button"
- className="flex-1 bg-green-600 hover:bg-green-700"
- onClick={() => {
- // 승인 처리 로직
- console.log("승인 처리")
- }}
- >
- <CheckCircle className="w-4 h-4 mr-2" />
- 승인
- </Button>
- <Button
- type="button"
- variant="destructive"
- className="flex-1"
- onClick={() => {
- // 반려 처리 로직
- console.log("반려 처리")
- }}
- >
- <X className="w-4 h-4 mr-2" />
- 반려
- </Button>
- </div>
-
- <FormField
- control={form.control}
- name="newRevision.comment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>검토 의견</FormLabel>
- <FormControl>
- <Textarea
- {...field}
- placeholder="승인/반려 사유를 입력하세요"
- rows={4}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- </div>
- </ScrollArea>
- </TabsContent>
- </Tabs>
-
- <Separator />
-
- <SheetFooter className="gap-2 pt-4">
- <SheetClose asChild>
- <Button type="button" variant="outline">
- 취소
- </Button>
- </SheetClose>
- <Button
- type="submit"
- disabled={isUpdatePending}
- className={mode === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
- >
- {isUpdatePending && <Loader className="mr-2 size-4 animate-spin" />}
- {mode === "upload" && <Upload className="mr-2 size-4" />}
- {mode === "approve" && <CheckCircle className="mr-2 size-4" />}
- {mode === "schedule" && <Calendar className="mr-2 size-4" />}
- {mode === "edit" && <Save className="mr-2 size-4" />}
-
- {mode === "upload" ? "업로드" :
- mode === "approve" ? "승인 처리" :
- mode === "schedule" ? "일정 저장" : "저장"}
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/enhanced-documents-table.tsx b/lib/vendor-document-list/ship/enhanced-documents-table.tsx
index 47bce275..2354a9be 100644
--- a/lib/vendor-document-list/ship/enhanced-documents-table.tsx
+++ b/lib/vendor-document-list/ship/enhanced-documents-table.tsx
@@ -9,36 +9,69 @@ import type {
} from "@/types/table"
import { useDataTable } from "@/hooks/use-data-table"
-import { getEnhancedDocumentsShip } from "../enhanced-document-service"
+import { getUserVendorDocuments, getUserVendorDocumentStats } from "@/lib/vendor-document-list/enhanced-document-service"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+import { FileText } from "lucide-react"
import { Label } from "@/components/ui/label"
import { DataTable } from "@/components/data-table/data-table"
import { SimplifiedDocumentsView } from "@/db/schema"
import { getSimplifiedDocumentColumns } from "./enhanced-doc-table-columns"
+import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions"
+
+// DrawingKind별 설명 매핑
+const DRAWING_KIND_INFO = {
+ B3: {
+ title: "B3 Vendor",
+ description: "Approval → Work 단계로 진행되는 승인 중심 도면",
+ color: "bg-blue-50 text-blue-700 border-blue-200"
+ },
+ B4: {
+ title: "B4 GTT",
+ description: "Pre → Work 단계로 진행되는 DOLCE 연동 도면",
+ color: "bg-green-50 text-green-700 border-green-200"
+ },
+ B5: {
+ title: "B5 FMEA",
+ description: "First → Second 단계로 진행되는 순차적 도면",
+ color: "bg-purple-50 text-purple-700 border-purple-200"
+ }
+} as const
interface SimplifiedDocumentsTableProps {
- promises: Promise<{
- data: SimplifiedDocumentsView[],
- pageCount: number,
- total: number
- }>
+ allPromises: Promise<[
+ Awaited<ReturnType<typeof getUserVendorDocuments>>,
+ Awaited<ReturnType<typeof getUserVendorDocumentStats>>
+ ]>
+ onDataLoaded?: (data: SimplifiedDocumentsView[]) => void
}
export function SimplifiedDocumentsTable({
- promises,
+ allPromises,
+ onDataLoaded,
}: SimplifiedDocumentsTableProps) {
// React.use()로 Promise 결과를 받고, 그 다음에 destructuring
- const result = React.use(promises)
- const { data, pageCount, total } = result
+ const [documentResult, statsResult] = React.use(allPromises)
+ const { data, pageCount, total, drawingKind, vendorInfo } = documentResult
+ const { stats, totalDocuments, primaryDrawingKind } = statsResult
+
+ // 데이터가 로드되면 콜백 호출
+ React.useEffect(() => {
+ if (onDataLoaded && data) {
+ onDataLoaded(data)
+ }
+ }, [data, onDataLoaded])
// 기존 상태들
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null) // ✅ 타입 변경
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<SimplifiedDocumentsView> | null>(null)
const [expandedRows,] = React.useState<Set<string>>(new Set())
const columns = React.useMemo(
- () => getSimplifiedDocumentColumns({ setRowAction }),
+ () => getSimplifiedDocumentColumns({
+ setRowAction,
+ }),
[setRowAction]
)
@@ -51,7 +84,7 @@ export function SimplifiedDocumentsTable({
},
{
id: "vendorDocNumber",
- label: "벤더 문서번호",
+ label: "벤더 문서번호",
type: "text",
},
{
@@ -107,7 +140,7 @@ export function SimplifiedDocumentsTable({
},
{
id: "secondStageName",
- label: "2차 스테이지",
+ label: "2차 스테이지",
type: "text",
},
{
@@ -155,14 +188,14 @@ export function SimplifiedDocumentsTable({
type: "text",
},
{
- id: "dGbn",
+ id: "dGbn",
label: "D 구분",
type: "text",
},
{
id: "degreeGbn",
label: "Degree 구분",
- type: "text",
+ type: "text",
},
{
id: "deptGbn",
@@ -171,7 +204,7 @@ export function SimplifiedDocumentsTable({
},
{
id: "jGbn",
- label: "J 구분",
+ label: "J 구분",
type: "text",
},
{
@@ -183,7 +216,7 @@ export function SimplifiedDocumentsTable({
// B4 문서가 있는지 확인하여 B4 전용 필드 추가
const hasB4Documents = data.some(doc => doc.drawingKind === 'B4')
- const finalFilterFields = hasB4Documents
+ const finalFilterFields = hasB4Documents
? [...advancedFilterFields, ...b4FilterFields]
: advancedFilterFields
@@ -203,36 +236,48 @@ export function SimplifiedDocumentsTable({
columnResizeMode: "onEnd",
})
- // ✅ 행 액션 처리 (필요에 따라 구현)
- React.useEffect(() => {
- if (rowAction?.type === "view") {
- toast.info(`문서 조회: ${rowAction.row.docNumber}`)
- setRowAction(null)
- } else if (rowAction?.type === "edit") {
- toast.info(`문서 편집: ${rowAction.row.docNumber}`)
- setRowAction(null)
- } else if (rowAction?.type === "delete") {
- toast.error(`문서 삭제: ${rowAction.row.docNumber}`)
- setRowAction(null)
- }
- }, [rowAction])
+ // 실제 데이터의 drawingKind 또는 주요 drawingKind 사용
+ const activeDrawingKind = drawingKind || primaryDrawingKind
+ const kindInfo = activeDrawingKind ? DRAWING_KIND_INFO[activeDrawingKind] : null
return (
- <div className="w-full" style={{maxWidth:'100%'}}>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={finalFilterFields}
- shallow={false}
- >
- {/* ✅ 추가 툴바 컨텐츠 (필요시) */}
- <div className="flex items-center gap-2">
- <Label className="text-sm font-medium">
- 총 {total}개 문서
- </Label>
+ <div className="w-full space-y-4">
+ {/* DrawingKind 정보 간단 표시 */}
+ {kindInfo && (
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <Badge variant="default" className="flex items-center gap-1 text-sm">
+ <FileText className="w-4 h-4" />
+ {kindInfo.title}
+ </Badge>
+ <span className="text-sm text-muted-foreground">
+ {kindInfo.description}
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {total}개 문서
+ </Badge>
+ </div>
</div>
- </DataTableAdvancedToolbar>
- </DataTable>
+ )}
+
+ {/* 테이블 */}
+ <div className="overflow-x-auto">
+ <DataTable table={table} compact>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={finalFilterFields}
+ shallow={false}
+ >
+ <EnhancedDocTableToolbarActions
+ table={table}
+ projectType="ship"
+ />
+
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
</div>
)
-}
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
index 519d40cb..23d80981 100644
--- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx
+++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
@@ -20,52 +20,87 @@ import {
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Separator } from "@/components/ui/separator"
+import { SimplifiedDocumentsView } from "@/db/schema"
+import { ImportStatus } from "../import-service"
interface ImportFromDOLCEButtonProps {
- contractId: number
+ allDocuments: SimplifiedDocumentsView[] // contractId 대신 문서 배열
onImportComplete?: () => void
}
-interface ImportStatus {
- lastImportAt?: string
- availableDocuments: number
- newDocuments: number
- updatedDocuments: number
- importEnabled: boolean
-}
-
export function ImportFromDOLCEButton({
- contractId,
+ allDocuments,
onImportComplete
}: ImportFromDOLCEButtonProps) {
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [importProgress, setImportProgress] = React.useState(0)
const [isImporting, setIsImporting] = React.useState(false)
- const [importStatus, setImportStatus] = React.useState<ImportStatus | null>(null)
+ const [importStatusMap, setImportStatusMap] = React.useState<Map<number, ImportStatus>>(new Map())
const [statusLoading, setStatusLoading] = React.useState(false)
- // DOLCE 상태 조회
- const fetchImportStatus = async () => {
+ // 문서들에서 contractId들 추출
+ const contractIds = React.useMemo(() => {
+ const uniqueIds = [...new Set(allDocuments.map(doc => doc.contractId).filter(Boolean))]
+ return uniqueIds.sort()
+ }, [allDocuments])
+
+ console.log(contractIds, "contractIds")
+
+ // 주요 contractId (가장 많이 나타나는 것)
+ const primaryContractId = React.useMemo(() => {
+ if (contractIds.length === 1) return contractIds[0]
+
+ const counts = allDocuments.reduce((acc, doc) => {
+ const id = doc.contractId || 0
+ acc[id] = (acc[id] || 0) + 1
+ return acc
+ }, {} as Record<number, number>)
+
+ return Number(Object.entries(counts)
+ .sort(([,a], [,b]) => b - a)[0]?.[0] || contractIds[0] || 0)
+ }, [contractIds, allDocuments])
+
+ // 모든 contractId에 대한 상태 조회
+ const fetchAllImportStatus = async () => {
setStatusLoading(true)
+ const statusMap = new Map<number, ImportStatus>()
+
try {
- const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`)
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}))
- throw new Error(errorData.message || 'Failed to fetch import status')
- }
+ // 각 contractId별로 상태 조회
+ const statusPromises = contractIds.map(async (contractId) => {
+ try {
+ const response = await fetch(`/api/sync/import/status?contractId=${contractId}&sourceSystem=DOLCE`)
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}))
+ throw new Error(errorData.message || 'Failed to fetch import status')
+ }
+
+ const status = await response.json()
+ if (status.error) {
+ console.warn(`Status error for contract ${contractId}:`, status.error)
+ return { contractId, status: null }
+ }
+
+ return { contractId, status }
+ } catch (error) {
+ console.error(`Failed to fetch status for contract ${contractId}:`, error)
+ return { contractId, status: null }
+ }
+ })
+
+ const results = await Promise.all(statusPromises)
+
+ results.forEach(({ contractId, status }) => {
+ if (status) {
+ statusMap.set(contractId, status)
+ }
+ })
- const status = await response.json()
- setImportStatus(status)
+ setImportStatusMap(statusMap)
- // 프로젝트 코드가 없는 경우 에러 처리
- if (status.error) {
- toast.error(`상태 확인 실패: ${status.error}`)
- setImportStatus(null)
- }
} catch (error) {
- console.error('Failed to fetch import status:', error)
- toast.error('DOLCE 상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.')
- setImportStatus(null)
+ console.error('Failed to fetch import statuses:', error)
+ toast.error('상태를 확인할 수 없습니다. 프로젝트 설정을 확인해주세요.')
} finally {
setStatusLoading(false)
}
@@ -73,11 +108,32 @@ export function ImportFromDOLCEButton({
// 컴포넌트 마운트 시 상태 조회
React.useEffect(() => {
- fetchImportStatus()
- }, [contractId])
+ if (contractIds.length > 0) {
+ fetchAllImportStatus()
+ }
+ }, [contractIds])
+
+ // 주요 contractId의 상태
+ const primaryImportStatus = importStatusMap.get(primaryContractId)
+
+ // 전체 통계 계산
+ const totalStats = React.useMemo(() => {
+ const statuses = Array.from(importStatusMap.values())
+ return statuses.reduce((acc, status) => ({
+ availableDocuments: acc.availableDocuments + (status.availableDocuments || 0),
+ newDocuments: acc.newDocuments + (status.newDocuments || 0),
+ updatedDocuments: acc.updatedDocuments + (status.updatedDocuments || 0),
+ importEnabled: acc.importEnabled || status.importEnabled
+ }), {
+ availableDocuments: 0,
+ newDocuments: 0,
+ updatedDocuments: 0,
+ importEnabled: false
+ })
+ }, [importStatusMap])
const handleImport = async () => {
- if (!contractId) return
+ if (contractIds.length === 0) return
setImportProgress(0)
setIsImporting(true)
@@ -85,51 +141,68 @@ export function ImportFromDOLCEButton({
try {
// 진행률 시뮬레이션
const progressInterval = setInterval(() => {
- setImportProgress(prev => Math.min(prev + 15, 90))
- }, 300)
-
- const response = await fetch('/api/sync/import', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contractId,
- sourceSystem: 'DOLCE'
+ setImportProgress(prev => Math.min(prev + 10, 85))
+ }, 500)
+
+ // 여러 contractId에 대해 순차적으로 가져오기 실행
+ const importPromises = contractIds.map(async (contractId) => {
+ const response = await fetch('/api/sync/import', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ contractId,
+ sourceSystem: 'DOLCE'
+ })
})
- })
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(errorData.message || 'Import failed')
- }
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(`Contract ${contractId}: ${errorData.message || 'Import failed'}`)
+ }
+
+ return response.json()
+ })
- const result = await response.json()
+ const results = await Promise.all(importPromises)
clearInterval(progressInterval)
setImportProgress(100)
+ // 결과 집계
+ const totalResult = results.reduce((acc, result) => ({
+ newCount: acc.newCount + (result.newCount || 0),
+ updatedCount: acc.updatedCount + (result.updatedCount || 0),
+ skippedCount: acc.skippedCount + (result.skippedCount || 0),
+ success: acc.success && result.success
+ }), {
+ newCount: 0,
+ updatedCount: 0,
+ skippedCount: 0,
+ success: true
+ })
+
setTimeout(() => {
setImportProgress(0)
setIsDialogOpen(false)
setIsImporting(false)
- if (result?.success) {
- const { newCount = 0, updatedCount = 0, skippedCount = 0 } = result
+ if (totalResult.success) {
toast.success(
`DOLCE 가져오기 완료`,
{
- description: `신규 ${newCount}건, 업데이트 ${updatedCount}건, 건너뜀 ${skippedCount}건 (B3/B4/B5 포함)`
+ description: `신규 ${totalResult.newCount}건, 업데이트 ${totalResult.updatedCount}건, 건너뜀 ${totalResult.skippedCount}건 (${contractIds.length}개 계약)`
}
)
} else {
toast.error(
`DOLCE 가져오기 부분 실패`,
{
- description: result?.message || '일부 DrawingKind에서 가져오기에 실패했습니다.'
+ description: '일부 계약에서 가져오기에 실패했습니다.'
}
)
}
- fetchImportStatus() // 상태 갱신
+ fetchAllImportStatus() // 상태 갱신
onImportComplete?.()
}, 500)
@@ -148,19 +221,19 @@ export function ImportFromDOLCEButton({
return <Badge variant="secondary">DOLCE 연결 확인 중...</Badge>
}
- if (!importStatus) {
+ if (importStatusMap.size === 0) {
return <Badge variant="destructive">DOLCE 연결 오류</Badge>
}
- if (!importStatus.importEnabled) {
+ if (!totalStats.importEnabled) {
return <Badge variant="secondary">DOLCE 가져오기 비활성화</Badge>
}
- if (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) {
+ if (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) {
return (
<Badge variant="default" className="gap-1 bg-blue-500 hover:bg-blue-600">
<AlertTriangle className="w-3 h-3" />
- 업데이트 가능 (B3/B4/B5)
+ 업데이트 가능 ({contractIds.length}개 계약)
</Badge>
)
}
@@ -173,8 +246,12 @@ export function ImportFromDOLCEButton({
)
}
- const canImport = importStatus?.importEnabled &&
- (importStatus?.newDocuments > 0 || importStatus?.updatedDocuments > 0)
+ const canImport = totalStats.importEnabled &&
+ (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0)
+
+ if (contractIds.length === 0) {
+ return null // 계약이 없으면 버튼을 표시하지 않음
+ }
return (
<>
@@ -193,19 +270,19 @@ export function ImportFromDOLCEButton({
<Download className="w-4 h-4" />
)}
<span className="hidden sm:inline">DOLCE에서 가져오기</span>
- {importStatus && (importStatus.newDocuments > 0 || importStatus.updatedDocuments > 0) && (
+ {totalStats.newDocuments + totalStats.updatedDocuments > 0 && (
<Badge
variant="default"
className="h-5 w-5 p-0 text-xs flex items-center justify-center bg-blue-500"
>
- {importStatus.newDocuments + importStatus.updatedDocuments}
+ {totalStats.newDocuments + totalStats.updatedDocuments}
</Badge>
)}
</Button>
</div>
</PopoverTrigger>
- <PopoverContent className="w-80">
+ <PopoverContent className="w-96">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">DOLCE 가져오기 상태</h4>
@@ -215,33 +292,61 @@ export function ImportFromDOLCEButton({
</div>
</div>
- {importStatus && (
+ {/* 다중 계약 정보 표시 */}
+ {contractIds.length > 1 && (
+ <div className="text-sm">
+ <div className="text-muted-foreground">대상 계약</div>
+ <div className="font-medium">{contractIds.length}개 계약</div>
+ <div className="text-xs text-muted-foreground">
+ Contract IDs: {contractIds.join(', ')}
+ </div>
+ </div>
+ )}
+
+ {totalStats && (
<div className="space-y-3">
<Separator />
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-muted-foreground">신규 문서</div>
- <div className="font-medium">{importStatus.newDocuments || 0}건</div>
+ <div className="font-medium">{totalStats.newDocuments || 0}건</div>
</div>
<div>
<div className="text-muted-foreground">업데이트</div>
- <div className="font-medium">{importStatus.updatedDocuments || 0}건</div>
+ <div className="font-medium">{totalStats.updatedDocuments || 0}건</div>
</div>
</div>
<div className="text-sm">
<div className="text-muted-foreground">DOLCE 전체 문서 (B3/B4/B5)</div>
- <div className="font-medium">{importStatus.availableDocuments || 0}건</div>
+ <div className="font-medium">{totalStats.availableDocuments || 0}건</div>
</div>
- {importStatus.lastImportAt && (
- <div className="text-sm">
- <div className="text-muted-foreground">마지막 가져오기</div>
- <div className="font-medium">
- {new Date(importStatus.lastImportAt).toLocaleString()}
+ {/* 각 계약별 세부 정보 (펼치기/접기 가능) */}
+ {contractIds.length > 1 && (
+ <details className="text-sm">
+ <summary className="cursor-pointer text-muted-foreground hover:text-foreground">
+ 계약별 세부 정보
+ </summary>
+ <div className="mt-2 space-y-2 pl-2 border-l-2 border-muted">
+ {contractIds.map(contractId => {
+ const status = importStatusMap.get(contractId)
+ return (
+ <div key={contractId} className="text-xs">
+ <div className="font-medium">Contract {contractId}</div>
+ {status ? (
+ <div className="text-muted-foreground">
+ 신규 {status.newDocuments}건, 업데이트 {status.updatedDocuments}건
+ </div>
+ ) : (
+ <div className="text-destructive">상태 확인 실패</div>
+ )}
+ </div>
+ )
+ })}
</div>
- </div>
+ </details>
)}
</div>
)}
@@ -271,7 +376,7 @@ export function ImportFromDOLCEButton({
<Button
variant="outline"
size="sm"
- onClick={fetchImportStatus}
+ onClick={fetchAllImportStatus}
disabled={statusLoading}
>
{statusLoading ? (
@@ -292,16 +397,17 @@ export function ImportFromDOLCEButton({
<DialogTitle>DOLCE에서 문서 목록 가져오기</DialogTitle>
<DialogDescription>
삼성중공업 DOLCE 시스템에서 최신 문서 목록을 가져옵니다.
+ {contractIds.length > 1 && ` (${contractIds.length}개 계약 대상)`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
- {importStatus && (
+ {totalStats && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>가져올 항목</span>
<span className="font-medium">
- {(importStatus.newDocuments || 0) + (importStatus.updatedDocuments || 0)}건
+ {totalStats.newDocuments + totalStats.updatedDocuments}건
</span>
</div>
@@ -309,6 +415,12 @@ export function ImportFromDOLCEButton({
신규 문서와 업데이트된 문서가 포함됩니다. (B3, B4, B5)
<br />
B4 문서의 경우 GTTPreDwg, GTTWorkingDwg 이슈 스테이지가 자동 생성됩니다.
+ {contractIds.length > 1 && (
+ <>
+ <br />
+ {contractIds.length}개 계약에서 순차적으로 가져옵니다.
+ </>
+ )}
</div>
{isImporting && (
diff --git a/lib/vendor-document-list/ship/revision-upload-dialog.tsx b/lib/vendor-document-list/ship/revision-upload-dialog.tsx
deleted file mode 100644
index 16fc9fbb..00000000
--- a/lib/vendor-document-list/ship/revision-upload-dialog.tsx
+++ /dev/null
@@ -1,629 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
-import { mutate } from "swr" // ✅ SWR mutate import 추가
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
-import {
- FileList,
- FileListAction,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import { Upload, X, Loader2 } from "lucide-react"
-import prettyBytes from "pretty-bytes"
-import { EnhancedDocumentsView } from "@/db/schema/vendorDocu"
-
-// 리비전 업로드 스키마
-const revisionUploadSchema = z.object({
- stage: z.string().min(1, "스테이지는 필수입니다"),
- revision: z.string().min(1, "리비전은 필수입니다"),
- uploaderName: z.string().optional(),
- comment: z.string().optional(),
- attachments: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"),
- // ✅ B3 문서용 usage 필드 추가
- usage: z.string().optional(),
-}).refine((data) => {
- // B3 문서이고 특정 stage인 경우 usage 필수
- // 이 검증은 컴포넌트 내에서 조건부로 처리
- return true;
-}, {
- message: "Usage는 필수입니다",
- path: ["usage"],
-});
-
-const getUsageOptions = (stageName: string): string[] => {
- const stageNameLower = stageName.toLowerCase();
-
- if (stageNameLower.includes('approval')) {
- return ['Approval (Partial)', 'Approval (Full)'];
- } else if (stageNameLower.includes('working')) {
- return ['Working (Partial)', 'Working (Full)'];
- }
-
- return [];
-};
-
-
-type RevisionUploadSchema = z.infer<typeof revisionUploadSchema>
-
-interface RevisionUploadDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- document: EnhancedDocumentsView | null
- projectType: "ship" | "plant"
- presetStage?: string
- presetRevision?: string
- mode?: 'new' | 'append'
- onUploadComplete?: () => void // ✅ 업로드 완료 콜백 추가
-}
-
-function getTargetSystem(projectType: "ship" | "plant") {
- return projectType === "ship" ? "DOLCE" : "SWP"
-}
-
-export function RevisionUploadDialog({
- open,
- onOpenChange,
- document,
- projectType,
- presetStage,
- presetRevision,
- mode = 'new',
- onUploadComplete,
-}: RevisionUploadDialogProps) {
-
- const targetSystem = React.useMemo(
- () => getTargetSystem(projectType),
- [projectType]
- )
-
- const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
- const [isUploading, setIsUploading] = React.useState(false)
- const [uploadProgress, setUploadProgress] = React.useState(0)
- const router = useRouter()
-
- const { data: session } = useSession()
-
- // 사용 가능한 스테이지 옵션
- const stageOptions = React.useMemo(() => {
- if (document?.allStages) {
- return document.allStages.map(stage => stage.stageName)
- }
- return ["Issued for Review", "AFC", "Final Issue"]
- }, [document])
-
- const form = useForm<RevisionUploadSchema>({
- resolver: zodResolver(revisionUploadSchema),
- defaultValues: {
- stage: presetStage || document?.currentStageName || "",
- revision: presetRevision || "",
- uploaderName: session?.user?.name || "",
- comment: "",
- attachments: [],
- usage: "", // ✅ usage 기본값 추가
- },
- })
-
- // ✅ 현재 선택된 stage 값을 watch
- const currentStage = form.watch('stage')
-
- // ✅ B3 문서 여부 확인
- const isB3Document = document?.drawingKind === 'B3'
-
- // ✅ 현재 stage에 따른 usage 옵션
- const usageOptions = React.useMemo(() => {
- if (!isB3Document || !currentStage) return []
- return getUsageOptions(currentStage)
- }, [isB3Document, currentStage])
-
- // ✅ usage 필드가 필요한지 확인
- const isUsageRequired = isB3Document && usageOptions.length > 0
-
- // session이 로드되면 uploaderName 업데이트
- React.useEffect(() => {
- if (session?.user?.name) {
- form.setValue('uploaderName', session.user.name)
- }
- }, [session?.user?.name, form])
-
- // presetStage와 presetRevision이 변경될 때 폼 값 업데이트
- React.useEffect(() => {
- if (presetStage) {
- form.setValue('stage', presetStage)
- }
- if (presetRevision) {
- form.setValue('revision', presetRevision)
- }
- }, [presetStage, presetRevision, form])
-
- // ✅ stage가 변경될 때 usage 값 리셋
- React.useEffect(() => {
- if (isB3Document) {
- const newUsageOptions = getUsageOptions(currentStage)
- if (newUsageOptions.length === 0) {
- form.setValue('usage', '')
- } else {
- // 기존 값이 새로운 옵션에 없으면 리셋
- const currentUsage = form.getValues('usage')
- if (currentUsage && !newUsageOptions.includes(currentUsage)) {
- form.setValue('usage', '')
- }
- }
- }
- }, [currentStage, isB3Document, form])
-
- // 파일 드롭 처리
- const handleDropAccepted = (acceptedFiles: File[]) => {
- const newFiles = [...selectedFiles, ...acceptedFiles]
- setSelectedFiles(newFiles)
- form.setValue('attachments', newFiles, { shouldValidate: true })
- }
-
- const removeFile = (index: number) => {
- const updatedFiles = [...selectedFiles]
- updatedFiles.splice(index, 1)
- setSelectedFiles(updatedFiles)
- form.setValue('attachments', updatedFiles, { shouldValidate: true })
- }
-
- // 캐시 갱신 함수
- const refreshCaches = async () => {
- try {
- router.refresh()
-
- if (document?.contractId) {
- await mutate(`/api/sync/status/${document.contractId}/${targetSystem}`)
- console.log('✅ Sync status cache refreshed')
- }
-
- await mutate(key =>
- typeof key === 'string' &&
- key.includes('sync') &&
- key.includes(String(document?.contractId))
- )
-
- onUploadComplete?.()
-
- console.log('✅ All caches refreshed after upload')
- } catch (error) {
- console.error('❌ Cache refresh failed:', error)
- }
- }
-
- // ✅ 업로드 처리 - usage 필드 검증 및 전송
- async function onSubmit(data: RevisionUploadSchema) {
- if (!document) return
-
- // ✅ B3 문서에서 usage가 필요한 경우 검증
- if (isUsageRequired && !data.usage) {
- form.setError('usage', {
- type: 'required',
- message: 'Usage 선택은 필수입니다'
- })
- return
- }
-
- setIsUploading(true)
- setUploadProgress(0)
-
- try {
- const formData = new FormData()
- formData.append("documentId", String(document.documentId))
- formData.append("stage", data.stage)
- formData.append("revision", data.revision)
- formData.append("mode", mode)
- formData.append("targetSystem", targetSystem)
-
- if (data.uploaderName) {
- formData.append("uploaderName", data.uploaderName)
- }
-
- if (data.comment) {
- formData.append("comment", data.comment)
- }
-
- // ✅ B3 문서인 경우 usage 추가
- if (isB3Document && data.usage) {
- formData.append("usage", data.usage)
- }
-
- // 파일들 추가
- data.attachments.forEach((file) => {
- formData.append("attachments", file)
- })
-
- // 진행률 업데이트 시뮬레이션
- const updateProgress = (progress: number) => {
- setUploadProgress(Math.min(progress, 95))
- }
-
- const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0)
- let uploadedSize = 0
-
- const progressInterval = setInterval(() => {
- uploadedSize += totalSize * 0.1
- const progress = Math.min((uploadedSize / totalSize) * 100, 90)
- updateProgress(progress)
- }, 300)
-
- const response = await fetch('/api/revision-upload', {
- method: 'POST',
- body: formData,
- })
-
- clearInterval(progressInterval)
-
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.')
- }
-
- const result = await response.json()
- setUploadProgress(100)
-
- toast.success(
- result.message ||
- `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)`
- )
-
- console.log('✅ 업로드 성공:', result)
-
- setTimeout(async () => {
- await refreshCaches()
- handleDialogClose()
- }, 1000)
-
- } catch (error) {
- console.error('❌ 업로드 오류:', error)
- toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다")
- } finally {
- setIsUploading(false)
- setTimeout(() => setUploadProgress(0), 2000)
- }
- }
-
- const handleDialogClose = () => {
- form.reset({
- stage: presetStage || document?.currentStageName || "",
- revision: presetRevision || "",
- uploaderName: session?.user?.name || "",
- comment: "",
- attachments: [],
- usage: "", // ✅ usage 리셋 추가
- })
- setSelectedFiles([])
- setIsUploading(false)
- setUploadProgress(0)
- onOpenChange(false)
- }
-
- return (
- <Dialog open={open} onOpenChange={handleDialogClose}>
- <DialogContent className="sm:max-w-md">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Upload className="w-5 h-5" />
- {mode === 'new' ? '새 리비전 업로드' : '파일 추가'}
- </DialogTitle>
- <DialogDescription>
- {document ? `${document.docNumber} - ${document.title}` :
- mode === 'new' ? "문서에 새 리비전을 업로드합니다." : "기존 리비전에 파일을 추가합니다."}
- </DialogDescription>
-
- <div className="flex items-center gap-2 pt-2 flex-wrap">
- <Badge variant={projectType === "ship" ? "default" : "secondary"}>
- {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"}
- </Badge>
- <Badge variant="outline" className="text-xs">
- → {targetSystem}
- </Badge>
- {/* ✅ B3 문서 표시 */}
- {isB3Document && (
- <Badge variant="outline" className="text-xs bg-orange-50 text-orange-700 border-orange-200">
- B3 문서
- </Badge>
- )}
- {session?.user?.name && (
- <Badge variant="outline" className="text-xs">
- 업로더: {session.user.name}
- </Badge>
- )}
- {mode === 'append' && presetRevision && (
- <Badge variant="outline" className="text-xs">
- 리비전 {presetRevision}에 파일 추가
- </Badge>
- )}
- {mode === 'new' && presetRevision && (
- <Badge variant="outline" className="text-xs">
- 다음 리비전: {presetRevision}
- </Badge>
- )}
- </div>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="stage"
- render={({ field }) => (
- <FormItem>
- <FormLabel>스테이지</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="스테이지 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {stageOptions.map((stage) => (
- <SelectItem key={stage} value={stage}>
- {stage}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="revision"
- render={({ field }) => (
- <FormItem>
- <FormLabel>리비전</FormLabel>
- <FormControl>
- <Input
- {...field}
- placeholder="예: A, B, 1, 2..."
- readOnly={mode === 'append'}
- className={mode === 'append' ? 'bg-gray-50' : ''}
- />
- </FormControl>
- <FormMessage />
- {mode === 'new' && presetRevision && (
- <p className="text-xs text-gray-500">
- 자동으로 계산된 다음 리비전입니다.
- </p>
- )}
- {mode === 'append' && (
- <p className="text-xs text-gray-500">
- 기존 리비전에 파일을 추가합니다.
- </p>
- )}
- </FormItem>
- )}
- />
- </div>
-
- {/* ✅ B3 문서용 Usage 필드 - 조건부 표시 */}
- {isB3Document && usageOptions.length > 0 && (
- <FormField
- control={form.control}
- name="usage"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center gap-2">
- 용도
- {isUsageRequired && <span className="text-red-500">*</span>}
- </FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="용도를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {usageOptions.map((usage) => (
- <SelectItem key={usage} value={usage}>
- {usage}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- <p className="text-xs text-gray-500">
- {currentStage} 스테이지에 필요한 용도를 선택하세요.
- </p>
- </FormItem>
- )}
- />
- )}
-
- <FormField
- control={form.control}
- name="uploaderName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>업로더명</FormLabel>
- <FormControl>
- <Input
- {...field}
- placeholder="업로더 이름을 입력하세요"
- className="bg-gray-50"
- />
- </FormControl>
- <FormMessage />
- <p className="text-xs text-gray-500">
- 로그인된 사용자 정보가 자동으로 입력됩니다.
- </p>
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="comment"
- render={({ field }) => (
- <FormItem>
- <FormLabel>코멘트 (선택)</FormLabel>
- <FormControl>
- <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 파일 업로드 영역 */}
- <FormField
- control={form.control}
- name="attachments"
- render={() => (
- <FormItem>
- <FormLabel>파일 첨부</FormLabel>
- <Dropzone
- maxSize={3e9} // 3GB
- multiple={true}
- onDropAccepted={handleDropAccepted}
- disabled={isUploading}
- >
- <DropzoneZone className="flex justify-center">
- <FormControl>
- <DropzoneInput />
- </FormControl>
- <div className="flex items-center gap-6">
- <DropzoneUploadIcon />
- <div className="grid gap-0.5">
- <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
- <DropzoneDescription>
- 또는 클릭하여 파일을 선택하세요
- </DropzoneDescription>
- </div>
- </div>
- </DropzoneZone>
- </Dropzone>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 선택된 파일 목록 */}
- {selectedFiles.length > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h6 className="text-sm font-semibold">
- 선택된 파일 ({selectedFiles.length})
- </h6>
- </div>
- <ScrollArea className="max-h-[200px]">
- <FileList>
- {selectedFiles.map((file, index) => (
- <FileListItem key={index} className="p-3">
- <FileListHeader>
- <FileListIcon />
- <FileListInfo>
- <FileListName>{file.name}</FileListName>
- <FileListSize>{file.size}</FileListSize>
- </FileListInfo>
- <FileListAction
- onClick={() => removeFile(index)}
- disabled={isUploading}
- >
- <X className="h-4 w-4" />
- </FileListAction>
- </FileListHeader>
- </FileListItem>
- ))}
- </FileList>
- </ScrollArea>
- </div>
- )}
-
- {/* 업로드 진행 상태 */}
- {isUploading && (
- <div className="space-y-2">
- <div className="flex items-center gap-2">
- <Loader2 className="h-4 w-4 animate-spin" />
- <span className="text-sm">{uploadProgress}% 업로드 중...</span>
- </div>
- <div className="h-2 w-full bg-muted rounded-full overflow-hidden">
- <div
- className="h-full bg-primary rounded-full transition-all"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={handleDialogClose}
- disabled={isUploading}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isUploading || selectedFiles.length === 0}
- >
- {isUploading ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 업로드 중...
- </>
- ) : (
- <>
- <Upload className="mr-2 h-4 w-4" />
- 업로드
- </>
- )}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx b/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx
deleted file mode 100644
index 933df263..00000000
--- a/lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { toast } from "sonner"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Calendar as CalendarComponent } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { Calendar, Edit, Loader2 } from "lucide-react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-import { cn } from "@/lib/utils"
-import { EnhancedDocumentsView } from "@/db/schema/vendorDocu"
-
-// 단순화된 문서 편집 스키마
-const documentEditSchema = z.object({
- docNumber: z.string().min(1, "문서번호는 필수입니다"),
- title: z.string().min(1, "제목은 필수입니다"),
- pic: z.string().optional(),
- status: z.string().min(1, "상태는 필수입니다"),
- issuedDate: z.date().optional(),
- description: z.string().optional(),
-})
-
-type DocumentEditSchema = z.infer<typeof documentEditSchema>
-
-const statusOptions = [
- { value: "ACTIVE", label: "활성" },
- { value: "INACTIVE", label: "비활성" },
- { value: "COMPLETED", label: "완료" },
- { value: "CANCELLED", label: "취소" },
-]
-
-interface SimplifiedDocumentEditDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- document: EnhancedDocumentsView | null
- projectType: "ship" | "plant"
-}
-
-export function SimplifiedDocumentEditDialog({
- open,
- onOpenChange,
- document,
- projectType,
-}: SimplifiedDocumentEditDialogProps) {
- const [isUpdating, setIsUpdating] = React.useState(false)
-
- const form = useForm<DocumentEditSchema>({
- resolver: zodResolver(documentEditSchema),
- defaultValues: {
- docNumber: "",
- title: "",
- pic: "",
- status: "ACTIVE",
- issuedDate: undefined,
- description: "",
- },
- })
-
- // 폼 초기화
- React.useEffect(() => {
- if (document) {
- form.reset({
- docNumber: document.docNumber,
- title: document.title,
- pic: document.pic || "",
- status: document.status,
- issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined,
- description: "",
- })
- }
- }, [document, form])
-
- async function onSubmit(data: DocumentEditSchema) {
- if (!document) return
-
- setIsUpdating(true)
- try {
- // 실제 업데이트 API 호출 (구현 필요)
- // await updateDocumentInfo({ documentId: document.documentId, ...data })
-
- toast.success("문서 정보가 업데이트되었습니다")
- onOpenChange(false)
- } catch (error) {
- toast.error("업데이트 중 오류가 발생했습니다")
- console.error(error)
- } finally {
- setIsUpdating(false)
- }
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-md">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Edit className="w-5 h-5" />
- 문서 정보 수정
- </DialogTitle>
- <DialogDescription>
- {document ? `${document.docNumber}의 기본 정보를 수정합니다.` : "문서 기본 정보를 수정합니다."}
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
- <FormField
- control={form.control}
- name="docNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel>문서번호</FormLabel>
- <FormControl>
- <Input {...field} disabled={projectType === "ship"} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>제목</FormLabel>
- <FormControl>
- <Input {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="grid grid-cols-2 gap-4">
- <FormField
- control={form.control}
- name="pic"
- render={({ field }) => (
- <FormItem>
- <FormLabel>담당자 (PIC)</FormLabel>
- <FormControl>
- <Input {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>상태</FormLabel>
- <Select onValueChange={field.onChange} value={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {statusOptions.map((option) => (
- <SelectItem key={option.value} value={option.value}>
- {option.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- <FormField
- control={form.control}
- name="issuedDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>발행일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant="outline"
- className={cn(
- "w-full pl-3 text-left font-normal",
- !field.value && "text-muted-foreground"
- )}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일", { locale: ko })
- ) : (
- <span>날짜를 선택하세요</span>
- )}
- <Calendar className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <CalendarComponent
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) => date > new Date()}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>설명 (선택)</FormLabel>
- <FormControl>
- <Textarea {...field} placeholder="문서에 대한 설명을 입력하세요" rows={3} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isUpdating}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isUpdating}>
- {isUpdating ? (
- <>
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
- 저장 중...
- </>
- ) : (
- <>
- <Edit className="mr-2 h-4 w-4" />
- 저장
- </>
- )}
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx b/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx
deleted file mode 100644
index 6b9cffb9..00000000
--- a/lib/vendor-document-list/ship/stage-revision-expanded-content.tsx
+++ /dev/null
@@ -1,752 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { WebViewerInstance } from "@pdftron/webviewer"
-import { formatDate } from "@/lib/utils"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import {
- FileText,
- User,
- Calendar,
- Clock,
- CheckCircle,
- AlertTriangle,
- ChevronDown,
- ChevronRight,
- Upload,
- Eye,
- Download,
- FileIcon,
- MoreHorizontal,
- Loader2
-} from "lucide-react"
-import { cn } from "@/lib/utils"
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
-import type { EnhancedDocument } from "@/types/enhanced-documents"
-
-// 유틸리티 함수들
-const getStatusColor = (status: string) => {
- switch (status) {
- case 'COMPLETED': case 'APPROVED': return 'bg-green-100 text-green-800'
- case 'IN_PROGRESS': return 'bg-blue-100 text-blue-800'
- case 'SUBMITTED': case 'UNDER_REVIEW': return 'bg-purple-100 text-purple-800'
- case 'REJECTED': return 'bg-red-100 text-red-800'
- default: return 'bg-gray-100 text-gray-800'
- }
-}
-
-const getPriorityColor = (priority: string) => {
- switch (priority) {
- case 'HIGH': return 'bg-red-100 text-red-800 border-red-200'
- case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border-yellow-200'
- case 'LOW': return 'bg-green-100 text-green-800 border-green-200'
- default: return 'bg-gray-100 text-gray-800 border-gray-200'
- }
-}
-
-const getStatusText = (status: string) => {
- switch (status) {
- case 'PLANNED': return '계획됨'
- case 'IN_PROGRESS': return '진행중'
- case 'SUBMITTED': return '제출됨'
- case 'UPLOADED': return '등록됨'
- case 'UNDER_REVIEW': return '검토중'
- case 'APPROVED': return '승인됨'
- case 'REJECTED': return '반려됨'
- case 'COMPLETED': return '완료됨'
- default: return status
- }
-}
-
-const getPriorityText = (priority: string) => {
- switch (priority) {
- case 'HIGH': return '높음'
- case 'MEDIUM': return '보통'
- case 'LOW': return '낮음'
- default: return priority
- }
-}
-
-const getFileIconColor = (fileName: string) => {
- const ext = fileName.split('.').pop()?.toLowerCase()
- switch (ext) {
- case 'pdf': return 'text-red-500'
- case 'doc': case 'docx': return 'text-blue-500'
- case 'xls': case 'xlsx': return 'text-green-500'
- case 'dwg': return 'text-amber-500'
- default: return 'text-gray-500'
- }
-}
-
-interface StageRevisionExpandedContentProps {
- document: EnhancedDocument
- onUploadRevision: (documentData: EnhancedDocument, stageName?: string, currentRevision?: string, mode?: 'new' | 'append') => void
- onStageStatusUpdate?: (stageId: number, status: string) => void
- onRevisionStatusUpdate?: (revisionId: number, status: string) => void
- projectType: "ship" | "plant"
- expandedStages?: Record<number, boolean>
- onStageToggle?: (stageId: number) => void
-}
-
-export const StageRevisionExpandedContent = ({
- document: documentData,
- onUploadRevision,
- onStageStatusUpdate,
- onRevisionStatusUpdate,
- projectType,
- expandedStages = {},
- onStageToggle,
-}: StageRevisionExpandedContentProps) => {
- // 로컬 상태 관리
- const [localExpandedStages, setLocalExpandedStages] = React.useState<Record<number, boolean>>({})
- const [expandedRevisions, setExpandedRevisions] = React.useState<Set<number>>(new Set())
-
- // ✅ 문서 뷰어 상태 관리
- const [viewerOpen, setViewerOpen] = React.useState(false)
- const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([])
- const [instance, setInstance] = React.useState<WebViewerInstance | null>(null)
- const [viewerLoading, setViewerLoading] = React.useState(true)
- const [fileSetLoading, setFileSetLoading] = React.useState(true)
- const viewer = React.useRef<HTMLDivElement>(null)
- const initialized = React.useRef(false)
- const isCancelled = React.useRef(false)
-
- // 상위에서 관리하는지 로컬에서 관리하는지 결정
- const isExternallyManaged = onStageToggle !== undefined
- const currentExpandedStages = isExternallyManaged ? expandedStages : localExpandedStages
-
- const handleStageToggle = React.useCallback((stageId: number) => {
- if (isExternallyManaged && onStageToggle) {
- onStageToggle(stageId)
- } else {
- setLocalExpandedStages(prev => ({
- ...prev,
- [stageId]: !prev[stageId]
- }))
- }
- }, [isExternallyManaged, onStageToggle])
-
- const toggleRevisionFiles = React.useCallback((revisionId: number) => {
- setExpandedRevisions(prev => {
- const newSet = new Set(prev)
- if (newSet.has(revisionId)) {
- newSet.delete(revisionId)
- } else {
- newSet.add(revisionId)
- }
- return newSet
- })
- }, [])
-
- // ✅ PDF 뷰어 정리 함수
- const cleanupHtmlStyle = React.useCallback(() => {
- const htmlElement = window.document.documentElement
- const originalStyle = htmlElement.getAttribute("style") || ""
- const colorSchemeStyle = originalStyle
- .split(";")
- .map((s) => s.trim())
- .find((s) => s.startsWith("color-scheme:"))
-
- if (colorSchemeStyle) {
- htmlElement.setAttribute("style", colorSchemeStyle + ";")
- } else {
- htmlElement.removeAttribute("style")
- }
- }, [])
-
- // ✅ 문서 뷰어 열기 함수
- const handleViewRevision = React.useCallback((revisions: any[]) => {
- setSelectedRevisions(revisions)
- setViewerOpen(true)
- setViewerLoading(true)
- setFileSetLoading(true)
- initialized.current = false
- }, [])
-
- // ✅ 파일 다운로드 함수 - 새로운 document-download API 사용
- const handleDownloadFile = React.useCallback(async (attachment: any) => {
- console.log(attachment)
- try {
- // ID를 우선으로 사용, 없으면 filePath 사용
- const queryParam = attachment.id
- ? `id=${encodeURIComponent(attachment.id)}`
- : `path=${encodeURIComponent(attachment.filePath)}`
-
- const response = await fetch(`/api/document-download?${queryParam}`)
-
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(errorData.error || '파일 다운로드에 실패했습니다.')
- }
-
- const blob = await response.blob()
- const url = window.URL.createObjectURL(blob)
- const link = window.document.createElement('a')
- link.href = url
- link.download = attachment.fileName
- window.document.body.appendChild(link)
- link.click()
- window.document.body.removeChild(link)
- window.URL.revokeObjectURL(url)
-
- console.log('✅ 파일 다운로드 완료:', attachment.fileName)
- } catch (error) {
- console.error('❌ 파일 다운로드 오류:', error)
- // 실제 앱에서는 toast나 alert로 에러 표시
- alert(`파일 다운로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
- }
- }, [])
-
- // ✅ WebViewer 초기화
- React.useEffect(() => {
- if (viewerOpen && !initialized.current) {
- initialized.current = true
- isCancelled.current = false
-
- requestAnimationFrame(() => {
- if (viewer.current && !isCancelled.current) {
- import("@pdftron/webviewer").then(({ default: WebViewer }) => {
- if (isCancelled.current) {
- console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)")
- return
- }
-
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd",
- fullAPI: true,
- css: "/globals.css",
- },
- viewer.current as HTMLDivElement
- ).then(async (instance: WebViewerInstance) => {
- if (!isCancelled.current) {
- setInstance(instance)
- instance.UI.enableFeatures([instance.UI.Feature.MultiTab])
- instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"])
- setViewerLoading(false)
- }
- })
- })
- }
- })
- }
-
- return () => {
- if (instance) {
- instance.UI.dispose()
- }
- setTimeout(() => cleanupHtmlStyle(), 500)
- }
- }, [viewerOpen, cleanupHtmlStyle])
-
- // ✅ 문서 로드
- React.useEffect(() => {
- const loadDocument = async () => {
- if (instance && selectedRevisions.length > 0) {
- const { UI } = instance
- const optionsArray: any[] = []
-
- selectedRevisions.forEach((revision) => {
- const { attachments } = revision
- attachments?.forEach((attachment: any) => {
- const { fileName, filePath, fileType } = attachment
- const fileTypeCur = fileType ?? ""
-
- const options = {
- filename: fileName,
- ...(fileTypeCur.includes("xlsx") && {
- officeOptions: {
- formatOptions: {
- applyPageBreaksToSheet: true,
- },
- },
- }),
- }
-
- optionsArray.push({ filePath, options })
- })
- })
-
- const tabIds = []
- for (const option of optionsArray) {
- const { filePath, options } = option
- try {
- const response = await fetch(filePath)
- const blob = await response.blob()
- const tab = await UI.TabManager.addTab(blob, options)
- tabIds.push(tab)
- } catch (error) {
- console.error("파일 로드 실패:", filePath, error)
- }
- }
-
- if (tabIds.length > 0) {
- await UI.TabManager.setActiveTab(tabIds[0])
- }
-
- setFileSetLoading(false)
- }
- }
- loadDocument()
- }, [instance, selectedRevisions])
-
- // ✅ 뷰어 닫기
- const handleCloseViewer = React.useCallback(async () => {
- if (!fileSetLoading) {
- isCancelled.current = true
-
- if (instance) {
- try {
- await instance.UI.dispose()
- setInstance(null)
- } catch (e) {
- console.warn("dispose error", e)
- }
- }
-
- setViewerLoading(false)
- setViewerOpen(false)
- setTimeout(() => cleanupHtmlStyle(), 1000)
- }
- }, [fileSetLoading, instance, cleanupHtmlStyle])
-
- // 뷰에서 가져온 allStages 데이터를 바로 사용
- const stagesWithRevisions = documentData.allStages || []
-
- console.log(stagesWithRevisions)
-
- if (stagesWithRevisions.length === 0) {
- return (
- <div className="p-6 text-center text-gray-500">
- <FileText className="w-12 h-12 mx-auto mb-4 text-gray-300" />
- <h4 className="font-medium mb-2">스테이지 정보가 없습니다</h4>
- <p className="text-sm">이 문서에 대한 스테이지를 먼저 설정해주세요.</p>
- </div>
- )
- }
-
- return (
- <>
- <div className="w-full max-w-none bg-gray-50" onClick={(e) => e.stopPropagation()}>
- <div className="p-4">
- <div className="flex items-center justify-between mb-4">
- <div>
- <h4 className="font-semibold flex items-center gap-2">
- <FileText className="w-4 h-4" />
- 스테이지별 리비전 현황
- </h4>
- <p className="text-xs text-gray-600 mt-1">
- 총 {stagesWithRevisions.length}개 스테이지, {stagesWithRevisions.reduce((acc, stage) => acc + (stage.revisions?.length || 0), 0)}개 리비전
- </p>
- </div>
- {/* <Button
- size="sm"
- onClick={() => onUploadRevision(document, undefined, undefined, 'new')}
- className="flex items-center gap-2"
- >
- <Upload className="w-3 h-3" />
- 새 리비전 업로드
- </Button> */}
- </div>
-
- <ScrollArea className="h-[400px] w-full">
- <div className="space-y-3 pr-4">
- {stagesWithRevisions.map((stage) => {
- const isExpanded = currentExpandedStages[stage.id] || false
- const revisions = stage.revisions || []
-
- return (
- <div key={stage.id} className="bg-white rounded border shadow-sm overflow-hidden">
- {/* 스테이지 헤더 - 전체 영역 클릭 가능 */}
- <div
- className="py-2 px-3 bg-gray-50 border-b cursor-pointer hover:bg-gray-100 transition-colors"
- onClick={(e) => {
- e.preventDefault()
- e.stopPropagation()
- handleStageToggle(stage.id)
- }}
- >
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-3">
- {/* 버튼 영역 - 이제 시각적 표시만 담당 */}
- <div className="flex items-center gap-2">
- <div className="flex items-center gap-2">
- <div className="w-6 h-6 rounded-full bg-white border-2 border-gray-300 flex items-center justify-center text-xs font-medium">
- {stage.stageOrder || 1}
- </div>
- <div className={cn(
- "w-2 h-2 rounded-full",
- stage.stageStatus === 'COMPLETED' ? 'bg-green-500' :
- stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' :
- stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' :
- 'bg-gray-300'
- )} />
- {isExpanded ?
- <ChevronDown className="w-3 h-3 text-gray-500" /> :
- <ChevronRight className="w-3 h-3 text-gray-500" />
- }
- </div>
- </div>
-
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <div className="font-medium text-sm">{stage.stageName}</div>
- <Badge className={cn("text-xs", getStatusColor(stage.stageStatus))}>
- {getStatusText(stage.stageStatus)}
- </Badge>
- <span className="text-xs text-gray-500">
- {revisions.length}개 리비전
- </span>
- </div>
- </div>
- </div>
-
- <div className="flex items-center gap-4">
- <div className="grid grid-cols-2 gap-2 text-xs">
- <div>
- <span className="text-gray-500">계획: </span>
- <span className="font-medium">{stage.planDate ? formatDate(stage.planDate) : '-'}</span>
- </div>
- {stage.actualDate && (
- <div>
- <span className="text-gray-500">완료: </span>
- <span className="font-medium">{formatDate(stage.actualDate)}</span>
- </div>
- )}
- {stage.assigneeName && (
- <div className="col-span-2 flex items-center gap-1 text-gray-600">
- <User className="w-3 h-3" />
- <span className="text-xs">{stage.assigneeName}</span>
- </div>
- )}
- </div>
-
- {/* 스테이지 액션 메뉴 - 클릭 이벤트 전파 차단 */}
- <div
- onClick={(e) => {
- e.stopPropagation() // 액션 메뉴 클릭 시 스테이지 토글 방지
- }}
- >
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button
- variant="ghost"
- size="sm"
- className="h-7 w-7 p-0"
- >
- <MoreHorizontal className="h-3 w-3" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {onStageStatusUpdate && (
- <>
- <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'IN_PROGRESS')}>
- 진행 시작
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'COMPLETED')}>
- 완료 처리
- </DropdownMenuItem>
- </>
- )}
- <DropdownMenuItem onClick={() => onUploadRevision(documentData, stage.stageName)}>
- 리비전 업로드
- </DropdownMenuItem>
- {/* ✅ 스테이지에 첨부파일이 있는 리비전이 있을 때만 문서 보기 버튼 표시 */}
- {revisions.some(rev => rev.attachments && rev.attachments.length > 0) && (
- <DropdownMenuItem onClick={() => handleViewRevision(revisions.filter(rev => rev.attachments && rev.attachments.length > 0))}>
- <Eye className="w-3 h-3 mr-1" />
- 스테이지 문서 보기
- </DropdownMenuItem>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- </div>
- </div>
-
- {/* 리비전 목록 - 테이블 형태 */}
- {isExpanded && (
- <div className="max-h-72 overflow-y-auto">
- {revisions.length > 0 ? (
- <div className="border-t">
- <Table>
- <TableHeader>
- <TableRow className="bg-gray-50/50 h-8">
- <TableHead className="w-16 py-1 px-2 text-xs"></TableHead>
- <TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead>
- <TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead>
- {documentData.drawingKind === 'B3' && (
- <TableHead className="w-24 py-1 px-2 text-xs">용도</TableHead>
- )}
- <TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead>
- <TableHead className="w-32 py-1 px-2 text-xs">등록일</TableHead>
- <TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead>
- <TableHead className="w-32 py-1 px-2 text-xs">승인/반려일</TableHead>
- <TableHead className="min-w-[120px] py-1 px-2 text-xs">첨부파일</TableHead>
- <TableHead className="w-16 py-1 px-2 text-xs">액션</TableHead>
- <TableHead className="min-w-0 py-1 px-2 text-xs">코멘트</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {revisions.map((revision) => {
- const hasAttachments = revision.attachments && revision.attachments.length > 0
-
- return (
- <TableRow key={revision.id} className="hover:bg-gray-50 h-10">
- {/* 리비전 */}
- <TableCell className="py-1 px-2">
- <span className="text-xs font-semibold">
- {revision.uploaderType === "vendor" ? "To SHI" : "From SHI"}
- </span>
- </TableCell>
-
- <TableCell className="py-1 px-2">
- <span className="font-mono text-xs font-semibold bg-gray-100 px-1.5 py-0.5 rounded">
- {revision.revision}
- </span>
- </TableCell>
-
- {/* 상태 */}
- <TableCell className="py-1 px-2">
- <Badge className={cn("text-xs px-1.5 py-0.5", getStatusColor(revision.revisionStatus))}>
- {getStatusText(revision.revisionStatus)}
- </Badge>
- </TableCell>
-
- {/* ✅ B3 문서일 때만 Usage 셀 표시 */}
- {documentData.drawingKind === 'B3' && (
- <TableCell className="py-1 px-2">
- {revision.usage ? (
- <span className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded border border-blue-200">
- {revision.usage}
- </span>
- ) : (
- <span className="text-gray-400 text-xs">-</span>
- )}
- </TableCell>
- )}
-
- {/* 업로더 */}
- <TableCell className="py-1 px-2">
- <div className="flex items-center gap-1">
- <User className="w-3 h-3 text-gray-400" />
- <span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span>
- </div>
- </TableCell>
- {/* 제출일 */}
- <TableCell className="py-1 px-2">
- <span className="text-xs text-gray-600">
- {revision.uploadedAt ? formatDate(revision.uploadedAt) : '-'}
- </span>
- </TableCell>
-
- {/* 제출일 */}
- <TableCell className="py-1 px-2">
- <span className="text-xs text-gray-600">
- {revision.externalSentDate ? formatDate(revision.externalSentDate) : '-'}
- </span>
- </TableCell>
-
- {/* 승인/반려일 */}
- <TableCell className="py-1 px-2">
- <div className="text-xs text-gray-600">
- {revision.approvedDate && (
- <div className="flex items-center gap-1 text-green-600">
- <CheckCircle className="w-3 h-3" />
- <span className="text-xs">{formatDate(revision.approvedDate)}</span>
- </div>
- )}
- {revision.rejectedDate && (
- <div className="flex items-center gap-1 text-red-600">
- <AlertTriangle className="w-3 h-3" />
- <span className="text-xs">{formatDate(revision.rejectedDate)}</span>
- </div>
- )}
- {revision.reviewStartDate && !revision.approvedDate && !revision.rejectedDate && (
- <div className="flex items-center gap-1 text-blue-600">
- <Clock className="w-3 h-3" />
- <span className="text-xs">{formatDate(revision.reviewStartDate)}</span>
- </div>
- )}
- {!revision.approvedDate && !revision.rejectedDate && !revision.reviewStartDate && (
- <span className="text-gray-400 text-xs">-</span>
- )}
- </div>
- </TableCell>
-
- {/* ✅ 첨부파일 - 클릭 시 다운로드, 별도 뷰어 버튼 */}
- <TableCell className="py-1 px-2">
- {hasAttachments ? (
- <div className="flex items-center gap-1 flex-wrap">
- {/* 파일 아이콘들 - 클릭 시 다운로드 */}
- {revision.attachments.slice(0, 4).map((file: any) => (
- <Button
- key={file.id}
- variant="ghost"
- size="sm"
- onClick={() => handleDownloadFile(file)}
- className="p-0.5 h-auto hover:bg-blue-50 rounded"
- title={`${file.fileName} - 클릭해서 다운로드`}
- >
- <FileIcon className={cn("w-3 h-3", getFileIconColor(file.fileName))} />
- </Button>
- ))}
- {revision.attachments.length > 4 && (
- <span
- className="text-xs text-gray-500 ml-0.5"
- title={`총 ${revision.attachments.length}개 파일`}
- >
- +{revision.attachments.length - 4}
- </span>
- )}
- {/* ✅ 모든 파일 보기 버튼 - 뷰어 열기 */}
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleViewRevision([revision])}
- className="p-0.5 h-auto hover:bg-green-50 rounded ml-1"
- title="모든 파일 보기"
- >
- <Eye className="w-3 h-3 text-green-600" />
- </Button>
- </div>
- ) : (
- <span className="text-gray-400 text-xs">-</span>
- )}
- </TableCell>
-
- {/* 액션 */}
- <TableCell className="py-1 px-2">
- <div className="flex gap-0.5">
- {revision.revisionStatus === 'UNDER_REVIEW' && onRevisionStatusUpdate && (
- <>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => onRevisionStatusUpdate(revision.id, 'APPROVED')}
- className="text-green-600 hover:bg-green-50 h-6 px-1"
- title="승인"
- >
- <CheckCircle className="w-3 h-3" />
- </Button>
- <Button
- size="sm"
- variant="ghost"
- onClick={() => onRevisionStatusUpdate(revision.id, 'REJECTED')}
- className="text-red-600 hover:bg-red-50 h-6 px-1"
- title="반려"
- >
- <AlertTriangle className="w-3 h-3" />
- </Button>
- </>
- )}
- <Button
- size="sm"
- variant="ghost"
- onClick={() => onUploadRevision(documentData, stage.stageName, revision.revision, 'append')}
- className="text-blue-600 hover:bg-blue-50 h-6 px-1"
- title="파일 추가"
- >
- <Upload className="w-3 h-3" />
- </Button>
- </div>
- </TableCell>
-
- {/* 코멘트 */}
- <TableCell className="py-1 px-2">
- {revision.comment ? (
- <div className="max-w-24">
- <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}>
- {revision.comment}
- </p>
- </div>
- ) : (
- <span className="text-gray-400 text-xs">-</span>
- )}
- </TableCell>
- </TableRow>
- )
- })}
- </TableBody>
- </Table>
- </div>
- ) : (
- <div className="p-6 text-center">
- <div className="flex flex-col items-center gap-3">
- <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
- <FileText className="w-6 h-6 text-gray-300" />
- </div>
- <div>
- <h5 className="font-medium text-gray-700 mb-1 text-sm">리비전이 없습니다</h5>
- <p className="text-xs text-gray-500 mb-3">아직 이 스테이지에 업로드된 리비전이 없습니다</p>
- <Button
- size="sm"
- onClick={() => onUploadRevision(documentData, stage.stageName, undefined, 'new')}
- className="text-xs"
- >
- <Upload className="w-3 h-3 mr-1" />
- 첫 리비전 업로드
- </Button>
- </div>
- </div>
- </div>
- )}
- </div>
- )}
- </div>
- )
- })}
- </div>
- </ScrollArea>
- </div>
- </div>
-
- {/* ✅ 통합된 문서 뷰어 다이얼로그 */}
- <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}>
- <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}>
- <DialogHeader className="h-[38px]">
- <DialogTitle>문서 미리보기</DialogTitle>
- <DialogDescription>
- {selectedRevisions.length === 1
- ? `리비전 ${selectedRevisions[0]?.revision} 첨부파일`
- : `${selectedRevisions.length}개 리비전 첨부파일`
- }
- </DialogDescription>
- </DialogHeader>
- <div
- ref={viewer}
- style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }}
- >
- {viewerLoading && (
- <div className="flex flex-col items-center justify-center py-12">
- <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">
- 문서 뷰어 로딩 중...
- </p>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- </>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/stage-revision-sheet.tsx b/lib/vendor-document-list/ship/stage-revision-sheet.tsx
deleted file mode 100644
index 2cc22cce..00000000
--- a/lib/vendor-document-list/ship/stage-revision-sheet.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-// StageRevisionDrawer.tsx
-// Slide‑up drawer (bottom) that shows StageRevisionExpandedContent.
-// Requires shadcn/ui Drawer primitives already installed.
-
-"use client"
-
-import * as React from "react"
-import {
- Drawer,
- DrawerContent,
- DrawerHeader,
- DrawerTitle,
- DrawerDescription,
-} from "@/components/ui/drawer"
-
-import type { EnhancedDocument } from "@/types/enhanced-documents"
-import { StageRevisionExpandedContent } from "./stage-revision-expanded-content"
-
-export interface StageRevisionDrawerProps {
- /** whether the drawer is open */
- open: boolean
- /** callback invoked when the open state should change */
- onOpenChange: (open: boolean) => void
- /** the document whose stages / revisions are displayed */
- document: EnhancedDocument | null
- /** project type to propagate further */
- projectType: "ship" | "plant"
- /** callbacks forwarded to StageRevisionExpandedContent */
- onUploadRevision: (
- doc: EnhancedDocument,
- stageName?: string,
- currentRevision?: string,
- mode?: "new" | "append"
- ) => void
- onViewRevision: (revisions: any[]) => void
- onStageStatusUpdate?: (stageId: number, status: string) => void
- onRevisionStatusUpdate?: (revisionId: number, status: string) => void
-}
-
-/**
- * Bottom‑anchored Drawer that presents Stage / Revision details.
- * Fills up to 85 vh and slides up from the bottom edge.
- */
-export const StageRevisionDrawer: React.FC<StageRevisionDrawerProps> = ({
- open,
- onOpenChange,
- document,
- projectType,
- onUploadRevision,
- onViewRevision,
- onStageStatusUpdate,
- onRevisionStatusUpdate,
-}) => {
- return (
- <Drawer open={open} onOpenChange={onOpenChange}>
- {/* No trigger – controlled by parent */}
- <DrawerContent className="h-[85vh] flex flex-col p-0">
- <DrawerHeader className="border-b p-4">
- <DrawerTitle>스테이지 / 리비전 상세</DrawerTitle>
- {document && (
- <DrawerDescription className="text-xs text-muted-foreground truncate">
- {document.docNumber} — {document.title}
- </DrawerDescription>
- )}
- </DrawerHeader>
-
- <div className="flex-1 overflow-auto">
- {document ? (
- <StageRevisionExpandedContent
- document={document}
- projectType={projectType}
- onUploadRevision={onUploadRevision}
- onViewRevision={onViewRevision}
- onStageStatusUpdate={onStageStatusUpdate}
- onRevisionStatusUpdate={onRevisionStatusUpdate}
- />
- ) : (
- <div className="flex h-full items-center justify-center text-sm text-gray-500">
- 문서가 선택되지 않았습니다.
- </div>
- )}
- </div>
- </DrawerContent>
- </Drawer>
- )
-}
diff --git a/lib/vendor-document-list/ship/swp-workflow-panel.tsx b/lib/vendor-document-list/ship/swp-workflow-panel.tsx
deleted file mode 100644
index ded306e7..00000000
--- a/lib/vendor-document-list/ship/swp-workflow-panel.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { Send, Eye, CheckCircle, Clock, RefreshCw, AlertTriangle, Loader2 } from "lucide-react"
-import { toast } from "sonner"
-
-import { Button } from "@/components/ui/button"
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { Progress } from "@/components/ui/progress"
-import type { EnhancedDocument } from "@/types/enhanced-documents"
-
-interface SWPWorkflowPanelProps {
- contractId: number
- documents: EnhancedDocument[]
- onWorkflowUpdate?: () => void
-}
-
-type WorkflowStatus =
- | 'IDLE' // 대기 상태
- | 'SUBMITTED' // 목록 전송됨
- | 'UNDER_REVIEW' // 검토 중
- | 'CONFIRMED' // 컨펌됨
- | 'REVISION_REQUIRED' // 수정 요청됨
- | 'RESUBMITTED' // 재전송됨
- | 'APPROVED' // 최종 승인됨
-
-interface WorkflowState {
- status: WorkflowStatus
- lastUpdatedAt?: string
- pendingActions: string[]
- confirmationData?: any
- revisionComments?: string[]
- approvalData?: any
-}
-
-export function SWPWorkflowPanel({
- contractId,
- documents,
- onWorkflowUpdate
-}: SWPWorkflowPanelProps) {
- const [workflowState, setWorkflowState] = React.useState<WorkflowState | null>(null)
- const [isLoading, setIsLoading] = React.useState(false)
- const [actionProgress, setActionProgress] = React.useState(0)
-
- // 워크플로우 상태 조회
- const fetchWorkflowStatus = async () => {
- setIsLoading(true)
- try {
- const response = await fetch(`/api/sync/workflow/status?contractId=${contractId}&targetSystem=SWP`)
- if (!response.ok) throw new Error('Failed to fetch workflow status')
-
- const status = await response.json()
- setWorkflowState(status)
- } catch (error) {
- console.error('Failed to fetch workflow status:', error)
- toast.error('워크플로우 상태를 확인할 수 없습니다')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 컴포넌트 마운트 시 상태 조회
- React.useEffect(() => {
- fetchWorkflowStatus()
- }, [contractId])
-
- // 워크플로우 액션 실행
- const executeWorkflowAction = async (action: string) => {
- setActionProgress(0)
- setIsLoading(true)
-
- try {
- // 진행률 시뮬레이션
- const progressInterval = setInterval(() => {
- setActionProgress(prev => Math.min(prev + 20, 90))
- }, 200)
-
- const response = await fetch('/api/sync/workflow/action', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- contractId,
- targetSystem: 'SWP',
- action,
- documents: documents.map(doc => ({ id: doc.id, documentNo: doc.documentNo }))
- })
- })
-
- if (!response.ok) {
- const errorData = await response.json()
- throw new Error(errorData.message || 'Workflow action failed')
- }
-
- const result = await response.json()
-
- clearInterval(progressInterval)
- setActionProgress(100)
-
- setTimeout(() => {
- setActionProgress(0)
-
- if (result?.success) {
- toast.success(
- `${getActionLabel(action)} 완료`,
- { description: result?.message || '워크플로우가 성공적으로 진행되었습니다.' }
- )
- } else {
- toast.error(
- `${getActionLabel(action)} 실패`,
- { description: result?.message || '워크플로우 실행에 실패했습니다.' }
- )
- }
-
- fetchWorkflowStatus() // 상태 갱신
- onWorkflowUpdate?.()
- }, 500)
-
- } catch (error) {
- setActionProgress(0)
-
- toast.error(`${getActionLabel(action)} 실패`, {
- description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- const getActionLabel = (action: string): string => {
- switch (action) {
- case 'SUBMIT_LIST': return '목록 전송'
- case 'CHECK_CONFIRMATION': return '컨펌 확인'
- case 'RESUBMIT_REVISED': return '수정본 재전송'
- case 'CHECK_APPROVAL': return '승인 확인'
- default: return action
- }
- }
-
- const getStatusBadge = () => {
- if (isLoading) {
- return <Badge variant="secondary">확인 중...</Badge>
- }
-
- if (!workflowState) {
- return <Badge variant="destructive">오류</Badge>
- }
-
- switch (workflowState.status) {
- case 'IDLE':
- return <Badge variant="secondary">대기</Badge>
- case 'SUBMITTED':
- return (
- <Badge variant="default" className="gap-1 bg-blue-500">
- <Clock className="w-3 h-3" />
- 전송됨
- </Badge>
- )
- case 'UNDER_REVIEW':
- return (
- <Badge variant="default" className="gap-1 bg-yellow-500">
- <Eye className="w-3 h-3" />
- 검토 중
- </Badge>
- )
- case 'CONFIRMED':
- return (
- <Badge variant="default" className="gap-1 bg-green-500">
- <CheckCircle className="w-3 h-3" />
- 컨펌됨
- </Badge>
- )
- case 'REVISION_REQUIRED':
- return (
- <Badge variant="destructive" className="gap-1">
- <AlertTriangle className="w-3 h-3" />
- 수정 요청
- </Badge>
- )
- case 'RESUBMITTED':
- return (
- <Badge variant="default" className="gap-1 bg-orange-500">
- <RefreshCw className="w-3 h-3" />
- 재전송됨
- </Badge>
- )
- case 'APPROVED':
- return (
- <Badge variant="default" className="gap-1 bg-green-600">
- <CheckCircle className="w-3 h-3" />
- 승인 완료
- </Badge>
- )
- default:
- return <Badge variant="secondary">알 수 없음</Badge>
- }
- }
-
- const getAvailableActions = (): string[] => {
- if (!workflowState) return []
-
- switch (workflowState.status) {
- case 'IDLE':
- return ['SUBMIT_LIST']
- case 'SUBMITTED':
- return ['CHECK_CONFIRMATION']
- case 'UNDER_REVIEW':
- return ['CHECK_CONFIRMATION']
- case 'CONFIRMED':
- return [] // 컨펌되면 자동으로 다음 단계로
- case 'REVISION_REQUIRED':
- return ['RESUBMIT_REVISED']
- case 'RESUBMITTED':
- return ['CHECK_APPROVAL']
- case 'APPROVED':
- return [] // 완료 상태
- default:
- return []
- }
- }
-
- const availableActions = getAvailableActions()
-
- return (
- <Popover>
- <PopoverTrigger asChild>
- <div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
- className="flex items-center border-orange-200 hover:bg-orange-50"
- disabled={isLoading}
- >
- {isLoading ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- <RefreshCw className="w-4 h-4" />
- )}
- <span className="hidden sm:inline">SWP 워크플로우</span>
- {workflowState?.pendingActions && workflowState.pendingActions.length > 0 && (
- <Badge
- variant="destructive"
- className="h-5 w-5 p-0 text-xs flex items-center justify-center"
- >
- {workflowState.pendingActions.length}
- </Badge>
- )}
- </Button>
- </div>
- </PopoverTrigger>
-
- <PopoverContent className="w-80">
- <div className="space-y-4">
- <div className="space-y-2">
- <h4 className="font-medium">SWP 워크플로우 상태</h4>
- <div className="flex items-center justify-between">
- <span className="text-sm text-muted-foreground">현재 상태</span>
- {getStatusBadge()}
- </div>
- </div>
-
- {workflowState && (
- <div className="space-y-3">
- <Separator />
-
- {/* 대기 중인 액션들 */}
- {workflowState.pendingActions && workflowState.pendingActions.length > 0 && (
- <div className="space-y-2">
- <div className="text-sm font-medium">대기 중인 작업</div>
- {workflowState.pendingActions.map((action, index) => (
- <Badge key={index} variant="outline" className="mr-1">
- {getActionLabel(action)}
- </Badge>
- ))}
- </div>
- )}
-
- {/* 수정 요청 사항 */}
- {workflowState.revisionComments && workflowState.revisionComments.length > 0 && (
- <div className="space-y-2">
- <div className="text-sm font-medium text-red-600">수정 요청 사항</div>
- <div className="text-xs text-muted-foreground space-y-1">
- {workflowState.revisionComments.map((comment, index) => (
- <div key={index} className="p-2 bg-red-50 rounded text-red-700">
- {comment}
- </div>
- ))}
- </div>
- </div>
- )}
-
- {/* 마지막 업데이트 시간 */}
- {workflowState.lastUpdatedAt && (
- <div className="text-sm">
- <div className="text-muted-foreground">마지막 업데이트</div>
- <div className="font-medium">
- {new Date(workflowState.lastUpdatedAt).toLocaleString()}
- </div>
- </div>
- )}
-
- {/* 진행률 표시 */}
- {isLoading && actionProgress > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between text-sm">
- <span>진행률</span>
- <span>{actionProgress}%</span>
- </div>
- <Progress value={actionProgress} className="h-2" />
- </div>
- )}
- </div>
- )}
-
- <Separator />
-
- {/* 액션 버튼들 */}
- <div className="space-y-2">
- {availableActions.length > 0 ? (
- availableActions.map((action) => (
- <Button
- key={action}
- onClick={() => executeWorkflowAction(action)}
- disabled={isLoading}
- className="w-full justify-start"
- size="sm"
- variant={action.includes('SUBMIT') || action.includes('RESUBMIT') ? 'default' : 'outline'}
- >
- {isLoading ? (
- <Loader2 className="w-4 h-4 mr-2 animate-spin" />
- ) : (
- <Send className="w-4 h-4 mr-2" />
- )}
- {getActionLabel(action)}
- </Button>
- ))
- ) : (
- <div className="text-sm text-muted-foreground text-center py-2">
- {workflowState?.status === 'APPROVED'
- ? '워크플로우가 완료되었습니다.'
- : '실행 가능한 작업이 없습니다.'}
- </div>
- )}
-
- {/* 상태 새로고침 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={fetchWorkflowStatus}
- disabled={isLoading}
- className="w-full"
- >
- {isLoading ? (
- <Loader2 className="w-4 h-4 mr-2 animate-spin" />
- ) : (
- <RefreshCw className="w-4 h-4 mr-2" />
- )}
- 상태 새로고침
- </Button>
- </div>
- </div>
- </PopoverContent>
- </Popover>
- )
-} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/update-doc-sheet.tsx b/lib/vendor-document-list/ship/update-doc-sheet.tsx
deleted file mode 100644
index 3e0ca225..00000000
--- a/lib/vendor-document-list/ship/update-doc-sheet.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader, Save } from "lucide-react"
-import { useForm } from "react-hook-form"
-import { toast } from "sonner"
-import { z } from "zod"
-import { useRouter } from "next/navigation"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormDescription,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import {
- Sheet,
- SheetClose,
- SheetContent,
- SheetDescription,
- SheetFooter,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { modifyDocument } from "../service"
-
-// Document 수정을 위한 Zod 스키마 정의
-const updateDocumentSchema = z.object({
- docNumber: z.string().min(1, "Document number is required"),
- title: z.string().min(1, "Title is required"),
- status: z.string().min(1, "Status is required"),
- description: z.string().optional(),
- remarks: z.string().optional()
-});
-
-type UpdateDocumentSchema = z.infer<typeof updateDocumentSchema>;
-
-// 상태 옵션 정의
-const statusOptions = [
- "pending",
- "in-progress",
- "completed",
- "rejected"
-];
-
-interface UpdateDocumentSheetProps
- extends React.ComponentPropsWithRef<typeof Sheet> {
- document: {
- id: number;
- contractId: number;
- docNumber: string;
- title: string;
- status: string;
- description?: string | null;
- remarks?: string | null;
- } | null
-}
-
-export function UpdateDocumentSheet({ document, ...props }: UpdateDocumentSheetProps) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const router = useRouter()
-
- const form = useForm<UpdateDocumentSchema>({
- resolver: zodResolver(updateDocumentSchema),
- defaultValues: {
- docNumber: "",
- title: "",
- status: "",
- description: "",
- remarks: "",
- },
- })
-
- // 폼 초기화 (document가 변경될 때)
- React.useEffect(() => {
- if (document) {
- form.reset({
- docNumber: document.docNumber,
- title: document.title,
- status: document.status,
- description: document.description ?? "",
- remarks: document.remarks ?? "",
- });
- }
- }, [document, form]);
-
- function onSubmit(input: UpdateDocumentSchema) {
- startUpdateTransition(async () => {
- if (!document) return
-
- const result = await modifyDocument({
- id: document.id,
- contractId: document.contractId,
- ...input,
- })
-
- if (!result.success) {
- if ('error' in result) {
- toast.error(result.error)
- } else {
- toast.error("Failed to update document")
- }
- return
- }
-
- form.reset()
- props.onOpenChange?.(false)
- toast.success("Document updated successfully")
- router.refresh()
- })
- }
-
- return (
- <Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
- <SheetHeader className="text-left">
- <SheetTitle>Update Document</SheetTitle>
- <SheetDescription>
- Update the document details and save the changes
- </SheetDescription>
- </SheetHeader>
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col gap-4"
- >
- {/* 문서 번호 필드 */}
- <FormField
- control={form.control}
- name="docNumber"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Document Number</FormLabel>
- <FormControl>
- <Input placeholder="Enter document number" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 문서 제목 필드 */}
- <FormField
- control={form.control}
- name="title"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Title</FormLabel>
- <FormControl>
- <Input placeholder="Enter document title" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 상태 필드 */}
- <FormField
- control={form.control}
- name="status"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Status</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- value={field.value}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="Select status" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectGroup>
- {statusOptions.map((status) => (
- <SelectItem key={status} value={status}>
- {status.charAt(0).toUpperCase() + status.slice(1)}
- </SelectItem>
- ))}
- </SelectGroup>
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 설명 필드 */}
- <FormField
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Description</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Enter document description"
- className="min-h-[80px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 비고 필드 */}
- <FormField
- control={form.control}
- name="remarks"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Remarks</FormLabel>
- <FormControl>
- <Textarea
- placeholder="Enter additional remarks"
- className="min-h-[80px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
- <Button
- type="button"
- variant="outline"
- onClick={() => form.reset()}
- >
- Cancel
- </Button>
- </SheetClose>
- <Button disabled={isUpdatePending}>
- {isUpdatePending && (
- <Loader
- className="mr-2 size-4 animate-spin"
- aria-hidden="true"
- />
- )}
- <Save className="mr-2 size-4" /> Save
- </Button>
- </SheetFooter>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-} \ No newline at end of file