summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
commite0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch)
tree68543a65d88f5afb3a0202925804103daa91bc6f /lib/vendor-document-list/table
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/vendor-document-list/table')
-rw-r--r--lib/vendor-document-list/table/add-doc-dialog.tsx299
-rw-r--r--lib/vendor-document-list/table/delete-docs-dialog.tsx231
-rw-r--r--lib/vendor-document-list/table/doc-table-column.tsx202
-rw-r--r--lib/vendor-document-list/table/doc-table-toolbar-actions.tsx66
-rw-r--r--lib/vendor-document-list/table/doc-table.tsx110
-rw-r--r--lib/vendor-document-list/table/update-doc-sheet.tsx267
6 files changed, 1175 insertions, 0 deletions
diff --git a/lib/vendor-document-list/table/add-doc-dialog.tsx b/lib/vendor-document-list/table/add-doc-dialog.tsx
new file mode 100644
index 00000000..b108721c
--- /dev/null
+++ b/lib/vendor-document-list/table/add-doc-dialog.tsx
@@ -0,0 +1,299 @@
+"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, X } from "lucide-react"
+import { useRouter } from "next/navigation"
+
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { useToast } from "@/hooks/use-toast"
+import { createDocument, CreateDocumentInputType, invalidateDocumentCache } from "../service"
+
+// Zod 스키마 정의 - 빈 문자열 방지 로직 추가
+const createDocumentSchema = z.object({
+ docNumber: z.string().min(1, "Document number is required"),
+ title: z.string().min(1, "Title is required"),
+ stages: z.array(z.string().min(1, "Stage name cannot be empty"))
+ .min(1, "At least one stage is required")
+ .refine(stages => !stages.some(stage => stage.trim() === ""), {
+ message: "Stage names cannot be empty"
+ })
+});
+
+type CreateDocumentSchema = z.infer<typeof createDocumentSchema>;
+
+interface AddDocumentListDialogProps {
+ projectType: "ship" | "plant";
+ contractId: number;
+}
+
+export function AddDocumentListDialog({ projectType, contractId }: AddDocumentListDialogProps) {
+ const [open, setOpen] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const router = useRouter();
+ const { toast } = useToast()
+
+ // 기본 스테이지 설정
+ const defaultStages = projectType === "ship"
+ ? ["For Approval", "For Working"]
+ : [""];
+
+ // react-hook-form 설정
+ const form = useForm<CreateDocumentSchema>({
+ resolver: zodResolver(createDocumentSchema),
+ defaultValues: {
+ docNumber: "",
+ title: "",
+ stages: defaultStages
+ },
+ });
+
+ // 식물 유형일 때 단계 추가 기능
+ const addStage = () => {
+ const currentStages = form.getValues().stages;
+ form.setValue('stages', [...currentStages, ""], { shouldValidate: true });
+ };
+
+ // 식물 유형일 때 단계 제거 기능
+ const removeStage = (index: number) => {
+ const currentStages = form.getValues().stages;
+ const newStages = currentStages.filter((_, i) => i !== index);
+ form.setValue('stages', newStages, { shouldValidate: true });
+ };
+
+ async function onSubmit(data: CreateDocumentSchema) {
+ try {
+ setIsSubmitting(true);
+
+ // 빈 문자열 필터링 (추가 안전장치)
+ const filteredStages = data.stages.filter(stage => stage.trim() !== "");
+
+ if (filteredStages.length === 0) {
+ toast({
+ title: "Error",
+ description: "At least one valid stage name is required",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ // 서버 액션 호출 - status를 "pending"으로 설정
+ const result = await createDocument({
+ ...data,
+ stages: filteredStages, // 필터링된 단계 사용
+ status: "pending", // status 필드 추가
+ contractId, // 계약 ID 추가
+ } as CreateDocumentInputType);
+
+ if (result.success) {
+ // 성공 시 캐시 무효화
+ await invalidateDocumentCache(contractId);
+
+ // 토스트 메시지
+ toast({
+ title: "Success",
+ description: "Document created successfully",
+ variant: "default",
+ });
+
+ // 모달 닫기 및 폼 리셋
+ form.reset();
+ setOpen(false);
+
+ router.refresh();
+ } else {
+ // 실패 시 에러 토스트
+ toast({
+ title: "Error",
+ description: result.message || "Failed to create document",
+ variant: "destructive",
+ });
+ }
+ } catch (error) {
+ console.error('Error creating document:', error);
+ toast({
+ title: "Error",
+ description: "An unexpected error occurred",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ // 제출 전 유효성 검사
+ const validateBeforeSubmit = async () => {
+ // 빈 스테이지 검사
+ const stages = form.getValues().stages;
+ const hasEmptyStage = stages.some(stage => stage.trim() === "");
+
+ if (hasEmptyStage) {
+ form.setError("stages", {
+ type: "manual",
+ message: "Stage names cannot be empty"
+ });
+ return false;
+ }
+
+ return true;
+ };
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset({
+ docNumber: "",
+ title: "",
+ stages: defaultStages
+ });
+ }
+ setOpen(nextOpen);
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="size-4 mr-1"/>
+ Add Document
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>Create New Document</DialogTitle>
+ <DialogDescription>
+ 새 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit, async (errors) => {
+ // 추가 유효성 검사 수행
+ console.error("Form errors:", errors);
+ const stages = form.getValues().stages;
+ if (stages.some(stage => stage.trim() === "")) {
+ toast({
+ title: "Error",
+ description: "Stage names cannot be empty",
+ variant: "destructive",
+ });
+ }
+ })} className="space-y-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>
+ )}
+ />
+
+ {/* 스테이지 섹션 */}
+ <div>
+ <div className="flex items-center justify-between mb-2">
+ <FormLabel>Stages</FormLabel>
+ {projectType === "plant" && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addStage}
+ className="h-8 px-2"
+ >
+ <Plus className="h-4 w-4 mr-1" /> Add Stage
+ </Button>
+ )}
+ </div>
+
+ {form.watch("stages").map((stage, index) => (
+ <div key={index} className="flex items-center gap-2 mb-2">
+ <FormField
+ control={form.control}
+ name={`stages.${index}`}
+ render={({ field }) => (
+ <FormItem className="flex-1">
+ <FormControl>
+ <Input
+ placeholder="Enter stage name"
+ {...field}
+ disabled={projectType === "ship"}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {projectType === "plant" && index > 0 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeStage(index)}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ ))}
+ <FormMessage>
+ {form.formState.errors.stages?.message}
+ </FormMessage>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || form.formState.isSubmitting}
+ >
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/delete-docs-dialog.tsx b/lib/vendor-document-list/table/delete-docs-dialog.tsx
new file mode 100644
index 00000000..8813c742
--- /dev/null
+++ b/lib/vendor-document-list/table/delete-docs-dialog.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import { removeDocuments } from "../service"
+
+interface DeleteDocumentsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ documents: Row<DocumentStagesView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteDocumentsDialog({
+ documents,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteDocumentsDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ // "pending" 상태인 문서만 필터링
+ const pendingDocuments = documents.filter(doc => doc.status === "pending")
+ const nonPendingDocuments = documents.filter(doc => doc.status !== "pending")
+
+ const hasMixedStatus = pendingDocuments.length > 0 && nonPendingDocuments.length > 0
+ const hasNoPendingDocuments = pendingDocuments.length === 0
+
+ function onDelete() {
+ // 삭제할 문서가 없으면 경고
+ if (pendingDocuments.length === 0) {
+ toast.error("No pending documents to delete")
+ props.onOpenChange?.(false)
+ return
+ }
+
+ startDeleteTransition(async () => {
+ // "pending" 상태인 문서 ID만 전달
+ const { success, error } = await removeDocuments({
+ ids: pendingDocuments.map((document) => document.documentId)
+ })
+
+ if (!success) {
+ toast.error(error || "Failed to delete documents")
+ return
+ }
+
+ props.onOpenChange?.(false)
+
+ // 적절한 성공 메시지 표시
+ if (hasMixedStatus) {
+ toast.success(`${pendingDocuments.length} pending document(s) deleted successfully. ${nonPendingDocuments.length} non-pending document(s) were not affected.`)
+ } else {
+ toast.success(`${pendingDocuments.length} document(s) deleted successfully`)
+ }
+
+ onSuccess?.()
+ })
+ }
+
+ // 선택된 문서 상태에 대한 알림 메시지 렌더링
+ const renderStatusAlert = () => {
+ if (hasNoPendingDocuments) {
+ return (
+ <Alert variant="destructive" className="mb-4">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ None of the selected documents are in "pending" status. Only pending documents can be deleted.
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ if (hasMixedStatus) {
+ return (
+ <Alert className="mb-4">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ Only the {pendingDocuments.length} document(s) with "pending" status will be deleted.
+ {nonPendingDocuments.length} document(s) cannot be deleted because they are not in pending status.
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ return null
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({documents.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. Only documents with "pending" status can be deleted.
+ </DialogDescription>
+ </DialogHeader>
+
+ {renderStatusAlert()}
+
+ <div>
+ {pendingDocuments.length > 0 && (
+ <p className="text-sm text-muted-foreground mb-2">
+ {pendingDocuments.length} pending document(s) will be deleted:
+ </p>
+ )}
+ {pendingDocuments.length > 0 && (
+ <ul className="text-sm list-disc pl-5 mb-4 max-h-40 overflow-y-auto">
+ {pendingDocuments.map(doc => (
+ <li key={doc.documentId} className="text-muted-foreground">{doc.docNumber} - {doc.title}</li>
+ ))}
+ </ul>
+ )}
+ </div>
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending || pendingDocuments.length === 0}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete {pendingDocuments.length > 0 ? `(${pendingDocuments.length})` : ""}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({documents.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. Only documents with "pending" status can be deleted.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ {renderStatusAlert()}
+
+ <div className="px-4">
+ {pendingDocuments.length > 0 && (
+ <p className="text-sm text-muted-foreground mb-2">
+ {pendingDocuments.length} pending document(s) will be deleted:
+ </p>
+ )}
+ {pendingDocuments.length > 0 && (
+ <ul className="text-sm list-disc pl-5 mb-4 max-h-40 overflow-y-auto">
+ {pendingDocuments.map(doc => (
+ <li key={doc.documentId} className="text-muted-foreground">{doc.docNumber} - {doc.title}</li>
+ ))}
+ </ul>
+ )}
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending || pendingDocuments.length === 0}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete {pendingDocuments.length > 0 ? `(${pendingDocuments.length})` : ""}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/doc-table-column.tsx b/lib/vendor-document-list/table/doc-table-column.tsx
new file mode 100644
index 00000000..30fb06b0
--- /dev/null
+++ b/lib/vendor-document-list/table/doc-table-column.tsx
@@ -0,0 +1,202 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import { Ellipsis } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesView> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<DocumentStagesView>[] {
+ 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,
+ },
+ {
+ accessorKey: "docNumber",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc Number" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("docNumber")}</div>,
+ meta: {
+ excelHeader: "Doc Number"
+ },
+ enableResizing: true,
+ minSize: 50,
+ size: 100,
+ },
+ {
+ accessorKey: "title",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Doc title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("title")}</div>,
+ meta: {
+ excelHeader: "Doc title"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 160,
+ },
+
+ {
+ accessorKey: "stageCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Stage Count" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("stageCount")}</div>,
+ meta: {
+ excelHeader: "Stage Count"
+ },
+ enableResizing: true,
+ minSize: 50,
+ size: 50,
+ },
+ {
+ accessorKey: "stageList",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Stage List" />
+ ),
+ cell: ({ row }) => {
+ const stageNames = row.getValue("stageList") as string[] | null
+
+ if (!stageNames || stageNames.length === 0) {
+ return <span className="text-sm text-muted-foreground italic">No stages</span>
+ }
+
+ return (
+ <div className="flex flex-wrap gap-2">
+ {stageNames.map((stageName, idx) => (
+ <Badge variant="secondary" key={idx}>
+ {stageName}
+ </Badge>
+ ))}
+ </div>
+ )
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 120,
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="status" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "status"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 60,
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Created At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "created At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ minSize: 120,
+ size: 120,
+ },
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx
new file mode 100644
index 00000000..a30384dd
--- /dev/null
+++ b/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Send, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import { AddDocumentListDialog } from "./add-doc-dialog"
+import { DeleteDocumentsDialog } from "./delete-docs-dialog"
+
+
+interface DocTableToolbarActionsProps {
+ table: Table<DocumentStagesView>
+ projectType: "ship" | "plant";
+ selectedPackageId: number
+}
+
+export function DocTableToolbarActions({ table, projectType, selectedPackageId }: DocTableToolbarActionsProps) {
+
+
+ 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}
+
+
+ <AddDocumentListDialog projectType={projectType} contractId={selectedPackageId} />
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "Document-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+
+
+
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Send to SHI</span>
+ </Button>
+
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/doc-table.tsx b/lib/vendor-document-list/table/doc-table.tsx
new file mode 100644
index 00000000..f70ce365
--- /dev/null
+++ b/lib/vendor-document-list/table/doc-table.tsx
@@ -0,0 +1,110 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns } from "./doc-table-column"
+import { getVendorDocuments } from "../service"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+import { useEffect } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DocTableToolbarActions } from "./doc-table-toolbar-actions"
+import { DeleteDocumentsDialog } from "./delete-docs-dialog"
+
+interface DocumentListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getVendorDocuments>>]>
+ selectedPackageId: number
+ projectType: "ship" | "plant";
+}
+
+export function DocumentsTable({
+ promises,
+ selectedPackageId,
+ projectType,
+}: DocumentListTableProps) {
+ // 1) 데이터를 가져옴 (server component -> use(...) pattern)
+ const [{ data, pageCount }] = React.use(promises)
+
+ console.log(data)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesView> | null>(null)
+
+
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<DocumentStagesView>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesView>[] = [
+ {
+ id: "docNumber",
+ label: "Doc Number",
+ type: "text",
+ },
+ {
+ id: "title",
+ label: "Doc Title",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성
+ const { table } = useDataTable({
+ data: data, // <-- 여기서 tableData 사용
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.documentId),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+
+ })
+ return (
+ <>
+ <DataTable table={table} >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <DocTableToolbarActions table={table} projectType={projectType} selectedPackageId={selectedPackageId} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <DeleteDocumentsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ documents={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/vendor-document-list/table/update-doc-sheet.tsx b/lib/vendor-document-list/table/update-doc-sheet.tsx
new file mode 100644
index 00000000..3e0ca225
--- /dev/null
+++ b/lib/vendor-document-list/table/update-doc-sheet.tsx
@@ -0,0 +1,267 @@
+"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