summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list')
-rw-r--r--lib/vendor-document-list/repository.ts44
-rw-r--r--lib/vendor-document-list/service.ts284
-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
-rw-r--r--lib/vendor-document-list/validations.ts33
9 files changed, 1536 insertions, 0 deletions
diff --git a/lib/vendor-document-list/repository.ts b/lib/vendor-document-list/repository.ts
new file mode 100644
index 00000000..43adf7ca
--- /dev/null
+++ b/lib/vendor-document-list/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { documentStagesView } from "@/db/schema/vendorDocu";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function selectVendorDocuments(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(documentStagesView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countVendorDocuments(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(documentStagesView).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts
new file mode 100644
index 00000000..75c9b6cd
--- /dev/null
+++ b/lib/vendor-document-list/service.ts
@@ -0,0 +1,284 @@
+"use server"
+
+import { eq, SQL } from "drizzle-orm"
+import db from "@/db/db"
+import { documents, documentStagesView, issueStages } from "@/db/schema/vendorDocu"
+import { contracts } from "@/db/schema/vendorData"
+import { GetVendorDcoumentsSchema } from "./validations"
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { getErrorMessage } from "@/lib/handle-error";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { countVendorDocuments, selectVendorDocuments } from "./repository"
+import path from "path";
+import fs from "fs/promises";
+import { v4 as uuidv4 } from "uuid"
+import { z } from "zod"
+import { revalidateTag, unstable_noStore ,revalidatePath} from "next/cache";
+
+/**
+ * 특정 vendorId에 속한 문서 목록 조회
+ */
+export async function getVendorDocuments(input: GetVendorDcoumentsSchema, id: number) {
+
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: documentStagesView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(documentStagesView.title, s), ilike(documentStagesView.docNumber, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(advancedWhere, globalWhere, eq(documentStagesView.contractId, id));
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(documentStagesView[item.id]) : asc(documentStagesView[item.id])
+ )
+ : [asc(documentStagesView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectVendorDocuments(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countVendorDocuments(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(id)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: [`vendor-docuemnt-list-${id}`],
+ }
+ )();
+}
+
+
+// 입력 스키마 정의
+const createDocumentSchema = z.object({
+ docNumber: z.string().min(1, "Document number is required"),
+ title: z.string().min(1, "Title is required"),
+ status: z.string(),
+ stages: z.array(z.string()).min(1, "At least one stage is required"),
+ contractId: z.number().positive("Contract ID is required")
+});
+
+export type CreateDocumentInputType = z.infer<typeof createDocumentSchema>;
+
+export async function createDocument(input: CreateDocumentInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = createDocumentSchema.parse(input);
+
+ // 트랜잭션 사용하여 문서와 스테이지 동시 생성
+ return await db.transaction(async (tx) => {
+ // 1. 문서 생성
+ const [newDocument] = await tx
+ .insert(documents)
+ .values({
+ contractId: validatedData.contractId,
+ docNumber: validatedData.docNumber,
+ title: validatedData.title,
+ status: validatedData.status,
+ // issuedDate는 선택적으로 추가 가능
+ })
+ .returning({ id: documents.id });
+
+ // 2. 스테이지 생성 (문서 ID 연결)
+ const stageValues = validatedData.stages.map(stageName => ({
+ documentId: newDocument.id,
+ stageName: stageName,
+ // planDate, actualDate는 나중에 설정 가능
+ }));
+
+ // 스테이지 배열 삽입
+ await tx.insert(issueStages).values(stageValues);
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ documentId: newDocument.id,
+ message: "Document and stages created successfully"
+ };
+ });
+ } catch (error) {
+ console.error("Error creating document:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ message: "Validation failed",
+ errors: error.errors
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ message: "Failed to create document"
+ };
+ }
+}
+
+// 캐시 무효화 함수
+export async function invalidateDocumentCache(contractId: number) {
+ revalidatePath(`/partners/document-list/${contractId}`);
+ // 추가로 tag 기반 캐시도 무효화할 수 있음
+ revalidateTag(`vendor-docuemnt-list-${contractId}`);
+}
+
+const removeDocumentsSchema = z.object({
+ ids: z.array(z.number()).min(1, "At least one document ID is required")
+});
+export type RemoveDocumentsInputType = z.infer<typeof removeDocumentsSchema>;
+
+export async function removeDocuments(input: RemoveDocumentsInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = removeDocumentsSchema.parse(input);
+
+ // 먼저 삭제할 문서의 contractId를 일반 select 쿼리로 가져옴
+ const [result] = await db
+ .select({ contractId: documents.contractId })
+ .from(documents)
+ .where(eq(documents.id, validatedData.ids[0]))
+ .limit(1);
+
+ const contractId = result?.contractId;
+
+ // 트랜잭션 사용하여 문서 삭제
+ await db.transaction(async (tx) => {
+ // documents 테이블에서 삭제 (cascade 옵션으로 연결된 issueStages도 함께 삭제)
+ await tx
+ .delete(documents)
+ .where(inArray(documents.id, validatedData.ids));
+ });
+
+ // 캐시 무효화
+ if (contractId) {
+ await invalidateDocumentCache(contractId);
+ }
+
+ return { success: true };
+ } catch (error) {
+ console.error("Error removing documents:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ error: "Failed to remove documents"
+ };
+ }
+}
+
+// 입력 스키마 정의
+const modifyDocumentSchema = z.object({
+ id: z.number().positive("Document ID is required"),
+ contractId: z.number().positive("Contract ID is required"),
+ 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()
+});
+
+export type ModifyDocumentInputType = z.infer<typeof modifyDocumentSchema>;
+
+/**
+ * 문서 정보 수정 서버 액션
+ */
+export async function modifyDocument(input: ModifyDocumentInputType) {
+ try {
+ // 입력 유효성 검증
+ const validatedData = modifyDocumentSchema.parse(input);
+
+ // 업데이트할 문서 데이터 준비
+ const updateData = {
+ docNumber: validatedData.docNumber,
+ title: validatedData.title,
+ status: validatedData.status,
+ description: validatedData.description,
+ remarks: validatedData.remarks,
+ updatedAt: new Date() // 수정 시간 업데이트
+ };
+
+ // 트랜잭션 사용하여 문서 업데이트
+ const [updatedDocument] = await db.transaction(async (tx) => {
+ // documents 테이블 업데이트
+ return tx
+ .update(documents)
+ .set(updateData)
+ .where(eq(documents.id, validatedData.id))
+ .returning({ id: documents.id });
+ });
+
+ // 문서가 존재하지 않는 경우 처리
+ if (!updatedDocument) {
+ return {
+ success: false,
+ error: "Document not found"
+ };
+ }
+
+ // 캐시 무효화
+ await invalidateDocumentCache(validatedData.contractId);
+
+ // 성공 결과 반환
+ return {
+ success: true,
+ documentId: updatedDocument.id,
+ message: "Document updated successfully"
+ };
+
+ } catch (error) {
+ console.error("Error updating document:", error);
+
+ // Zod 유효성 검사 에러 처리
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "Validation failed: " + error.errors.map(e => e.message).join(', ')
+ };
+ }
+
+ // 기타 에러 처리
+ return {
+ success: false,
+ error: getErrorMessage(error) || "Failed to update document"
+ };
+ }
+}
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
diff --git a/lib/vendor-document-list/validations.ts b/lib/vendor-document-list/validations.ts
new file mode 100644
index 00000000..036cc6c6
--- /dev/null
+++ b/lib/vendor-document-list/validations.ts
@@ -0,0 +1,33 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { DocumentStagesView } from "@/db/schema/vendorDocu"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<DocumentStagesView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ title: parseAsString.withDefault(""),
+ docNumber: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export type GetVendorDcoumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>