diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /lib/vendor-document | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'lib/vendor-document')
| -rw-r--r-- | lib/vendor-document/repository.ts | 44 | ||||
| -rw-r--r-- | lib/vendor-document/service.ts | 346 | ||||
| -rw-r--r-- | lib/vendor-document/table/doc-table-column.tsx | 150 | ||||
| -rw-r--r-- | lib/vendor-document/table/doc-table-toolbar-actions.tsx | 57 | ||||
| -rw-r--r-- | lib/vendor-document/table/doc-table.tsx | 124 | ||||
| -rw-r--r-- | lib/vendor-document/validations.ts | 33 |
6 files changed, 754 insertions, 0 deletions
diff --git a/lib/vendor-document/repository.ts b/lib/vendor-document/repository.ts new file mode 100644 index 00000000..79e0cf70 --- /dev/null +++ b/lib/vendor-document/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { vendorDocumentsView } 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(vendorDocumentsView) + .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(vendorDocumentsView).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts new file mode 100644 index 00000000..b14a64e0 --- /dev/null +++ b/lib/vendor-document/service.ts @@ -0,0 +1,346 @@ +"use server" + +import { eq, SQL } from "drizzle-orm" +import db from "@/db/db" +import { documentAttachments, documents, issueStages, revisions, vendorDocumentsView } 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 , isNotNull, isNull} from "drizzle-orm"; +import { countVendorDocuments, selectVendorDocuments } from "./repository" +import path from "path"; +import fs from "fs/promises"; +import { v4 as uuidv4 } from "uuid" + +/** + * 특정 vendorId에 속한 문서 목록 조회 + */ +export async function getVendorDocumentLists(input: GetVendorDcoumentsSchema, id: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: vendorDocumentsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(vendorDocumentsView.title, s), ilike(vendorDocumentsView.docNumber, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and(advancedWhere, globalWhere, eq(vendorDocumentsView.contractId, id)); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendorDocumentsView[item.id]) : asc(vendorDocumentsView[item.id]) + ) + : [asc(vendorDocumentsView.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)], // Include id in the cache key + { + revalidate: 3600, + tags: [`vendor-docuemnt-${id}`], + } + )(); +} + + +// getDocumentVersionsByDocId 함수 수정 - 업로더 타입으로 필터링 추가 +export async function getDocumentVersionsByDocId( + docId: number, +) { + // 모든 조건을 배열로 관리 + const conditions: SQL<unknown>[] = [eq(issueStages.documentId, docId)]; + + + + // 쿼리 실행 + const rows = await db + .select({ + // stage 정보 + stageId: issueStages.id, + stageName: issueStages.stageName, + planDate: issueStages.planDate, + actualDate: issueStages.actualDate, + + // revision 정보 + revisionId: revisions.id, + revision: revisions.revision, + uploaderType: revisions.uploaderType, + uploaderName: revisions.uploaderName, + comment: revisions.comment, + status: revisions.status, + approvedDate: revisions.approvedDate, + + // attachment 정보 + attachmentId: documentAttachments.id, + fileName: documentAttachments.fileName, + filePath: documentAttachments.filePath, + fileType: documentAttachments.fileType, + DocumentSubmitDate: revisions.createdAt, + }) + .from(issueStages) + .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId)) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(and(...conditions)) + .orderBy(issueStages.id, revisions.id, documentAttachments.id); + + // 결과를 처리하여 프론트엔드 형식으로 변환 + // 스테이지+리비전별로 그룹화 + const stageRevMap = new Map(); + // 리비전이 있는 스테이지 ID 추적 + const stagesWithRevisions = new Set(); + + for (const row of rows) { + const stageId = row.stageId; + + + // 리비전이 있는 경우 처리 + if (row.revisionId) { + // 리비전이 있는 스테이지 추적 + stagesWithRevisions.add(stageId); + + const key = `${stageId}-${row.revisionId}`; + + if (!stageRevMap.has(key)) { + stageRevMap.set(key, { + id: row.revisionId, + stage: row.stageName, + revision: row.revision, + uploaderType: row.uploaderType, + uploaderName: row.uploaderName || null, + comment: row.comment || null, + status: row.status || null, + planDate: row.planDate, + actualDate: row.actualDate, + approvedDate: row.approvedDate, + DocumentSubmitDate: row.DocumentSubmitDate, + attachments: [] + }); + } + + // attachmentId가 있는 경우에만 첨부파일 추가 + if (row.attachmentId) { + stageRevMap.get(key).attachments.push({ + id: row.attachmentId, + fileName: row.fileName, + filePath: row.filePath, + fileType: row.fileType + }); + } + } + } + + + // 최종 결과 생성 + const result = [ + ...stageRevMap.values() + ]; + + // 스테이지 이름으로 정렬하고, 같은 스테이지 내에서는 리비전이 없는 항목이 먼저 오도록 정렬 + result.sort((a, b) => { + if (a.stage !== b.stage) { + return a.stage.localeCompare(b.stage); + } + + // 같은 스테이지 내에서는 리비전이 없는 항목이 먼저 오도록 + if (a.revision === null) return -1; + if (b.revision === null) return 1; + + // 두 항목 모두 리비전이 있는 경우 리비전 번호로 정렬 + return a.revision - b.revision; + }); + + return result; +} +// createRevisionAction 함수 수정 - 확장된 업로더 타입 지원 +export async function createRevisionAction(formData: FormData) { + + const stage = formData.get("stage") as string | null + const revision = formData.get("revision") as string | null + const docIdStr = formData.get("documentId") as string + const docId = parseInt(docIdStr, 10) + const customFileName = formData.get("customFileName") as string; + + // 업로더 타입 추가 (기본값: "vendor") + const uploaderType = formData.get("uploaderType") as string || "vendor" + const uploaderName = formData.get("uploaderName") as string | null + const comment = formData.get("comment") as string | null + + if (!docId || Number.isNaN(docId)) { + throw new Error("Invalid or missing documentId") + } + if (!stage || !revision) { + throw new Error("Missing stage/revision") + } + + // 업로더 타입 검증 + if (!['vendor', 'client', 'shi'].includes(uploaderType)) { + throw new Error(`Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi`); + } + + // 트랜잭션 시작 + return await db.transaction(async (tx) => { + // (1) issueStageId 찾기 (stageName + documentId) + let issueStageId: number; + const stageRecord = await tx + .select() + .from(issueStages) + .where(and(eq(issueStages.stageName, stage), eq(issueStages.documentId, docId))) + .limit(1) + + if (!stageRecord.length) { + // Stage가 없으면 새로 생성 + const [newStage] = await tx + .insert(issueStages) + .values({ + documentId: docId, + stageName: stage, + updatedAt: new Date(), + }) + .returning() + + issueStageId = newStage.id + } else { + issueStageId = stageRecord[0].id + } + + // (2) Revision 찾기 또는 생성 (issueStageId + revision 조합) + let revisionId: number; + const revisionRecord = await tx + .select() + .from(revisions) + .where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision))) + .limit(1) + + // 기본 상태값 설정 + let status = 'submitted'; + if (uploaderType === 'client') status = 'reviewed'; + if (uploaderType === 'shi') status = 'official'; + + if (!revisionRecord.length) { + // Revision이 없으면 새로 생성 + const [newRevision] = await tx + .insert(revisions) + .values({ + issueStageId, + revision, + uploaderType, + uploaderName: uploaderName || undefined, + comment: comment || undefined, + status, + updatedAt: new Date(), + }) + .returning() + + revisionId = newRevision.id + } else { + // 이미 존재하는 경우, 업로더 타입이 다르면 업데이트 + if (revisionRecord[0].uploaderType !== uploaderType) { + await tx + .update(revisions) + .set({ + uploaderType, + uploaderName: uploaderName || undefined, + comment: comment || undefined, + status, + updatedAt: new Date(), + }) + .where(eq(revisions.id, revisionRecord[0].id)) + } + revisionId = revisionRecord[0].id + } + + // (3) 파일 처리 + const file = formData.get("attachment") as File | null + let attachmentRecord: typeof documentAttachments.$inferSelect | null = null; + + if (file && file.size > 0) { + const originalName = customFileName + const ext = path.extname(originalName) + const uniqueName = uuidv4() + ext + const baseDir = path.join(process.cwd(), "public", "documents") + const savePath = path.join(baseDir, uniqueName) + + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + await fs.writeFile(savePath, buffer) + + // 파일 정보를 documentAttachments 테이블에 저장 + const result = await tx + .insert(documentAttachments) + .values({ + revisionId, + fileName: originalName, + filePath: "/documents/" + uniqueName, + fileSize: file.size, + fileType: ext.replace('.', '').toLowerCase(), + updatedAt: new Date(), + }) + .returning() + + // 첫 번째 결과만 할당 + attachmentRecord = result[0] + } + + // (4) Documents 테이블의 updatedAt 갱신 (docId가 documents.id) + await tx + .update(documents) + .set({ updatedAt: new Date() }) + .where(eq(documents.id, docId)) + + return attachmentRecord + }) +} + + +export async function getStageNamesByDocumentId(documentId: number) { + try { + if (!documentId || Number.isNaN(documentId)) { + throw new Error("Invalid document ID"); + } + + const stageRecords = await db + .select({ stageName: issueStages.stageName }) + .from(issueStages) + .where(eq(issueStages.documentId, documentId)) + .orderBy(issueStages.stageName); + + // stageName 배열로 변환 + return stageRecords.map(record => record.stageName); + } catch (error) { + console.error("Error fetching stage names:", error); + return []; // 오류 발생시 빈 배열 반환 + } +}
\ No newline at end of file diff --git a/lib/vendor-document/table/doc-table-column.tsx b/lib/vendor-document/table/doc-table-column.tsx new file mode 100644 index 00000000..e53b03b9 --- /dev/null +++ b/lib/vendor-document/table/doc-table-column.tsx @@ -0,0 +1,150 @@ +"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 { VendorDocumentsView } from "@/db/schema/vendorDocu" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorDocumentsView> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<VendorDocumentsView>[] { + return [ + { + id: "select", + // Remove the "Select all" checkbox in header since we're doing single-select + header: () => <span className="sr-only">Select</span>, + cell: ({ row, table }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + // If selecting this row + if (value) { + // First deselect all rows (to ensure single selection) + table.toggleAllRowsSelected(false) + // Then select just this row + row.toggleSelected(true) + // Trigger the same action that was in the "Select" button + setRowAction({ row, type: "select" }) + } else { + // Just deselect this row + row.toggleSelected(false) + } + }} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 40, + minSize: 40, + maxSize: 40, + }, + + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc Number" /> + ), + cell: ({ row }) => <div>{row.getValue("docNumber")}</div>, + meta: { + excelHeader: "Doc Number" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + 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: "latestStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Stage Name" /> + ), + cell: ({ row }) => <div>{row.getValue("latestStageName")}</div>, + meta: { + excelHeader: "Latest Stage Name" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Stage Plan Date" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date) : ""; + }, meta: { + excelHeader: "Latest Stage Plan Date" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestStageActualDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Stage Actual Date" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date) : ""; + }, meta: { + excelHeader: "Latest Stage Actual Date" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Revision" /> + ), + cell: ({ row }) => <div>{row.getValue("latestRevision")}</div>, + meta: { + excelHeader: "Latest Revision" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + + { + 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: 180, + }, + // The "actions" column has been removed + ] +}
\ No newline at end of file diff --git a/lib/vendor-document/table/doc-table-toolbar-actions.tsx b/lib/vendor-document/table/doc-table-toolbar-actions.tsx new file mode 100644 index 00000000..cf4aa7c1 --- /dev/null +++ b/lib/vendor-document/table/doc-table-toolbar-actions.tsx @@ -0,0 +1,57 @@ +"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 { VendorDocumentsView } from "@/db/schema/vendorDocu" + + +interface DocTableToolbarActionsProps { + table: Table<VendorDocumentsView> +} + +export function DocTableToolbarActions({ table }: DocTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + + <Button + size="sm" + className="gap-2" + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Bulk File Upload</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/table/doc-table.tsx b/lib/vendor-document/table/doc-table.tsx new file mode 100644 index 00000000..dfd906fa --- /dev/null +++ b/lib/vendor-document/table/doc-table.tsx @@ -0,0 +1,124 @@ +"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 { getVendorDocumentLists } from "../service" +import { VendorDocumentsView } 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" + +interface DocumentListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getVendorDocumentLists>>]> + selectedPackageId: number + onSelectDocument?: (document: VendorDocumentsView | null) => void +} + +export function DocumentListTable({ + promises, + selectedPackageId, + onSelectDocument +}: DocumentListTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + + console.log(data) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorDocumentsView> | null>(null) + + // 3) 행 액션 처리 + useEffect(() => { + if (rowAction) { + // 액션 유형에 따라 처리 + switch (rowAction.type) { + case "select": + // 선택된 문서 처리 + if (onSelectDocument) { + onSelectDocument(rowAction.row.original) + } + break; + case "update": + // 업데이트 처리 로직 + console.log("Update document:", rowAction.row.original) + break; + case "delete": + // 삭제 처리 로직 + console.log("Delete document:", rowAction.row.original) + break; + } + + // 액션 처리 후 rowAction 초기화 + setRowAction(null) + } + }, [rowAction, onSelectDocument]) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<VendorDocumentsView>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorDocumentsView>[] = [ + { + 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.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + return ( + <> + <DataTable table={table} > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <DocTableToolbarActions table={table}/> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document/validations.ts b/lib/vendor-document/validations.ts new file mode 100644 index 00000000..7b8bb5fb --- /dev/null +++ b/lib/vendor-document/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 { VendorDocumentsView, vendorDocumentsView } 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<VendorDocumentsView>().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>> |
