From 12af09245b38da8cc3fdb851ebb03bc0de45c8be Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Sat, 29 Nov 2025 22:58:24 +0900 Subject: (김준회) 미사용 스키마 코드 제거, 미사용 페이지 제거 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema/bRfq.ts | 1409 ------------------------------------------- db/schema/index.ts | 11 +- db/schema/procurementRFQ.ts | 771 +---------------------- db/schema/rfqLastTBE.ts | 63 +- db/schema/vendors.ts | 3 +- 5 files changed, 41 insertions(+), 2216 deletions(-) delete mode 100644 db/schema/bRfq.ts (limited to 'db') diff --git a/db/schema/bRfq.ts b/db/schema/bRfq.ts deleted file mode 100644 index 6eef6ee3..00000000 --- a/db/schema/bRfq.ts +++ /dev/null @@ -1,1409 +0,0 @@ -import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, uniqueIndex, foreignKey } from "drizzle-orm/pg-core"; -import { eq, sql, and, relations } from "drizzle-orm"; -import { projects } from "./projects"; -import { users } from "./users"; -import { vendors } from "./vendors"; -import { incoterms, paymentTerms } from "./procurementRFQ"; - -export const bRfqs = pgTable( - "b_rfqs", - { - id: serial("id").primaryKey(), - - // RFQ 고유 코드 - rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" - - // 프로젝트 참조 - projectId: integer("project_id") - .references(() => projects.id, { onDelete: "set null" }), - - description: varchar("description", { length: 255 }), - - remark: text("remark"), - - dueDate: date("due_date", { mode: "date" }) - .$type() - .notNull(), - - status: varchar("status", { length: 30 }) - .$type<"DRAFT" | "Doc. Received" | "PIC Assigned" | "Doc. Confirmed" | "Init. RFQ Sent" | "Init. RFQ Answered" | "TBE started" | "TBE finished" | "Final RFQ Sent" | "Quotation Received" | "Vendor Selected">() - .default("DRAFT") - .notNull(), - - picCode: varchar("pic_code", { length: 50 }), - picName: varchar("pic_name", { length: 50 }), - EngPicName: varchar("eng_pic_name", { length: 50 }), - - projectCompany: varchar("project_company", { length: 255 }), - projectFlag: varchar("project_flag", { length: 255 }), - projectSite: varchar("project_site", { length: 255 }), - - packageNo: varchar("package_no", { length: 50 }), - packageName: varchar("package_name", { length: 255 }), - - // 생성자 - createdBy: integer("created_by") - .notNull() - .references(() => users.id, { onDelete: "set null" }), - - updatedBy: integer("updated_by") - .notNull() - .references(() => users.id, { onDelete: "set null" }), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - } -); - -export const initialRfq = pgTable("initial_rfq", { - id: serial("id").primaryKey(), - rfqId: integer("rfq_id") - .notNull() - .references(() => bRfqs.id), - - initialRfqStatus: varchar("initial_rfq_status", { length: 30 }) - .$type<"DRAFT" | "Init. RFQ Sent" | "S/L Decline" | "Init. RFQ Answered">() - .default("DRAFT") - .notNull(), - - vendorId: integer("vendor_id") - .notNull() - .references(() => vendors.id), - - dueDate: date("due_date", { mode: "date" }) - .$type() - .notNull(), - - validDate: date("valid_date", { mode: "date" }) - .$type(), - - incotermsCode: varchar("incoterms_code", { length: 20 }) - .references(() => incoterms.code, { onDelete: "set null" }), - - gtc: varchar("gtc", { length: 255 }), - gtcValidDate: varchar("gtc_valid_date", { length: 255 }), - - classification: varchar("classification", { length: 255 }), - sparepart: varchar("sparepart", { length: 255 }), - - shortList: boolean('short_list').notNull().default(false), - returnYn: boolean('return_yn').notNull().default(false), - - cpRequestYn: boolean('cp_request_yn').notNull().default(false), - - prjectGtcYn: boolean('prject_gtc_yn').notNull().default(false), - - returnRevision: integer("return_revision") - .notNull().default(0), - - rfqRevision: integer("rfq_revision") - .notNull().default(0), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); - -export const finalRfq = pgTable("final_rfq", { - id: serial("id").primaryKey(), - rfqId: integer("rfq_id") - .notNull() - .references(() => bRfqs.id), - - finalRfqStatus: varchar("final_rfq_status", { length: 30 }) - .$type<"DRAFT" | "Final RFQ Sent" | "Final RFQ Answered">() - .default("DRAFT") - .notNull(), - - vendorId: integer("vendor_id") - .notNull() - .references(() => vendors.id), - - dueDate: date("due_date", { mode: "date" }) - .$type() - .notNull(), - - validDate: date("valid_date", { mode: "date" }) - .$type(), - - incotermsCode: varchar("incoterms_code", { length: 20 }) - .references(() => incoterms.code, { onDelete: "set null" }), - - gtc: varchar("gtc", { length: 255 }), - gtcValidDate: varchar("gtc_valid_date", { length: 255 }), - - classification: varchar("classification", { length: 255 }), - sparepart: varchar("sparepart", { length: 255 }), - - shortList: boolean('short_list').notNull().default(false), - returnYn: boolean('return_yn').notNull().default(false), - - cpRequestYn: boolean('cp_request_yn').notNull().default(false), - - prjectGtcYn: boolean('prject_gtc_yn').notNull().default(true), - - returnRevision: integer("return_revision") - .notNull().default(0), - - currency: varchar("currency", { length: 10 }).default("KRW"), - - paymentTermsCode: varchar("payment_terms_code", { length: 50 }) - .references(() => paymentTerms.code, { onDelete: "set null" }), - - taxCode: varchar("tax_code", { length: 255 }).default("VV"), - deliveryDate: date("delivery_date", { mode: "date" }) - .$type() - .notNull(), - - placeOfShipping: varchar("place_of_shipping", { length: 255 }), - placeOfDestination: varchar("place_of_destination", { length: 255 }), - - firsttimeYn: boolean('firsttime_yn').notNull().default(true), - materialPriceRelatedYn: boolean("material_price_related_yn").default(false), - remark: text("remark"), - - vendorRemark: text("vendor_remark"), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); - -export const bRfqsAttachments = pgTable( - "b_rfq_attachments", - { - id: serial("id").primaryKey(), - attachmentType: varchar("attachment_type", { length: 50 }).notNull(), - serialNo: varchar("serial_no", { length: 50 }).notNull(), - rfqId: integer("rfq_id") - .notNull() - .references(() => bRfqs.id), - - // 현재 리비전 정보 (빠른 접근용) - currentRevision: varchar("current_revision", { length: 10 }).notNull().default("Rev.0"), - latestRevisionId: integer("latest_revision_id"), // self-reference to bRfqAttachmentRevisions - - // 메타 정보 - description: varchar("description", { length: 500 }), - createdBy: integer("created_by") - .references(() => users.id, { onDelete: "set null" }) - .notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - } -) - -// 리비전 테이블 (모든 파일 버전 관리) -export const bRfqAttachmentRevisions = pgTable( - "b_rfq_attachment_revisions", - { - id: serial("id").primaryKey(), - attachmentId: integer("attachment_id") - .notNull() - .references(() => bRfqsAttachments.id, { onDelete: "cascade" }), - - // 리비전 정보 - revisionNo: varchar("revision_no", { length: 10 }).notNull(), // "Rev.0", "Rev.1", "Rev.2" - revisionComment: text("revision_comment"), - isLatest: boolean("is_latest").notNull().default(true), - - // 파일 정보 - fileName: varchar("file_name", { length: 255 }).notNull(), - originalFileName: varchar("original_file_name", { length: 255 }).notNull(), - filePath: varchar("file_path", { length: 512 }).notNull(), - fileSize: integer("file_size"), - fileType: varchar("file_type", { length: 100 }), - - // 리비전 생성 정보 - createdBy: integer("created_by") - .references(() => users.id, { onDelete: "set null" }) - .notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - - - }, - (t) => ({ - // 첨부파일당 하나의 최신 리비전만 허용 - latestRevisionIdx: uniqueIndex('latest_revision_idx') - .on(t.attachmentId, t.isLatest) - .where(eq(t.isLatest, true)), - - // 첨부파일 + 리비전 번호 유니크 - attachmentRevisionIdx: uniqueIndex('attachment_revision_idx') - .on(t.attachmentId, t.revisionNo), - }) -) - -// 첨부파일 + 최신 리비전 뷰 -export const attachmentsWithLatestRevisionView = pgView("attachments_with_latest_revision", { - // 메인 첨부파일 정보 - attachmentId: integer("attachment_id"), - attachmentType: varchar("attachment_type", { length: 50 }), - serialNo: varchar("serial_no", { length: 50 }), - rfqId: integer("rfq_id"), - description: varchar("description", { length: 500 }), - currentRevision: varchar("current_revision", { length: 10 }), - - // 최신 리비전 파일 정보 - revisionId: integer("revision_id"), - fileName: varchar("file_name", { length: 255 }), - originalFileName: varchar("original_file_name", { length: 255 }), - filePath: varchar("file_path", { length: 512 }), - fileSize: integer("file_size"), - fileType: varchar("file_type", { length: 100 }), - revisionComment: text("revision_comment"), - - // 생성/수정 정보 - createdBy: integer("created_by"), - createdByName: varchar("created_by_name", { length: 255 }), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), -}).as(sql` - SELECT - a.id as attachment_id, - a.attachment_type, - a.serial_no, - a.rfq_id, - a.description, - a.current_revision, - - r.id as revision_id, - r.file_name, - r.original_file_name, - r.file_path, - r.file_size, - r.file_type, - r.revision_comment, - - a.created_by, - u.name as created_by_name, - a.created_at, - a.updated_at - FROM b_rfq_attachments a - LEFT JOIN b_rfq_attachment_revisions r ON a.latest_revision_id = r.id - LEFT JOIN users u ON a.created_by = u.id - `) - -// 2. 벤더별 첨부파일 응답 현황 관리 -export const vendorAttachmentResponses = pgTable( - "vendor_attachment_responses", - { - id: serial("id").primaryKey(), - attachmentId: integer("attachment_id") - .notNull() - .references(() => bRfqsAttachments.id, { onDelete: "cascade" }), - - vendorId: integer("vendor_id") - .notNull() - .references(() => vendors.id, { onDelete: "cascade" }), - - rfqType: varchar("rfq_type", { length: 20 }) - .$type<"INITIAL" | "FINAL">() - .notNull(), // initial_rfq 또는 final_rfq 구분 - - rfqRecordId: integer("rfq_record_id").notNull(), // initialRfq.id 또는 finalRfq.id - - responseStatus: varchar("response_status", { length: 30 }) - .$type<"NOT_RESPONDED" | "RESPONDED" | "REVISION_REQUESTED" | "WAIVED">() - .default("NOT_RESPONDED") - .notNull(), - - currentRevision: varchar("current_revision", { length: 10 }).default("Rev.0"), - respondedRevision: varchar("responded_revision", { length: 10 }), - - responseComment: text("response_comment"), - vendorComment: text("vendor_comment"), - - revisionRequestComment: text("revision_request_comment"), - - - // 응답 관련 날짜 - requestedAt: timestamp("requested_at").notNull(), - respondedAt: timestamp("responded_at"), - revisionRequestedAt: timestamp("revision_requested_at"), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - - createdBy: integer("created_by") - .references(() => users.id, { onDelete: "set null" }) - , - - updatedBy: integer("updated_by") - .references(() => users.id, { onDelete: "set null" }) - , - }, (t) => ({ - // attachmentId + vendorId + rfqType 유니크 - vendorResponseIdx: uniqueIndex('vendor_response_idx').on( - t.attachmentId.asc(), - t.vendorId.asc(), - t.rfqType.asc(), - ), - })); - -// 3. 벤더 응답 첨부파일 -export const vendorResponseAttachmentsB = pgTable( - "vendor_response_attachments_b", - { - id: serial("id").primaryKey(), - vendorResponseId: integer("vendor_response_id") - .notNull() - .references(() => vendorAttachmentResponses.id, { onDelete: "cascade" }), - - fileName: varchar("file_name", { length: 255 }).notNull(), - originalFileName: varchar("original_file_name", { length: 255 }).notNull(), - filePath: varchar("file_path", { length: 512 }).notNull(), - fileSize: integer("file_size"), - fileType: varchar("file_type", { length: 100 }), - - description: varchar("description", { length: 500 }), - - uploadedBy: integer("uploaded_by") - .references(() => users.id, { onDelete: "set null" }), - uploadedAt: timestamp("uploaded_at").defaultNow().notNull(), - }, -); - -// 4. 응답 히스토리 추적 (선택사항) -export const vendorResponseHistory = pgTable( - "vendor_response_history", - { - id: serial("id").primaryKey(), - vendorResponseId: integer("vendor_response_id") - .notNull() - .references(() => vendorAttachmentResponses.id, { onDelete: "cascade" }), - - action: varchar("action", { length: 50 }) - .$type<"REQUESTED" | "RESPONDED" | "REVISION_REQUESTED" | "REVISED" | "WAIVED">() - .notNull(), - - previousStatus: varchar("previous_status", { length: 30 }), - newStatus: varchar("new_status", { length: 30 }), - - comment: text("comment"), - - actionBy: integer("action_by") - .references(() => users.id, { onDelete: "set null" }), - actionAt: timestamp("action_at").defaultNow().notNull(), - }, -); - -// === 유용한 뷰들 === - -// 1. bRfqs 기본 마스터 뷰 (프로젝트 정보 포함) -export const bRfqsMasterView = pgView("b_rfqs_master", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - description: varchar("description", { length: 255 }), - status: varchar("status", { length: 30 }), - dueDate: date("due_date"), - picCode: varchar("pic_code", { length: 50 }), - picName: varchar("pic_name", { length: 50 }), - EngPicName: varchar("eng_pic_name", { length: 50 }), - packageNo: varchar("package_no", { length: 50 }), - packageName: varchar("package_name", { length: 255 }), - projectId: integer("project_id"), - projectCode: varchar("project_code", { length: 50 }), - projectName: text("project_name"), - projectType: varchar("project_type", { length: 20 }), - projectCompany: varchar("project_company", { length: 255 }), - projectFlag: varchar("project_flag", { length: 255 }), - projectSite: varchar("project_site", { length: 255 }), - totalAttachments: integer("total_attachments"), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), -}).as(sql` - SELECT - br.id as rfq_id, - br.rfq_code, - br.description, - br.status, - br.due_date, - br.pic_code, - br.pic_name, - br.eng_pic_name, - br.package_no, - br.package_name, - br.project_id, - p.code as project_code, - p.name as project_name, - p.type as project_type, - br.project_company, - br.project_flag, - br.project_site, - COALESCE(att_count.total_attachments, 0) as total_attachments, - br.created_at, - br.updated_at - FROM b_rfqs br - LEFT JOIN projects p ON br.project_id = p.id - LEFT JOIN ( - SELECT rfq_id, COUNT(*) as total_attachments - FROM b_rfq_attachments - GROUP BY rfq_id - ) att_count ON br.id = att_count.rfq_id -`); - -// 2. Initial RFQ 상세 뷰 (벤더, 인코텀즈 정보 포함) -export const initialRfqDetailView = pgView("initial_rfq_detail", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - rfqStatus: varchar("rfq_status", { length: 30 }), - initialRfqId: integer("initial_rfq_id"), - initialRfqStatus: varchar("initial_rfq_status", { length: 30 }), - vendorId: integer("vendor_id"), - vendorCode: varchar("vendor_code", { length: 50 }), - vendorName: varchar("vendor_name", { length: 255 }), - vendorCategory: varchar("vendor_category", { length: 255 }), - vendorCountry: varchar("vendor_country", { length: 100 }), - vendorBusinessSize: varchar("vendor_business_size", { length: 50 }), - dueDate: date("due_date"), - validDate: date("valid_date"), - incotermsCode: varchar("incoterms_code", { length: 20 }), - incotermsDescription: varchar("incoterms_description", { length: 255 }), - shortList: boolean("short_list"), - returnYn: boolean("return_yn"), - cpRequestYn: boolean("cp_request_yn"), - prjectGtcYn: boolean("prject_gtc_yn"), - returnRevision: integer("return_revision"), - rfqRevision: integer("rfq_revision"), - gtc: varchar("gtc", { length: 255 }), - gtcValidDate: varchar("gtc_valid_date", { length: 255 }), - classification: varchar("classification", { length: 255 }), - sparepart: varchar("sparepart", { length: 255 }), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), -}).as(sql` - SELECT - br.id as rfq_id, - br.rfq_code, - br.status as rfq_status, - ir.id as initial_rfq_id, - ir.initial_rfq_status, - ir.vendor_id, - v.vendor_code, - v.vendor_name, - v.country as vendor_country, - v.business_size as vendor_business_size, - v.vendor_category as vendor_category, - ir.due_date, - ir.valid_date, - ir.incoterms_code, - inc.description as incoterms_description, - ir.short_list, - ir.return_yn, - ir.cp_request_yn, - ir.prject_gtc_yn, - ir.return_revision, - ir.rfq_revision, - ir.gtc, - ir.gtc_valid_date, - ir.classification, - ir.sparepart, - ir.created_at, - ir.updated_at - FROM b_rfqs br - JOIN initial_rfq ir ON br.id = ir.rfq_id - LEFT JOIN vendors_with_types v ON ir.vendor_id = v.id - LEFT JOIN incoterms inc ON ir.incoterms_code = inc.code -`); - -// 3. Final RFQ 상세 뷰 (벤더, 인코텀즈, 결제조건 정보 포함) -export const finalRfqDetailView = pgView("final_rfq_detail", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - rfqStatus: varchar("rfq_status", { length: 30 }), - finalRfqId: integer("final_rfq_id"), - finalRfqStatus: varchar("final_rfq_status", { length: 30 }), - vendorId: integer("vendor_id"), - vendorCode: varchar("vendor_code", { length: 50 }), - vendorName: varchar("vendor_name", { length: 255 }), - vendorCountry: varchar("vendor_country", { length: 100 }), - vendorBusinessSize: varchar("vendor_business_size", { length: 50 }), - dueDate: date("due_date"), - validDate: date("valid_date"), - deliveryDate: date("delivery_date"), - incotermsCode: varchar("incoterms_code", { length: 20 }), - incotermsDescription: varchar("incoterms_description", { length: 255 }), - paymentTermsCode: varchar("payment_terms_code", { length: 50 }), - paymentTermsDescription: varchar("payment_terms_description", { length: 255 }), - currency: varchar("currency", { length: 10 }), - taxCode: varchar("tax_code", { length: 255 }), - placeOfShipping: varchar("place_of_shipping", { length: 255 }), - placeOfDestination: varchar("place_of_destination", { length: 255 }), - shortList: boolean("short_list"), - returnYn: boolean("return_yn"), - cpRequestYn: boolean("cp_request_yn"), - prjectGtcYn: boolean("prject_gtc_yn"), - firsttimeYn: boolean("firsttime_yn"), - materialPriceRelatedYn: boolean("material_price_related_yn"), - returnRevision: integer("return_revision"), - gtc: varchar("gtc", { length: 255 }), - gtcValidDate: varchar("gtc_valid_date", { length: 255 }), - classification: varchar("classification", { length: 255 }), - sparepart: varchar("sparepart", { length: 255 }), - remark: text("remark"), - vendorRemark: text("vendor_remark"), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), -}).as(sql` - SELECT - br.id as rfq_id, - br.rfq_code, - br.status as rfq_status, - fr.id as final_rfq_id, - fr.final_rfq_status, - fr.vendor_id, - v.vendor_code, - v.vendor_name, - v.country as vendor_country, - v.business_size as vendor_business_size, - fr.due_date, - fr.valid_date, - fr.delivery_date, - fr.incoterms_code, - inc.description as incoterms_description, - fr.payment_terms_code, - pt.description as payment_terms_description, - fr.currency, - fr.tax_code, - fr.place_of_shipping, - fr.place_of_destination, - fr.short_list, - fr.return_yn, - fr.cp_request_yn, - fr.prject_gtc_yn, - fr.firsttime_yn, - fr.material_price_related_yn, - fr.return_revision, - fr.gtc, - fr.gtc_valid_date, - fr.classification, - fr.sparepart, - fr.remark, - fr.vendor_remark, - fr.created_at, - fr.updated_at - FROM b_rfqs br - JOIN final_rfq fr ON br.id = fr.rfq_id - LEFT JOIN vendors v ON fr.vendor_id = v.id - LEFT JOIN incoterms inc ON fr.incoterms_code = inc.code - LEFT JOIN payment_terms pt ON fr.payment_terms_code = pt.code -`); - -// 4. 벤더 응답 현황 요약 뷰 -export const vendorResponseSummaryView2 = pgView("vendor_response_summary", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - rfqStatus: varchar("rfq_status", { length: 30 }), - vendorId: integer("vendor_id"), - vendorCode: varchar("vendor_code", { length: 50 }), - vendorName: varchar("vendor_name", { length: 255 }), - vendorCountry: varchar("vendor_country", { length: 100 }), - vendorBusinessSize: varchar("vendor_business_size", { length: 50 }), - rfqType: varchar("rfq_type", { length: 20 }), - totalAttachments: integer("total_attachments"), - respondedCount: integer("responded_count"), - pendingCount: integer("pending_count"), - waivedCount: integer("waived_count"), - revisionRequestedCount: integer("revision_requested_count"), - responseRate: numeric("response_rate", { precision: 5, scale: 2 }), - completionRate: numeric("completion_rate", { precision: 5, scale: 2 }), -}).as(sql` - SELECT - br.id as rfq_id, - br.rfq_code, - br.status as rfq_status, - v.id as vendor_id, - v.vendor_code, - v.vendor_name, - v.country as vendor_country, - v.business_size as vendor_business_size, - var.rfq_type, - COUNT(var.id) as total_attachments, - COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count, - COUNT(CASE WHEN var.response_status = 'NOT_RESPONDED' THEN 1 END) as pending_count, - COUNT(CASE WHEN var.response_status = 'WAIVED' THEN 1 END) as waived_count, - COUNT(CASE WHEN var.response_status = 'REVISION_REQUESTED' THEN 1 END) as revision_requested_count, - ROUND( - (COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) * 100.0 / - NULLIF(COUNT(CASE WHEN var.response_status != 'WAIVED' THEN 1 END), 0)), - 2 - ) as response_rate, - ROUND( - ((COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) + - COUNT(CASE WHEN var.response_status = 'WAIVED' THEN 1 END)) * 100.0 / COUNT(var.id)), - 2 - ) as completion_rate - FROM b_rfqs br - JOIN b_rfq_attachments bra ON br.id = bra.rfq_id - JOIN vendor_attachment_responses var ON bra.id = var.attachment_id - JOIN vendors v ON var.vendor_id = v.id - GROUP BY br.id, br.rfq_code, br.status, v.id, v.vendor_code, v.vendor_name, v.country, v.business_size, var.rfq_type -`); - -// 5. RFQ 전체 진행 현황 대시보드 뷰 -export const rfqDashboardView = pgView("rfq_dashboard", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - description: varchar("description", { length: 255 }), - status: varchar("status", { length: 30 }), - dueDate: date("due_date"), - projectCode: varchar("project_code", { length: 50 }), - projectName: text("project_name"), - packageNo: varchar("package_no", { length: 50 }), - packageName: varchar("package_name", { length: 255 }), - picCode: varchar("pic_code", { length: 50 }), - picName: varchar("pic_name", { length: 50 }), - engPicName: varchar("eng_pic_name", { length: 50 }), - projectCompany: varchar("project_company", { length: 255 }), - projectFlag: varchar("project_flag", { length: 255 }), - projectSite: varchar("project_site", { length: 255 }), - totalAttachments: integer("total_attachments"), - initialVendorCount: integer("initial_vendor_count"), - finalVendorCount: integer("final_vendor_count"), - initialResponseRate: numeric("initial_response_rate", { precision: 5, scale: 2 }), - finalResponseRate: numeric("final_response_rate", { precision: 5, scale: 2 }), - overallProgress: numeric("overall_progress", { precision: 5, scale: 2 }), - daysToDeadline: integer("days_to_deadline"), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), - remark: text("remark"), - updatedByName: varchar("updated_by_name", { length: 255 }), - updatedByEmail: varchar("updated_by_email", { length: 255 }), -}).as(sql` - -- ② SELECT 절 확장 ------------------------------------------- - SELECT - br.id AS rfq_id, - br.rfq_code, - br.description, - br.status, - br.due_date, - p.code AS project_code, - p.name AS project_name, - br.package_no, - br.package_name, - br.pic_code, - br.pic_name, - br.eng_pic_name, - br.project_company, - br.project_flag, - br.project_site, - br.remark, - - -- 첨부/벤더 요약 ----------------------- - COALESCE(att_count.total_attachments, 0) AS total_attachments, - COALESCE(init_summary.vendor_count, 0) AS initial_vendor_count, - COALESCE(final_summary.vendor_count, 0) AS final_vendor_count, - COALESCE(init_summary.avg_response_rate, 0) AS initial_response_rate, - COALESCE(final_summary.avg_response_rate, 0) AS final_response_rate, - - -- 진행률·마감까지 일수 -------------- - CASE - WHEN br.status = 'DRAFT' THEN 0 - WHEN br.status = 'Doc. Received' THEN 10 - WHEN br.status = 'PIC Assigned' THEN 20 - WHEN br.status = 'Doc. Confirmed' THEN 30 - WHEN br.status = 'Init. RFQ Sent' THEN 40 - WHEN br.status = 'Init. RFQ Answered' THEN 50 - WHEN br.status = 'TBE started' THEN 60 - WHEN br.status = 'TBE finished' THEN 70 - WHEN br.status = 'Final RFQ Sent' THEN 80 - WHEN br.status = 'Quotation Received' THEN 90 - WHEN br.status = 'Vendor Selected' THEN 100 - ELSE 0 - END AS overall_progress, - (br.due_date - CURRENT_DATE) AS days_to_deadline, - - br.created_at, - br.updated_at, - - -- 💡 추가되는 컬럼 ------------------- - upd.name AS updated_by_name, - upd.email AS updated_by_email - FROM b_rfqs br - LEFT JOIN projects p ON br.project_id = p.id - - -- ③ 사용자 정보 조인 -------------------- - LEFT JOIN users upd ON br.updated_by = upd.id - - -- (나머지 이미 있던 JOIN 들은 그대로) ----- - LEFT JOIN ( - SELECT rfq_id, COUNT(*) AS total_attachments - FROM b_rfq_attachments - GROUP BY rfq_id - ) att_count ON br.id = att_count.rfq_id - - LEFT JOIN ( - SELECT - rfq_id, - COUNT(DISTINCT vendor_id) AS vendor_count, - AVG(response_rate) AS avg_response_rate - FROM vendor_response_summary - WHERE rfq_type = 'INITIAL' - GROUP BY rfq_id - ) init_summary ON br.id = init_summary.rfq_id - - LEFT JOIN ( - SELECT - rfq_id, - COUNT(DISTINCT vendor_id) AS vendor_count, - AVG(response_rate) AS avg_response_rate - FROM vendor_response_summary - WHERE rfq_type = 'FINAL' - GROUP BY rfq_id - ) final_summary ON br.id = final_summary.rfq_id - `); - -// 사용 예시 타입 정의 -export type VendorAttachmentResponse = typeof vendorAttachmentResponses.$inferSelect; -export type NewVendorAttachmentResponse = typeof vendorAttachmentResponses.$inferInsert; -export type AttachmentRevision = typeof bRfqAttachmentRevisions.$inferSelect; -export type ResponseAttachment = typeof vendorResponseAttachmentsB.$inferSelect; - -export type InitialRfqDetailView = typeof initialRfqDetailView.$inferSelect; -export type FinalRfqDetailView = typeof finalRfqDetailView.$inferSelect; -export type RfqDashboardView = typeof rfqDashboardView.$inferSelect; - - -export const bRfqsRelations = relations(bRfqs, ({ one, many }) => ({ - // 단일 관계 - project: one(projects, { - fields: [bRfqs.projectId], - references: [projects.id], - }), - createdByUser: one(users, { - fields: [bRfqs.createdBy], - references: [users.id], - relationName: "bRfqCreatedBy" - }), - updatedByUser: one(users, { - fields: [bRfqs.updatedBy], - references: [users.id], - relationName: "bRfqUpdatedBy" - }), - - // 다중 관계 - attachments: many(bRfqsAttachments), - initialRfqs: many(initialRfq), - finalRfqs: many(finalRfq), - })); - - // bRfqsAttachments 관계 정의 - export const bRfqsAttachmentsRelations = relations(bRfqsAttachments, ({ one, many }) => ({ - // 단일 관계 - rfq: one(bRfqs, { - fields: [bRfqsAttachments.rfqId], - references: [bRfqs.id], - }), - createdByUser: one(users, { - fields: [bRfqsAttachments.createdBy], - references: [users.id], - }), - latestRevision: one(bRfqAttachmentRevisions, { - fields: [bRfqsAttachments.latestRevisionId], - references: [bRfqAttachmentRevisions.id], - relationName: "attachmentLatestRevision" - }), - - // 다중 관계 - revisions: many(bRfqAttachmentRevisions), - vendorResponses: many(vendorAttachmentResponses), - })); - - // bRfqAttachmentRevisions 관계 정의 - export const bRfqAttachmentRevisionsRelations = relations(bRfqAttachmentRevisions, ({ one }) => ({ - attachment: one(bRfqsAttachments, { - fields: [bRfqAttachmentRevisions.attachmentId], - references: [bRfqsAttachments.id], - }), - createdByUser: one(users, { - fields: [bRfqAttachmentRevisions.createdBy], - references: [users.id], - }), - })); - - // vendorAttachmentResponses 관계 정의 - export const vendorAttachmentResponsesRelations = relations(vendorAttachmentResponses, ({ one, many }) => ({ - // 단일 관계 - attachment: one(bRfqsAttachments, { - fields: [vendorAttachmentResponses.attachmentId], - references: [bRfqsAttachments.id], - }), - vendor: one(vendors, { - fields: [vendorAttachmentResponses.vendorId], - references: [vendors.id], - }), - - // 다중 관계 - responseAttachments: many(vendorResponseAttachmentsB), - history: many(vendorResponseHistory), - })); - - // vendorResponseAttachmentsB 관계 정의 - export const vendorResponseAttachmentsBRelations = relations(vendorResponseAttachmentsB, ({ one }) => ({ - vendorResponse: one(vendorAttachmentResponses, { - fields: [vendorResponseAttachmentsB.vendorResponseId], - references: [vendorAttachmentResponses.id], - }), - uploadedByUser: one(users, { - fields: [vendorResponseAttachmentsB.uploadedBy], - references: [users.id], - }), - })); - - // vendorResponseHistory 관계 정의 - export const vendorResponseHistoryRelations_old = relations(vendorResponseHistory, ({ one }) => ({ - vendorResponse: one(vendorAttachmentResponses, { - fields: [vendorResponseHistory.vendorResponseId], - references: [vendorAttachmentResponses.id], - }), - actionByUser: one(users, { - fields: [vendorResponseHistory.actionBy], - references: [users.id], - }), - })); - - // initialRfq 관계 정의 - export const initialRfqRelations = relations(initialRfq, ({ one }) => ({ - rfq: one(bRfqs, { - fields: [initialRfq.rfqId], - references: [bRfqs.id], - }), - vendor: one(vendors, { - fields: [initialRfq.vendorId], - references: [vendors.id], - }), - })); - - // finalRfq 관계 정의 - export const finalRfqRelations = relations(finalRfq, ({ one }) => ({ - rfq: one(bRfqs, { - fields: [finalRfq.rfqId], - references: [bRfqs.id], - }), - vendor: one(vendors, { - fields: [finalRfq.vendorId], - references: [vendors.id], - }), - })); - - - -// 업데이트된 vendorResponseAttachmentsEnhanced 뷰 -export const vendorResponseAttachmentsEnhanced = pgView("vendor_response_attachments_enhanced", { - // 벤더 응답 파일 기본 정보 - responseAttachmentId: integer("response_attachment_id"), - vendorResponseId: integer("vendor_response_id"), - fileName: varchar("file_name", { length: 255 }), - originalFileName: varchar("original_file_name", { length: 255 }), - filePath: varchar("file_path", { length: 512 }), - fileSize: integer("file_size"), - fileType: varchar("file_type", { length: 100 }), - description: varchar("description", { length: 500 }), - uploadedAt: timestamp("uploaded_at"), - - // 응답 기본 정보 - attachmentId: integer("attachment_id"), - vendorId: integer("vendor_id"), - rfqType: varchar("rfq_type", { length: 20 }), - rfqRecordId: integer("rfq_record_id"), - responseStatus: varchar("response_status", { length: 30 }), - currentRevision: varchar("current_revision", { length: 10 }), - respondedRevision: varchar("responded_revision", { length: 10 }), - - // 코멘트 관련 필드들 (새로 추가된 필드 포함) - responseComment: text("response_comment"), - vendorComment: text("vendor_comment"), - revisionRequestComment: text("revision_request_comment"), // 새로 추가 - - // 날짜 관련 필드들 (새로 추가된 필드 포함) - requestedAt: timestamp("requested_at"), - respondedAt: timestamp("responded_at"), - revisionRequestedAt: timestamp("revision_requested_at"), // 새로 추가 - - // 첨부파일 정보 - attachmentType: varchar("attachment_type", { length: 50 }), - serialNo: varchar("serial_no", { length: 50 }), - rfqId: integer("rfq_id"), - - // 벤더 정보 - vendorCode: varchar("vendor_code", { length: 50 }), - vendorName: varchar("vendor_name", { length: 255 }), - vendorCountry: varchar("vendor_country", { length: 100 }), - - // 발주처 현재 리비전 정보 - latestClientRevisionId: integer("latest_client_revision_id"), - latestClientRevisionNo: varchar("latest_client_revision_no", { length: 10 }), - latestClientFileName: varchar("latest_client_file_name", { length: 255 }), - - // 리비전 비교 정보 - isVersionMatched: boolean("is_version_matched"), - versionLag: integer("version_lag"), // 몇 버전 뒤처져 있는지 - needsUpdate: boolean("needs_update"), - - // 응답 파일 순서 (같은 응답에 대한 여러 파일이 있을 경우) - fileSequence: integer("file_sequence"), - isLatestResponseFile: boolean("is_latest_response_file"), - -}).as(sql` - SELECT - vra.id as response_attachment_id, - vra.vendor_response_id, - vra.file_name, - vra.original_file_name, - vra.file_path, - vra.file_size, - vra.file_type, - vra.description, - vra.uploaded_at, - - -- 응답 기본 정보 - var.attachment_id, - var.vendor_id, - var.rfq_type, - var.rfq_record_id, - var.response_status, - var.current_revision, - var.responded_revision, - - -- 코멘트 (새로 추가된 필드 포함) - var.response_comment, - var.vendor_comment, - var.revision_request_comment, - - -- 날짜 (새로 추가된 필드 포함) - var.requested_at, - var.responded_at, - var.revision_requested_at, - - -- 첨부파일 정보 - ba.attachment_type, - ba.serial_no, - ba.rfq_id, - - -- 벤더 정보 - v.vendor_code, - v.vendor_name, - v.country as vendor_country, - - -- 발주처 현재 리비전 정보 - latest_rev.id as latest_client_revision_id, - latest_rev.revision_no as latest_client_revision_no, - latest_rev.original_file_name as latest_client_file_name, - - -- 리비전 비교 - CASE - WHEN var.responded_revision = ba.current_revision THEN true - ELSE false - END as is_version_matched, - - -- 버전 차이 계산 (Rev.0, Rev.1 형태 가정) - CASE - WHEN var.responded_revision IS NULL THEN NULL - WHEN ba.current_revision IS NULL THEN NULL - ELSE CAST(SUBSTRING(ba.current_revision FROM '[0-9]+') AS INTEGER) - - CAST(SUBSTRING(var.responded_revision FROM '[0-9]+') AS INTEGER) - END as version_lag, - - CASE - WHEN var.response_status = 'RESPONDED' - AND var.responded_revision != ba.current_revision THEN true - ELSE false - END as needs_update, - - -- 파일 순서 - ROW_NUMBER() OVER ( - PARTITION BY var.id - ORDER BY vra.uploaded_at DESC - ) as file_sequence, - - -- 최신 응답 파일 여부 - CASE - WHEN ROW_NUMBER() OVER ( - PARTITION BY var.id - ORDER BY vra.uploaded_at DESC - ) = 1 THEN true - ELSE false - END as is_latest_response_file - - FROM vendor_response_attachments_b vra - JOIN vendor_attachment_responses var ON vra.vendor_response_id = var.id - JOIN b_rfq_attachments ba ON var.attachment_id = ba.id - LEFT JOIN vendors v ON var.vendor_id = v.id - LEFT JOIN b_rfq_attachment_revisions latest_rev ON ba.latest_revision_id = latest_rev.id -`); - -// 2. 첨부파일별 리비전 히스토리 전체 뷰 -export const attachmentRevisionHistoryView = pgView("attachment_revision_history", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - attachmentId: integer("attachment_id"), - attachmentType: varchar("attachment_type", { length: 50 }), - serialNo: varchar("serial_no", { length: 50 }), - - // 발주처 리비전 정보 - clientRevisionId: integer("client_revision_id"), - clientRevisionNo: varchar("client_revision_no", { length: 10 }), - clientFileName: varchar("client_file_name", { length: 255 }), - clientFilePath: varchar("client_file_path", { length: 512 }), - clientFileSize: integer("client_file_size"), - clientRevisionComment: text("client_revision_comment"), - clientRevisionCreatedAt: timestamp("client_revision_created_at"), - isLatestClientRevision: boolean("is_latest_client_revision"), - - // 이 리비전에 대한 벤더 응답 통계 - totalVendorResponses: integer("total_vendor_responses"), - respondedVendors: integer("responded_vendors"), - pendingVendors: integer("pending_vendors"), - totalResponseFiles: integer("total_response_files"), - -}).as(sql` - SELECT - br.id as rfq_id, - br.rfq_code, - ba.id as attachment_id, - ba.attachment_type, - ba.serial_no, - - -- 발주처 리비전 정보 - rev.id as client_revision_id, - rev.revision_no as client_revision_no, - rev.original_file_name as client_file_name, - rev.file_size as client_file_size, - rev.file_path as client_file_path, - rev.revision_comment as client_revision_comment, - rev.created_at as client_revision_created_at, - rev.is_latest as is_latest_client_revision, - - -- 벤더 응답 통계 - COALESCE(response_stats.total_responses, 0) as total_vendor_responses, - COALESCE(response_stats.responded_count, 0) as responded_vendors, - COALESCE(response_stats.pending_count, 0) as pending_vendors, - COALESCE(response_stats.total_files, 0) as total_response_files - - FROM b_rfqs br - JOIN b_rfq_attachments ba ON br.id = ba.rfq_id - JOIN b_rfq_attachment_revisions rev ON ba.id = rev.attachment_id - LEFT JOIN ( - SELECT - var.attachment_id, - COUNT(*) as total_responses, - COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count, - COUNT(CASE WHEN var.response_status = 'NOT_RESPONDED' THEN 1 END) as pending_count, - COUNT(vra.id) as total_files - FROM vendor_attachment_responses var - LEFT JOIN vendor_response_attachments_b vra ON var.id = vra.vendor_response_id - GROUP BY var.attachment_id - ) response_stats ON ba.id = response_stats.attachment_id - - ORDER BY ba.id, rev.created_at DESC -`); - -// 3. 벤더별 응답 현황 상세 뷰 (리비전 정보 포함) -// 업데이트된 vendorResponseDetailView -export const vendorResponseDetailView = pgView("vendor_response_detail", { - // 기본 식별 정보 - responseId: integer("response_id"), - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - rfqType: varchar("rfq_type", { length: 20 }), - rfqRecordId: integer("rfq_record_id"), - - // 첨부파일 정보 - attachmentId: integer("attachment_id"), - attachmentType: varchar("attachment_type", { length: 50 }), - serialNo: varchar("serial_no", { length: 50 }), - attachmentDescription: varchar("attachment_description", { length: 500 }), - - // 벤더 정보 - vendorId: integer("vendor_id"), - vendorCode: varchar("vendor_code", { length: 50 }), - vendorName: varchar("vendor_name", { length: 255 }), - vendorCountry: varchar("vendor_country", { length: 100 }), - - // 응답 상태 정보 - responseStatus: varchar("response_status", { length: 30 }), - currentRevision: varchar("current_revision", { length: 10 }), - respondedRevision: varchar("responded_revision", { length: 10 }), - - // 코멘트 관련 필드들 (새로 추가된 필드 포함) - responseComment: text("response_comment"), - vendorComment: text("vendor_comment"), - revisionRequestComment: text("revision_request_comment"), // 새로 추가 - - // 날짜 관련 필드들 (새로 추가된 필드 포함) - requestedAt: timestamp("requested_at"), - respondedAt: timestamp("responded_at"), - revisionRequestedAt: timestamp("revision_requested_at"), // 새로 추가 - - // 발주처 최신 리비전 정보 - latestClientRevisionNo: varchar("latest_client_revision_no", { length: 10 }), - latestClientFileName: varchar("latest_client_file_name", { length: 255 }), - latestClientFileSize: integer("latest_client_file_size"), - latestClientRevisionComment: text("latest_client_revision_comment"), - - // 리비전 분석 - isVersionMatched: boolean("is_version_matched"), - versionLag: integer("version_lag"), - needsUpdate: boolean("needs_update"), - hasMultipleRevisions: boolean("has_multiple_revisions"), - - // 응답 파일 통계 - totalResponseFiles: integer("total_response_files"), - latestResponseFileName: varchar("latest_response_file_name", { length: 255 }), - latestResponseFileSize: integer("latest_response_file_size"), - latestResponseUploadedAt: timestamp("latest_response_uploaded_at"), - - // 효과적인 상태 (UI 표시용) - effectiveStatus: varchar("effective_status", { length: 50 }), - -}).as(sql` - SELECT - var.id as response_id, - ba.rfq_id, - br.rfq_code, - var.rfq_type, - var.rfq_record_id, - - -- 첨부파일 정보 - ba.id as attachment_id, - ba.attachment_type, - ba.serial_no, - ba.description as attachment_description, - - -- 벤더 정보 - v.id as vendor_id, - v.vendor_code, - v.vendor_name, - v.country as vendor_country, - - -- 응답 상태 - var.response_status, - var.current_revision, - var.responded_revision, - - -- 코멘트 (새로 추가된 필드 포함) - var.response_comment, - var.vendor_comment, - var.revision_request_comment, - - -- 날짜 (새로 추가된 필드 포함) - var.requested_at, - var.responded_at, - var.revision_requested_at, - - -- 발주처 최신 리비전 - latest_rev.revision_no as latest_client_revision_no, - latest_rev.original_file_name as latest_client_file_name, - latest_rev.file_size as latest_client_file_size, - latest_rev.revision_comment as latest_client_revision_comment, - - -- 리비전 분석 - CASE - WHEN var.responded_revision = ba.current_revision THEN true - ELSE false - END as is_version_matched, - - CASE - WHEN var.responded_revision IS NULL OR ba.current_revision IS NULL THEN NULL - ELSE CAST(SUBSTRING(ba.current_revision FROM '[0-9]+') AS INTEGER) - - CAST(SUBSTRING(var.responded_revision FROM '[0-9]+') AS INTEGER) - END as version_lag, - - CASE - WHEN var.response_status = 'RESPONDED' - AND var.responded_revision != ba.current_revision THEN true - ELSE false - END as needs_update, - - CASE - WHEN revision_count.total_revisions > 1 THEN true - ELSE false - END as has_multiple_revisions, - - -- 응답 파일 정보 - COALESCE(file_stats.total_files, 0) as total_response_files, - file_stats.latest_file_name as latest_response_file_name, - file_stats.latest_file_size as latest_response_file_size, - file_stats.latest_uploaded_at as latest_response_uploaded_at, - - -- 효과적인 상태 - CASE - WHEN var.response_status = 'NOT_RESPONDED' THEN 'NOT_RESPONDED' - WHEN var.response_status = 'WAIVED' THEN 'WAIVED' - WHEN var.response_status = 'REVISION_REQUESTED' THEN 'REVISION_REQUESTED' - WHEN var.response_status = 'RESPONDED' AND var.responded_revision = ba.current_revision THEN 'UP_TO_DATE' - WHEN var.response_status = 'RESPONDED' AND var.responded_revision != ba.current_revision THEN 'VERSION_MISMATCH' - ELSE var.response_status - END as effective_status - - FROM vendor_attachment_responses var - JOIN b_rfq_attachments ba ON var.attachment_id = ba.id - JOIN b_rfqs br ON ba.rfq_id = br.id - LEFT JOIN vendors v ON var.vendor_id = v.id - LEFT JOIN b_rfq_attachment_revisions latest_rev ON ba.latest_revision_id = latest_rev.id - LEFT JOIN ( - SELECT - attachment_id, - COUNT(*) as total_revisions - FROM b_rfq_attachment_revisions - GROUP BY attachment_id - ) revision_count ON ba.id = revision_count.attachment_id - LEFT JOIN ( - SELECT - vendor_response_id, - COUNT(*) as total_files, - MAX(original_file_name) as latest_file_name, - MAX(file_size) as latest_file_size, - MAX(uploaded_at) as latest_uploaded_at - FROM vendor_response_attachments_b - GROUP BY vendor_response_id - ) file_stats ON var.id = file_stats.vendor_response_id -`); - -// 4. RFQ 진행 현황 요약 뷰 (리비전 정보 포함) -export const rfqProgressSummaryView = pgView("rfq_progress_summary", { - rfqId: integer("rfq_id"), - rfqCode: varchar("rfq_code", { length: 50 }), - rfqStatus: varchar("rfq_status", { length: 30 }), - dueDate: date("due_date"), - daysToDeadline: integer("days_to_deadline"), - - // 첨부파일 통계 - totalAttachments: integer("total_attachments"), - attachmentsWithMultipleRevisions: integer("attachments_with_multiple_revisions"), - totalClientRevisions: integer("total_client_revisions"), - - // 응답 통계 (INITIAL) - initialVendorCount: integer("initial_vendor_count"), - initialTotalResponses: integer("initial_total_responses"), - initialRespondedCount: integer("initial_responded_count"), - initialUpToDateCount: integer("initial_up_to_date_count"), - initialVersionMismatchCount: integer("initial_version_mismatch_count"), - initialResponseRate: numeric("initial_response_rate", { precision: 5, scale: 2 }), - initialVersionMatchRate: numeric("initial_version_match_rate", { precision: 5, scale: 2 }), - - // 응답 통계 (FINAL) - finalVendorCount: integer("final_vendor_count"), - finalTotalResponses: integer("final_total_responses"), - finalRespondedCount: integer("final_responded_count"), - finalUpToDateCount: integer("final_up_to_date_count"), - finalVersionMismatchCount: integer("final_version_mismatch_count"), - finalResponseRate: numeric("final_response_rate", { precision: 5, scale: 2 }), - finalVersionMatchRate: numeric("final_version_match_rate", { precision: 5, scale: 2 }), - - // 전체 파일 통계 - totalResponseFiles: integer("total_response_files"), - -}).as(sql` - SELECT - br.id as rfq_id, - br.rfq_code, - br.status as rfq_status, - br.due_date, - (br.due_date - CURRENT_DATE) as days_to_deadline, - - -- 첨부파일 통계 - attachment_stats.total_attachments, - attachment_stats.attachments_with_multiple_revisions, - attachment_stats.total_client_revisions, - - -- Initial RFQ 통계 - COALESCE(initial_stats.vendor_count, 0) as initial_vendor_count, - COALESCE(initial_stats.total_responses, 0) as initial_total_responses, - COALESCE(initial_stats.responded_count, 0) as initial_responded_count, - COALESCE(initial_stats.up_to_date_count, 0) as initial_up_to_date_count, - COALESCE(initial_stats.version_mismatch_count, 0) as initial_version_mismatch_count, - COALESCE(initial_stats.response_rate, 0) as initial_response_rate, - COALESCE(initial_stats.version_match_rate, 0) as initial_version_match_rate, - - -- Final RFQ 통계 - COALESCE(final_stats.vendor_count, 0) as final_vendor_count, - COALESCE(final_stats.total_responses, 0) as final_total_responses, - COALESCE(final_stats.responded_count, 0) as final_responded_count, - COALESCE(final_stats.up_to_date_count, 0) as final_up_to_date_count, - COALESCE(final_stats.version_mismatch_count, 0) as final_version_mismatch_count, - COALESCE(final_stats.response_rate, 0) as final_response_rate, - COALESCE(final_stats.version_match_rate, 0) as final_version_match_rate, - - COALESCE(file_stats.total_files, 0) as total_response_files - - FROM b_rfqs br - LEFT JOIN ( - SELECT - ba.rfq_id, - COUNT(*) as total_attachments, - COUNT(CASE WHEN rev_count.total_revisions > 1 THEN 1 END) as attachments_with_multiple_revisions, - SUM(rev_count.total_revisions) as total_client_revisions - FROM b_rfq_attachments ba - LEFT JOIN ( - SELECT - attachment_id, - COUNT(*) as total_revisions - FROM b_rfq_attachment_revisions - GROUP BY attachment_id - ) rev_count ON ba.id = rev_count.attachment_id - GROUP BY ba.rfq_id - ) attachment_stats ON br.id = attachment_stats.rfq_id - - LEFT JOIN ( - SELECT - br.id as rfq_id, - COUNT(DISTINCT var.vendor_id) as vendor_count, - COUNT(*) as total_responses, - COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count, - COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) as up_to_date_count, - COUNT(CASE WHEN vrd.effective_status = 'VERSION_MISMATCH' THEN 1 END) as version_mismatch_count, - ROUND( - COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) * 100.0 / - NULLIF(COUNT(*), 0), 2 - ) as response_rate, - ROUND( - COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) * 100.0 / - NULLIF(COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END), 0), 2 - ) as version_match_rate - FROM b_rfqs br - JOIN vendor_response_detail vrd ON br.id = vrd.rfq_id - JOIN vendor_attachment_responses var ON vrd.response_id = var.id - WHERE var.rfq_type = 'INITIAL' - GROUP BY br.id - ) initial_stats ON br.id = initial_stats.rfq_id - - LEFT JOIN ( - SELECT - br.id as rfq_id, - COUNT(DISTINCT var.vendor_id) as vendor_count, - COUNT(*) as total_responses, - COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) as responded_count, - COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) as up_to_date_count, - COUNT(CASE WHEN vrd.effective_status = 'VERSION_MISMATCH' THEN 1 END) as version_mismatch_count, - ROUND( - COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END) * 100.0 / - NULLIF(COUNT(*), 0), 2 - ) as response_rate, - ROUND( - COUNT(CASE WHEN vrd.effective_status = 'UP_TO_DATE' THEN 1 END) * 100.0 / - NULLIF(COUNT(CASE WHEN var.response_status = 'RESPONDED' THEN 1 END), 0), 2 - ) as version_match_rate - FROM b_rfqs br - JOIN vendor_response_detail vrd ON br.id = vrd.rfq_id - JOIN vendor_attachment_responses var ON vrd.response_id = var.id - WHERE var.rfq_type = 'FINAL' - GROUP BY br.id - ) final_stats ON br.id = final_stats.rfq_id - - LEFT JOIN ( - SELECT - br.id as rfq_id, - COUNT(vra.id) as total_files - FROM b_rfqs br - JOIN b_rfq_attachments ba ON br.id = ba.rfq_id - JOIN vendor_attachment_responses var ON ba.id = var.attachment_id - LEFT JOIN vendor_response_attachments_b vra ON var.id = vra.vendor_response_id - GROUP BY br.id - ) file_stats ON br.id = file_stats.rfq_id -`); - -// 타입 정의 -export type VendorResponseAttachmentEnhanced = typeof vendorResponseAttachmentsEnhanced.$inferSelect; -export type AttachmentRevisionHistory = typeof attachmentRevisionHistoryView.$inferSelect; -export type VendorResponseDetail = typeof vendorResponseDetailView.$inferSelect; -export type RfqProgressSummary = typeof rfqProgressSummaryView.$inferSelect; \ No newline at end of file diff --git a/db/schema/index.ts b/db/schema/index.ts index cd54e032..7d433f7c 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -1,24 +1,29 @@ +export * from './vendors'; export * from './companies'; export * from './contract'; export * from './items'; export * from './pq'; export * from './projects'; -export * from './rfq'; +// 미사용 테이블 주석 처리 +// export * from './rfq'; + export * from './users'; export * from './vendorData'; export * from './vendorDocu'; -export * from './vendors'; export * from './tasks'; export * from './logs'; export * from './basicContractDocumnet'; + +// 미사용 테이블 주석 처리 export * from './procurementRFQ'; export * from './agreementComments'; export * from './setting'; export * from './techSales'; export * from './ocr'; // 명시적 import/export로 vendorResponseSummaryView 이름 충돌 방지 -export * from './bRfq'; +// 미사용 스키마 제거 +// export * from './bRfq'; export * from './techVendors'; export * from './evaluation'; export * from './evaluationTarget'; diff --git a/db/schema/procurementRFQ.ts b/db/schema/procurementRFQ.ts index fe60bb0e..2756f934 100644 --- a/db/schema/procurementRFQ.ts +++ b/db/schema/procurementRFQ.ts @@ -1,65 +1,4 @@ -import { foreignKey, pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, check } from "drizzle-orm/pg-core"; -import { eq, sql, relations } from "drizzle-orm"; -import { projects } from "./projects"; -import { users } from "./users"; -import { vendors } from "./vendors"; - -export const procurementRfqs = pgTable( - "procurement_rfqs", - { - id: serial("id").primaryKey(), - - // RFQ 고유 코드 - rfqCode: varchar("rfq_code", { length: 50 }).unique(), // ex) "RFQ-2025-001" - - // 프로젝트: ECC RFQ는 프로젝트 테이블과 1:N 관계를 가져야 함 - // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문 - projectId: varchar("project_id", { length: 1000 }), - - // SS, II, null 값을 가질 수 있음. - // SS = 시리즈 통합, II = 품목 통합, 공란 = 통합 없음 - series: varchar("series", { length: 50 }), - - // 자재코드, 자재명: ECC RFQ는 자재코드, 자재명을 가지지 않음 - // WHY?: 여러 프로젝트 혹은 여러 시리즈의 동일 품목을 PR로 묶어 올리기 때문 - // 아래 컬럼은 대표 자재코드, 대표 자재명으로 사용 - itemCode: varchar("item_code", { length: 100 }), - itemName: varchar("item_name", { length: 255 }), - - dueDate: date("due_date", { mode: "date" }) - .$type(), // 인터페이스한 값은 dueDate가 없으므로 notNull 제약조건 제거 - - rfqSendDate: date("rfq_send_date", { mode: "date" }) - .$type(), // notNull() 제약조건 제거, null 허용 (ECC에서 수신 후 보내지 않은 RFQ) - - status: varchar("status", { length: 30 }) - .$type<"RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "PO Transfer" | "PO Create">() - .default("RFQ Created") - .notNull(), - - rfqSealedYn: boolean("rfq_sealed_yn").default(false), - picCode: varchar("pic_code", { length: 50 }), // 구매그룹에 대응시킴 (담당자 코드로 3자리) - - remark: text("remark"), - // 생성자 - - sentBy: integer("sent_by") - .references(() => users.id, { onDelete: "set null" }), - - - createdBy: integer("created_by") - .notNull() - .references(() => users.id, { onDelete: "set null" }), - - updatedBy: integer("updated_by") - .notNull() - .references(() => users.id, { onDelete: "set null" }), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - }, - -); +import { pgTable, varchar, timestamp, boolean } from "drizzle-orm/pg-core"; /** * 지불조건, 인코텀즈, 선적/하역(출발지, 도착지) 테이블은 Non-SAP에서 동기화 (Oracle DB to PostgreSQL) @@ -91,712 +30,4 @@ export const placeOfShipping = pgTable("place_of_shipping", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -export const procurementRfqDetails = pgTable( - "procurement_rfq_details", - { - id: serial("id").primaryKey(), - procurementRfqsId: integer("procurement_rfqs_id") - .references(() => procurementRfqs.id, { onDelete: "set null" }), - vendorsId: integer("vendors_id") - .references(() => vendors.id, { onDelete: "set null" }), - currency: varchar("currency", { length: 10 }).default("USD"), - - // 정규화된 paymentTerms 참조 - paymentTermsCode: varchar("payment_terms_code", { length: 50 }) - .references(() => paymentTerms.code, { onDelete: "set null" }), - // paymentTerms 필드는 제거 (코드로 조회) - - // 정규화된 incoterms 참조 - incotermsCode: varchar("incoterms_code", { length: 20 }) - .references(() => incoterms.code, { onDelete: "set null" }), - incotermsDetail: varchar("incoterms_detail", { length: 255 }), - - deliveryDate: date("delivery_date", { mode: "date" }) - .$type() - .notNull(), - taxCode: varchar("tax_code", { length: 255 }).default("VV"), - placeOfShipping: varchar("place_of_shipping", { length: 255 }), - placeOfDestination: varchar("place_of_destination", { length: 255 }), - remark: text("remark"), - cancelReason: text("cancel_reason"), - updatedBy: integer("updated_by") - .notNull() - .references(() => users.id, { onDelete: "set null" }), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - materialPriceRelatedYn: boolean("material_price_related_yn").default(false), - } -); - -export const prItems = pgTable( - "pr_items", - { - id: serial("id").primaryKey(), - procurementRfqsId: integer("procurement_rfqs_id") - .references(() => procurementRfqs.id, { onDelete: "set null" }), - - rfqItem: varchar("rfq_item", { length: 50 }), // 단위 - prItem: varchar("pr_item", { length: 50 }), // 단위 - prNo: varchar("pr_no", { length: 50 }), // 단위 - - // itemId: integer("item_id") - // .references(() => items.id, { onDelete: "set null" }), - - materialCode: varchar("material_code", { length: 255 }), - materialCategory: varchar("material_category", { length: 255 }), - acc: varchar("acc", { length: 255 }), - materialDescription: varchar("material_description", { length: 255 }), - size: varchar("size", { length: 255 }), - deliveryDate: date("delivery_date", { mode: "date" }) - .$type(), - quantity: numeric("quantity", { precision: 12, scale: 2 }) - .$type() - .default(1), - uom: varchar("uom", { length: 50 }), // 단위 - grossWeight: numeric("gross_weight", { precision: 12, scale: 2 }) - .$type() - .default(1), - gwUom: varchar("gw_uom", { length: 50 }), // 단위 - - specNo: varchar("spec_no", { length: 255 }), - specUrl: varchar("spec_url", { length: 255 }), - trackingNo: varchar("tracking_no", { length: 255 }), - - majorYn: boolean("major_yn").default(false), - - projectDef: varchar("project_def", { length: 255 }), - projectSc: varchar("project_sc", { length: 255 }), - projectKl: varchar("project_kl", { length: 255 }), - projectLc: varchar("project_lc", { length: 255 }), - projectDl: varchar("project_dl", { length: 255 }), - - remark: text("remark"), - - }, -); - - -//view -export const procurementRfqsView = pgView("procurement_rfqs_view").as((qb) => { - const createdByUser = alias(users, "created_by_user"); - const updatedByUser = alias(users, "updated_by_user"); - const sentByUser = alias(users, "sent_by_user"); - - return qb - .select({ - // Basic RFQ identification - id: sql`${procurementRfqs.id}`.as("id"), - rfqCode: sql`${procurementRfqs.rfqCode}`.as("rfq_code"), - series: sql`${procurementRfqs.series}`.as("series"), - rfqSealedYn: sql`${procurementRfqs.rfqSealedYn}`.as("rfq_sealed_yn"), - - // Project information - projectCode: sql`${projects.code}`.as("project_code"), - projectName: sql`${projects.name}`.as("project_name"), - - // Item information - itemCode: sql`${procurementRfqs.itemCode}`.as("item_code"), - itemName: sql`${procurementRfqs.itemName}`.as("item_name"), - - // Status and dates - status: sql`${procurementRfqs.status}`.as("status"), - picCode: sql`${procurementRfqs.picCode}`.as("pic_code"), - rfqSendDate: sql`${procurementRfqs.rfqSendDate}`.as("rfq_send_date"), - dueDate: sql`${procurementRfqs.dueDate}`.as("due_date"), - - // 가장 빠른 견적서 제출 날짜 추가 - earliestQuotationSubmittedAt: sql`( - SELECT MIN(submitted_at) - FROM procurement_vendor_quotations - WHERE rfq_id = ${procurementRfqs.id} - AND submitted_at IS NOT NULL - )`.as("earliest_quotation_submitted_at"), - - // Audit information - createdByUserName: sql`${createdByUser.name}`.as("created_by_user_name"), - sentByUserName: sql`${sentByUser.name}`.as("sent_by_user_name"), - updatedAt: sql`${procurementRfqs.updatedAt}`.as("updated_at"), - updatedByUserName: sql`${updatedByUser.name}`.as("updated_by_user_name"), - remark: sql`${procurementRfqs.remark}`.as("remark"), - - // Related item information - majorItemMaterialCode: sql`( - SELECT material_code - FROM pr_items - WHERE procurement_rfqs_id = ${procurementRfqs.id} - AND major_yn = true - LIMIT 1 - )`.as("major_item_material_code"), - - poNo: sql`( - SELECT pr_no - FROM pr_items - WHERE procurement_rfqs_id = ${procurementRfqs.id} - AND major_yn = true - LIMIT 1 - )`.as("po_no"), - - prItemsCount: sql`( - SELECT COUNT(*) - FROM pr_items - WHERE procurement_rfqs_id = ${procurementRfqs.id} - )`.as("pr_items_count") - }) - .from(procurementRfqs) - .leftJoin(projects, eq(procurementRfqs.projectId, projects.id)) - // .leftJoin(items, eq(procurementRfqs.itemId, items.id)) - .leftJoin(createdByUser, eq(procurementRfqs.createdBy, createdByUser.id)) - .leftJoin(updatedByUser, eq(procurementRfqs.updatedBy, updatedByUser.id)) - .leftJoin(sentByUser, eq(procurementRfqs.sentBy, sentByUser.id)); -}); - -// 수정된 pr_items_view -export const prItemsView = pgView("pr_items_view").as((qb) => { - return qb - .select({ - id: prItems.id, - procurementRfqsId: prItems.procurementRfqsId, - rfqItem: prItems.rfqItem, - prItem: prItems.prItem, - prNo: prItems.prNo, - // itemId: prItems.itemId, - materialCode: prItems.materialCode, - materialCategory: prItems.materialCategory, - acc: prItems.acc, - materialDescription: prItems.materialDescription, - size: prItems.size, - deliveryDate: prItems.deliveryDate, - quantity: prItems.quantity, - uom: prItems.uom, - grossWeight: prItems.grossWeight, // 필드명 수정 - gwUom: prItems.gwUom, - specNo: prItems.specNo, - specUrl: prItems.specUrl, - trackingNo: prItems.trackingNo, - majorYn: prItems.majorYn, - projectDef: prItems.projectDef, - projectSc: prItems.projectSc, - projectKl: prItems.projectKl, - projectLc: prItems.projectLc, - projectDl: prItems.projectDl, - remark: prItems.remark, - rfqCode: procurementRfqs.rfqCode, - itemCode: procurementRfqs.itemCode, - itemName: procurementRfqs.itemName - }) - .from(prItems) - .leftJoin(procurementRfqs, eq(prItems.procurementRfqsId, procurementRfqs.id)) -}); - -export const procurementRfqDetailsView = pgView("procurement_rfq_details_view").as((qb) => { - // 기존 별칭 정의 유지 - const rfqDetailsTable = alias(procurementRfqDetails, "rfq_details"); - const rfqsTable = alias(procurementRfqs, "rfqs"); - const projectsTable = alias(projects, "projects"); - const vendorsTable = alias(vendors, "vendors"); - const paymentTermsTable = alias(paymentTerms, "payment_terms"); - const incotermsTable = alias(incoterms, "incoterms"); - const updatedByUser = alias(users, "updated_by_user"); - - return qb - .select({ - // procurementRfqDetails 필드 - detailId: sql`${rfqDetailsTable.id}`.as("detail_id"), - rfqId: sql`${rfqsTable.id}`.as("rfq_id"), - rfqCode: sql`${rfqsTable.rfqCode}`.as("rfq_code"), - - // 프로젝트 관련 필드 - projectCode: sql`${projectsTable.code}`.as("project_code"), - projectName: sql`${projectsTable.name}`.as("project_name"), - - // 아이템 관련 필드 - itemCode: sql`${rfqsTable.itemCode}`.as("item_code"), - itemName: sql`${rfqsTable.itemName}`.as("item_name"), - - // 벤더 관련 필드 - vendorName: sql`${vendorsTable.vendorName}`.as("vendor_name"), - vendorCode: sql`${vendorsTable.vendorCode}`.as("vendor_code"), - vendorId: sql`${vendorsTable.id}`.as("vendor_id"), - vendorCountry: sql`${vendorsTable.country}`.as("vendor_country"), - - // RFQ 상세 정보 필드 - currency: sql`${rfqDetailsTable.currency}`.as("currency"), - paymentTermsCode: sql`${paymentTermsTable.code}`.as("payment_terms_code"), - paymentTermsDescription: sql`${paymentTermsTable.description}`.as("payment_terms_description"), - incotermsCode: sql`${incotermsTable.code}`.as("incoterms_code"), - incotermsDescription: sql`${incotermsTable.description}`.as("incoterms_description"), - incotermsDetail: sql`${rfqDetailsTable.incotermsDetail}`.as("incoterms_detail"), - deliveryDate: sql`${rfqDetailsTable.deliveryDate}`.as("delivery_date"), - taxCode: sql`${rfqDetailsTable.taxCode}`.as("tax_code"), - placeOfShipping: sql`${rfqDetailsTable.placeOfShipping}`.as("place_of_shipping"), - placeOfDestination: sql`${rfqDetailsTable.placeOfDestination}`.as("place_of_destination"), - materialPriceRelatedYn: sql`${rfqDetailsTable.materialPriceRelatedYn}`.as("material_price_related_yn"), - - // 업데이트 관련 필드 - updatedByUserName: sql`${updatedByUser.name}`.as("updated_by_user_name"), - updatedAt: sql`${rfqDetailsTable.updatedAt}`.as("updated_at"), - - // 추가적으로 pr_items 개수도 카운트하는 필드 추가 - prItemsCount: sql`( - SELECT COUNT(*) - FROM pr_items - WHERE procurement_rfqs_id = ${rfqsTable.id} - )`.as("pr_items_count"), - - // 메이저 아이템 개수도 카운트 - majorItemsCount: sql`( - SELECT COUNT(*) - FROM pr_items - WHERE procurement_rfqs_id = ${rfqsTable.id} - AND major_yn = true - )`.as("major_items_count"), - - // 새로운 필드 추가: 코멘트 수 카운트 - commentCount: sql`( - SELECT COUNT(*) - FROM procurement_rfq_comments - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - )`.as("comment_count"), - - // 새로운 필드 추가: 최근 코멘트 날짜 - lastCommentDate: sql`( - SELECT created_at - FROM procurement_rfq_comments - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - ORDER BY created_at DESC LIMIT 1 - )`.as("last_comment_date"), - - // 새로운 필드 추가: 벤더가 마지막으로 코멘트한 날짜 - lastVendorCommentDate: sql`( - SELECT created_at - FROM procurement_rfq_comments - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} AND is_vendor_comment = true - ORDER BY created_at DESC LIMIT 1 - )`.as("last_vendor_comment_date"), - - // 새로운 필드 추가: 첨부파일 수 카운트 - attachmentCount: sql`( - SELECT COUNT(*) - FROM procurement_rfq_attachments - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - )`.as("attachment_count"), - - // 새로운 필드 추가: 견적서 제출 여부 - hasQuotation: sql`( - SELECT COUNT(*) > 0 - FROM procurement_vendor_quotations - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - )`.as("has_quotation"), - - // 새로운 필드 추가: 견적서 상태 - quotationStatus: sql`( - SELECT status - FROM procurement_vendor_quotations - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - ORDER BY created_at DESC LIMIT 1 - )`.as("quotation_status"), - - // 새로운 필드 추가: 견적서 총액 - quotationTotalPrice: sql`( - SELECT total_price - FROM procurement_vendor_quotations - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - ORDER BY created_at DESC LIMIT 1 - )`.as("quotation_total_price"), - - // 최신 견적서 버전 - quotationVersion: sql`( - SELECT quotation_version - FROM procurement_vendor_quotations - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - ORDER BY quotation_version DESC LIMIT 1 - )`.as("quotation_version"), - - // 총 견적서 버전 수 - quotationVersionCount: sql`( - SELECT COUNT(DISTINCT quotation_version) - FROM procurement_vendor_quotations - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - )`.as("quotation_version_count"), - - // 마지막 견적서 생성 날짜 - lastQuotationDate: sql`( - SELECT created_at - FROM procurement_vendor_quotations - WHERE rfq_id = ${rfqsTable.id} AND vendor_id = ${rfqDetailsTable.vendorsId} - ORDER BY quotation_version DESC LIMIT 1 - )`.as("last_quotation_date"), - }) - .from(rfqDetailsTable) - // 기존 조인 유지 - .leftJoin(rfqsTable, eq(rfqDetailsTable.procurementRfqsId, rfqsTable.id)) - .leftJoin(projectsTable, eq(rfqsTable.projectId, projectsTable.id)) - // .leftJoin(itemsTable, eq(rfqsTable.itemId, itemsTable.id)) - .leftJoin(vendorsTable, eq(rfqDetailsTable.vendorsId, vendorsTable.id)) - .leftJoin(paymentTermsTable, eq(rfqDetailsTable.paymentTermsCode, paymentTermsTable.code)) - .leftJoin(incotermsTable, eq(rfqDetailsTable.incotermsCode, incotermsTable.code)) - .leftJoin(updatedByUser, eq(rfqDetailsTable.updatedBy, updatedByUser.id)); -}); - -export const procurementAttachments = pgTable( - "procurement_attachments", - { - id: serial("id").primaryKey(), - attachmentType: varchar("attachment_type", { length: 50 }).notNull(), // 'RFQ_COMMON', 'VENDOR_SPECIFIC' - procurementRfqsId: integer("procurement_rfqs_id") - .references(() => procurementRfqs.id, { onDelete: "cascade" }), - procurementRfqDetailsId: integer("procurement_rfq_details_id") - .references(() => procurementRfqDetails.id, { onDelete: "cascade" }), - fileName: varchar("file_name", { length: 255 }).notNull(), - originalFileName: varchar("original_file_name", { length: 255 }).notNull(), - filePath: varchar("file_path", { length: 512 }).notNull(), - fileSize: integer("file_size"), - fileType: varchar("file_type", { length: 100 }), - description: varchar("description", { length: 500 }), - createdBy: integer("created_by") - .references(() => users.id, { onDelete: "set null" }) - .notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - }, - (table) => ({ - attachmentTypeCheck: check( - "attachment_type_check", - sql`${table.procurementRfqsId} IS NOT NULL OR ${table.procurementRfqDetailsId} IS NOT NULL` - ) - }) -); - -export type ProcurementRfqsView = typeof procurementRfqsView.$inferSelect; -export type PrItemsView = typeof prItemsView.$inferSelect; -export type ProcurementRfqDetailsView = typeof procurementRfqDetailsView.$inferSelect; - - -//vendor response -export const procurementVendorQuotations = pgTable( - "procurement_vendor_quotations", - { - id: serial("id").primaryKey(), - rfqId: integer("rfq_id") - .notNull() - .references(() => procurementRfqs.id, { onDelete: "cascade" }), - vendorId: integer("vendor_id") - .notNull() - .references(() => vendors.id, { onDelete: "set null" }), - - // 견적 요약 정보 - quotationCode: varchar("quotation_code", { length: 50 }), - quotationVersion: integer("quotation_version").default(1), - totalItemsCount: integer("total_items_count").default(0), - subTotal: numeric("sub_total").default("0"), - taxTotal: numeric("tax_total").default("0"), - discountTotal: numeric("discount_total").default("0"), - totalPrice: numeric("total_price").default("0"), - currency: varchar("currency", { length: 10 }).default("USD"), - - // 견적 유효성 및 배송 정보 - validUntil: date("valid_until", { mode: "date" }).$type(), - estimatedDeliveryDate: date("estimated_delivery_date", { mode: "date" }).$type(), - - // 지불 조건 등 상세 정보 - paymentTermsCode: varchar("payment_terms_code", { length: 50 }) - .references(() => paymentTerms.code, { onDelete: "set null" }), - incotermsCode: varchar("incoterms_code", { length: 20 }) - .references(() => incoterms.code, { onDelete: "set null" }), - incotermsDetail: varchar("incoterms_detail", { length: 255 }), - - // 상태 관리 - status: varchar("status", { length: 30 }) - .$type<"Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted">() - .default("Draft") - .notNull(), - - // 기타 정보 - remark: text("remark"), - rejectionReason: text("rejection_reason"), - submittedAt: timestamp("submitted_at"), - acceptedAt: timestamp("accepted_at"), - - // 감사 필드 - createdBy: integer("created_by"), - updatedBy: integer("updated_by"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - } -); - -export const procurementRfqComments = pgTable( - "procurement_rfq_comments", - { - id: serial("id").primaryKey(), - rfqId: integer("rfq_id") - .notNull() - .references(() => procurementRfqs.id, { onDelete: "cascade" }), - vendorId: integer("vendor_id") - .references(() => vendors.id, { onDelete: "set null" }), - userId: integer("user_id") - .references(() => users.id, { onDelete: "set null" }), - content: text("content").notNull(), - isVendorComment: boolean("is_vendor_comment").default(false), - isRead: boolean("is_read").default(false), // 읽음 상태 추가 - parentCommentId: integer("parent_comment_id"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - }, - // 자기참조 FK 정의 - (table) => { - return { - parentFk: foreignKey({ - columns: [table.parentCommentId], - foreignColumns: [table.id], - }).onDelete("set null"), - }; - } -); - -export const procurementRfqAttachments = pgTable( - "procurement_rfq_attachments", - { - id: serial("id").primaryKey(), - rfqId: integer("rfq_id") - .notNull() - .references(() => procurementRfqs.id, { onDelete: "cascade" }), - commentId: integer("comment_id") - .references(() => procurementRfqComments.id, { onDelete: "cascade" }), - quotationId: integer("quotation_id") - .references(() => procurementVendorQuotations.id, { onDelete: "cascade" }), - fileName: varchar("file_name", { length: 255 }).notNull(), - fileSize: integer("file_size").notNull(), - fileType: varchar("file_type", { length: 100 }), - filePath: varchar("file_path", { length: 500 }).notNull(), - isVendorUpload: boolean("is_vendor_upload").default(false), - uploadedBy: integer("uploaded_by") - .references(() => users.id, { onDelete: "set null" }), - vendorId: integer("vendor_id") - .references(() => vendors.id, { onDelete: "set null" }), - uploadedAt: timestamp("uploaded_at").defaultNow().notNull(), - } -); - -export const procurementQuotationItems = pgTable( - "procurement_quotation_items", - { - id: serial("id").primaryKey(), - quotationId: integer("quotation_id") - .notNull() - .references(() => procurementVendorQuotations.id, { onDelete: "cascade" }), - - // PR 아이템과의 연결 추가 - prItemId: integer("pr_item_id") - .notNull() - .references(() => prItems.id, { onDelete: "cascade" }), - - // 원본 PR 아이템 정보 참조 (읽기 전용) - materialCode: varchar("material_code", { length: 50 }), - materialDescription: varchar("material_description", { length: 255 }), - quantity: numeric("quantity").notNull(), - uom: varchar("uom", { length: 20 }), - - // 벤더가 입력하는 정보 - unitPrice: numeric("unit_price").notNull(), - totalPrice: numeric("total_price").notNull(), - currency: varchar("currency", { length: 10 }).default("USD"), - vendorMaterialCode: varchar("vendor_material_code", { length: 50 }), - vendorMaterialDescription: varchar("vendor_material_description", { length: 255 }), - - // 배송 관련 정보 - deliveryDate: date("delivery_date", { mode: "date" }).$type(), - leadTimeInDays: integer("lead_time_in_days"), - - // 세금 및 기타 정보 - taxRate: numeric("tax_rate"), - taxAmount: numeric("tax_amount"), - discountRate: numeric("discount_rate"), - discountAmount: numeric("discount_amount"), - - // 기타 정보 - remark: text("remark"), - isAlternative: boolean("is_alternative").default(false), - isRecommended: boolean("is_recommended").default(false), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), - } -); - - -// procurementRfqComments 테이블의 관계 정의 - 타입 명시 -export const procurementRfqCommentsRelations = relations( - procurementRfqComments, - ({ one, many }) => ({ - user: one(users, { - fields: [procurementRfqComments.userId], - references: [users.id], - }), - vendor: one(vendors, { - fields: [procurementRfqComments.vendorId], - references: [vendors.id], - }), - rfq: one(procurementRfqs, { - fields: [procurementRfqComments.rfqId], - references: [procurementRfqs.id], - }), - parentComment: one(procurementRfqComments, { - fields: [procurementRfqComments.parentCommentId], - references: [procurementRfqComments.id], - relationName: "commentHierarchy", - }), - childComments: many(procurementRfqComments, { relationName: "commentHierarchy" }), - attachments: many(procurementRfqAttachments, { relationName: "commentAttachments" }), - }) -); - -export const procurementRfqAttachmentsRelations = relations( - procurementRfqAttachments, - ({ one }) => ({ - comment: one(procurementRfqComments, { - fields: [procurementRfqAttachments.commentId], - references: [procurementRfqComments.id], - relationName: "commentAttachments", - }), - rfq: one(procurementRfqs, { - fields: [procurementRfqAttachments.rfqId], - references: [procurementRfqs.id], - }), - uploader: one(users, { - fields: [procurementRfqAttachments.uploadedBy], - references: [users.id], - }), - vendor: one(vendors, { - fields: [procurementRfqAttachments.vendorId], - references: [vendors.id], - }), - }) -); - - -export const procurementRfqsRelations = relations( - procurementRfqs, - ({ one, many }) => ({ - project: one(projects, { - fields: [procurementRfqs.projectId], - references: [projects.id], - }), - // item: one(items, { - // fields: [procurementRfqs.itemId], - // references: [items.id], - // }), - createdByUser: one(users, { - fields: [procurementRfqs.createdBy], - references: [users.id], - relationName: "rfqCreator", - }), - updatedByUser: one(users, { - fields: [procurementRfqs.updatedBy], - references: [users.id], - relationName: "rfqUpdater", - }), - rfqDetails: many(procurementRfqDetails), - prItems: many(prItems, { relationName: "rfqPrItems" }), - quotations: many(procurementVendorQuotations), - - }) -); - -export const prItemsRelations = relations( - prItems, - ({ one }) => ({ - rfq: one(procurementRfqs, { - fields: [prItems.procurementRfqsId], - references: [procurementRfqs.id], - relationName: "rfqPrItems" - }), - }) -); - -// procurementRfqDetails 테이블의 관계 정의 -export const procurementRfqDetailsRelations = relations( - procurementRfqDetails, - ({ one }) => ({ - rfq: one(procurementRfqs, { - fields: [procurementRfqDetails.procurementRfqsId], - references: [procurementRfqs.id], - }), - vendor: one(vendors, { - fields: [procurementRfqDetails.vendorsId], - references: [vendors.id], - }), - paymentTerms: one(paymentTerms, { - fields: [procurementRfqDetails.paymentTermsCode], - references: [paymentTerms.code], - }), - incoterms: one(incoterms, { - fields: [procurementRfqDetails.incotermsCode], - references: [incoterms.code], - }), - updatedByUser: one(users, { - fields: [procurementRfqDetails.updatedBy], - references: [users.id], - }), - }) -); - -export const vendorsRelations = relations( - vendors, - ({ many }) => ({ - users: many(users, { - relationName: "vendorUsers" - }), - quotations: many(procurementVendorQuotations), - }) -); - -export const usersRelations = relations( - users, - ({ one }) => ({ - vendor: one(vendors, { - fields: [users.companyId], - references: [vendors.id], - relationName: "vendorUsers" - }), - }) -); - -export const procurementVendorQuotationsRelations = relations( - procurementVendorQuotations, - ({ one, many }) => ({ - rfq: one(procurementRfqs, { - fields: [procurementVendorQuotations.rfqId], - references: [procurementRfqs.id], - // relationName 제거 - }), - vendor: one(vendors, { - fields: [procurementVendorQuotations.vendorId], - references: [vendors.id], - // relationName 제거 - }), - items: many(procurementQuotationItems), - paymentTerms: one(paymentTerms, { - fields: [procurementVendorQuotations.paymentTermsCode], - references: [paymentTerms.code], - }), - incoterms: one(incoterms, { - fields: [procurementVendorQuotations.incotermsCode], - references: [incoterms.code], - }), - }) -); - -export const procurementQuotationItemsRelations = relations( - procurementQuotationItems, - ({ one }) => ({ - quotation: one(procurementVendorQuotations, { - fields: [procurementQuotationItems.quotationId], - references: [procurementVendorQuotations.id], - }), - prItem: one(prItems, { - fields: [procurementQuotationItems.prItemId], - references: [prItems.id], - }), - }) -); - -export type ProcurementVendorQuotations = typeof procurementVendorQuotations.$inferSelect; export type Incoterm = typeof incoterms.$inferSelect; diff --git a/db/schema/rfqLastTBE.ts b/db/schema/rfqLastTBE.ts index e690ce4b..20cefba3 100644 --- a/db/schema/rfqLastTBE.ts +++ b/db/schema/rfqLastTBE.ts @@ -1,6 +1,6 @@ import { pgTable, pgView, serial, varchar, text, timestamp, boolean, integer, numeric, date, alias, jsonb, uniqueIndex, index } from "drizzle-orm/pg-core"; import { eq, sql, relations } from "drizzle-orm"; -import { rfqsLast, rfqLastDetails, rfqLastAttachments, rfqLastAttachmentRevisions } from "./rfqLast"; +import { rfqsLast, rfqLastDetails, rfqLastAttachments, rfqLastAttachmentRevisions, rfqPrItems } from "./rfqLast"; import { users } from "./users"; import { vendors } from "./vendors"; import { rfqLastVendorAttachments } from "./rfqVendor"; @@ -393,23 +393,23 @@ export const tbeSessionSummaryView = pgView("tbe_session_summary_view").as((qb) // 문서 검토 통계 totalDocuments: sql`( SELECT COUNT(*) - FROM rfq_last_tbe_document_reviews - WHERE tbe_session_id = ${tbeSession.id} + FROM ${rfqLastTbeDocumentReviews} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${tbeSession.id} )`.as("total_documents"), reviewedDocuments: sql`( SELECT COUNT(*) - FROM rfq_last_tbe_document_reviews - WHERE tbe_session_id = ${tbeSession.id} - AND review_status IN ('검토완료', '승인') + FROM ${rfqLastTbeDocumentReviews} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${tbeSession.id} + AND ${rfqLastTbeDocumentReviews.reviewStatus} IN ('검토완료', '승인') )`.as("reviewed_documents"), // PDFTron 코멘트 통계 totalComments: sql`( - SELECT COUNT(*) - FROM rfq_last_tbe_pdftron_comments pc - JOIN rfq_last_tbe_document_reviews dr ON pc.document_review_id = dr.id - WHERE dr.tbe_session_id = ${tbeSession.id} + SELECT COALESCE(SUM((${rfqLastTbePdftronComments.commentSummary}->>'total')::int), 0) + FROM ${rfqLastTbePdftronComments} + JOIN ${rfqLastTbeDocumentReviews} ON ${rfqLastTbePdftronComments.documentReviewId} = ${rfqLastTbeDocumentReviews.id} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${tbeSession.id} )`.as("total_comments"), @@ -537,54 +537,53 @@ export const tbeLastView = pgView("tbe_last_view").as((qb) => { // PR 아이템 수 prItemsCount: sql`( SELECT COUNT(*) - FROM rfq_pr_items - WHERE rfqs_last_id = ${rfqsLast.id} + FROM ${rfqPrItems} + WHERE ${rfqPrItems.rfqsLastId} = ${rfqsLast.id} )`.as("pr_items_count"), majorItemsCount: sql`( SELECT COUNT(*) - FROM rfq_pr_items - WHERE rfqs_last_id = ${rfqsLast.id} - AND major_yn = true + FROM ${rfqPrItems} + WHERE ${rfqPrItems.rfqsLastId} = ${rfqsLast.id} + AND ${rfqPrItems.majorYn} = true )`.as("major_items_count"), // 구매자 문서 수 (설계 문서) buyerDocumentsCount: sql`( SELECT COUNT(*) - FROM rfq_last_tbe_document_reviews - WHERE tbe_session_id = ${rfqLastTbeSessions.id} - AND document_source = 'buyer' + FROM ${rfqLastTbeDocumentReviews} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id} + AND ${rfqLastTbeDocumentReviews.documentSource} = 'buyer' )`.as("buyer_documents_count"), // 벤더 문서 수 vendorDocumentsCount: sql`( SELECT COUNT(*) - FROM rfq_last_tbe_vendor_documents - WHERE tbe_session_id = ${rfqLastTbeSessions.id} + FROM ${rfqLastTbeVendorDocuments} + WHERE ${rfqLastTbeVendorDocuments.tbeSessionId} = ${rfqLastTbeSessions.id} )`.as("vendor_documents_count"), // 검토 완료 문서 수 reviewedDocumentsCount: sql`( SELECT COUNT(*) - FROM rfq_last_tbe_document_reviews - WHERE tbe_session_id = ${rfqLastTbeSessions.id} - AND review_status IN ('검토완료', '승인') + FROM ${rfqLastTbeDocumentReviews} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id} + AND ${rfqLastTbeDocumentReviews.reviewStatus} IN ('검토완료', '승인') )`.as("reviewed_documents_count"), // PDFTron 코멘트 수 totalCommentsCount: sql`( - SELECT COUNT(*) - FROM rfq_last_tbe_pdftron_comments pc - JOIN rfq_last_tbe_document_reviews dr ON pc.document_review_id = dr.id - WHERE dr.tbe_session_id = ${rfqLastTbeSessions.id} + SELECT COALESCE(SUM((${rfqLastTbePdftronComments.commentSummary}->>'total')::int), 0) + FROM ${rfqLastTbePdftronComments} + JOIN ${rfqLastTbeDocumentReviews} ON ${rfqLastTbePdftronComments.documentReviewId} = ${rfqLastTbeDocumentReviews.id} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id} )`.as("total_comments_count"), unresolvedCommentsCount: sql`( - SELECT COUNT(*) - FROM rfq_last_tbe_pdftron_comments pc - JOIN rfq_last_tbe_document_reviews dr ON pc.document_review_id = dr.id - WHERE dr.tbe_session_id = ${rfqLastTbeSessions.id} - AND pc.status = 'open' + SELECT COALESCE(SUM((${rfqLastTbePdftronComments.commentSummary}->>'open')::int), 0) + FROM ${rfqLastTbePdftronComments} + JOIN ${rfqLastTbeDocumentReviews} ON ${rfqLastTbePdftronComments.documentReviewId} = ${rfqLastTbeDocumentReviews.id} + WHERE ${rfqLastTbeDocumentReviews.tbeSessionId} = ${rfqLastTbeSessions.id} )`.as("unresolved_comments_count"), // 타임스탬프 diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts index d587d441..c194cf61 100644 --- a/db/schema/vendors.ts +++ b/db/schema/vendors.ts @@ -114,8 +114,7 @@ export const vendorPossibleItems = pgTable("vendor_possible_items", { vendorId: integer("vendor_id").notNull().references(() => vendors.id), // itemId: integer("item_id"), // 별도 item 테이블 연동시 itemCode: varchar("item_code", { length: 100 }) - .notNull() - .references(() => items.itemCode, { onDelete: "cascade" }), + .notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); -- cgit v1.2.3 From 81aa92fecc298d66eb420468316bcf7a7213171c Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Sun, 30 Nov 2025 17:12:17 +0900 Subject: (김준회) group 기능 추가, 기타 버그 수정, preset 기능추가, 테스트 페이지 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/test/table/page.tsx | 168 ++++++++ .../client-table/client-table-column-header.tsx | 70 +++- components/client-table/client-table-preset.tsx | 185 +++++++++ components/client-table/client-table-save-view.tsx | 185 +++++++++ components/client-table/client-table-toolbar.tsx | 23 +- .../client-table/client-table-view-options.tsx | 9 +- components/client-table/client-virtual-table.tsx | 457 ++++++++++++++++----- components/client-table/preset-actions.ts | 87 ++++ components/client-table/preset-types.ts | 13 + db/schema/index.ts | 2 + db/schema/user-custom-data/userCustomData.ts | 16 + 11 files changed, 1093 insertions(+), 122 deletions(-) create mode 100644 app/[lng]/test/table/page.tsx create mode 100644 components/client-table/client-table-preset.tsx create mode 100644 components/client-table/client-table-save-view.tsx create mode 100644 components/client-table/preset-actions.ts create mode 100644 components/client-table/preset-types.ts create mode 100644 db/schema/user-custom-data/userCustomData.ts (limited to 'db') diff --git a/app/[lng]/test/table/page.tsx b/app/[lng]/test/table/page.tsx new file mode 100644 index 00000000..88d050fc --- /dev/null +++ b/app/[lng]/test/table/page.tsx @@ -0,0 +1,168 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { ClientVirtualTable } from "@/components/client-table/client-virtual-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" + +// 1. Define the data type +type TestData = { + id: string + name: string + email: string + role: "Admin" | "User" | "Guest" + status: "Active" | "Inactive" | "Pending" + lastLogin: string + amount: number +} + +// 2. Generate dummy data +const generateData = (count: number): TestData[] => { + const roles: TestData["role"][] = ["Admin", "User", "Guest"] + const statuses: TestData["status"][] = ["Active", "Inactive", "Pending"] + + return Array.from({ length: count }).map((_, i) => ({ + id: `ID-${i + 1}`, + name: `User ${i + 1}`, + email: `user${i + 1}@example.com`, + role: roles[Math.floor(Math.random() * roles.length)], + status: statuses[Math.floor(Math.random() * statuses.length)], + lastLogin: new Date(Date.now() - Math.floor(Math.random() * 10000000000)).toISOString().split('T')[0], + amount: Math.floor(Math.random() * 10000), + })) +} + +export default function TestTablePage() { + // State for data + const [data, setData] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(true) + + // Load data on mount + React.useEffect(() => { + const timer = setTimeout(() => { + setData(generateData(100000)) // Generate 1000 rows + setIsLoading(false) + }, 500) + return () => clearTimeout(timer) + }, []) + + // 3. Define columns + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + size: 80, + }, + { + accessorKey: "name", + header: "Name", + size: 150, + }, + { + accessorKey: "email", + header: "Email", + size: 200, + }, + { + accessorKey: "role", + header: "Role", + size: 100, + cell: ({ getValue }) => { + const role = getValue() as string + return ( + + {role} + + ) + } + }, + { + accessorKey: "status", + header: "Status", + size: 100, + cell: ({ getValue }) => { + const status = getValue() as string + let color = "bg-gray-500" + if (status === "Active") color = "bg-green-500" + if (status === "Inactive") color = "bg-red-500" + if (status === "Pending") color = "bg-yellow-500" + + return ( +
+
+ {status} +
+ ) + } + }, + { + accessorKey: "amount", + header: "Amount", + size: 200, + cell: ({ getValue }) => { + const amount = getValue() as number + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(amount) + }, + meta: { + align: "right" + } + }, + { + accessorKey: "lastLogin", + header: "Last Login", + size: 120, + }, + { + id: "actions", + header: "Actions", + size: 100, + cell: () => ( + + ), + enablePinning: true, + } + ] + + return ( +
+
+
+

Virtual Table Test

+

+ Testing the ClientVirtualTable component with 1000 generated rows. +

+
+
+ +
+
+ +
+ console.log("Row clicked:", row.original)} + enableUserPreset={true} + tableKey="test-table" + /> +
+
+ ) +} diff --git a/components/client-table/client-table-column-header.tsx b/components/client-table/client-table-column-header.tsx index 12dc57ac..2d8e5bce 100644 --- a/components/client-table/client-table-column-header.tsx +++ b/components/client-table/client-table-column-header.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { Header } from "@tanstack/react-table" +import { Header, Column } from "@tanstack/react-table" import { useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" import { flexRender } from "@tanstack/react-table" @@ -20,19 +20,29 @@ import { PinOff, MoveLeft, MoveRight, + Group, + Ungroup, } from "lucide-react" import { cn } from "@/lib/utils" -import { ClientTableFilter } from "./client-table-filter" +import { ClientTableFilter } from "../client-table/client-table-filter" interface ClientTableColumnHeaderProps extends React.HTMLAttributes { header: Header enableReordering?: boolean + renderHeaderVisualFeedback?: (props: { + column: Column + isPinned: boolean | string + isSorted: boolean | string + isFiltered: boolean + isGrouped: boolean + }) => React.ReactNode } export function ClientTableColumnHeader({ header, enableReordering = true, + renderHeaderVisualFeedback, className, ...props }: ClientTableColumnHeaderProps) { @@ -46,7 +56,7 @@ export function ClientTableColumnHeader({ isDragging, } = useSortable({ id: header.id, - disabled: !enableReordering, + disabled: !enableReordering || column.getIsResizing(), }) // -- Styles -- @@ -62,14 +72,18 @@ export function ClientTableColumnHeader({ // Pinning Styles const isPinned = column.getIsPinned() + const isSorted = column.getIsSorted() + const isFiltered = column.getFilterValue() !== undefined + const isGrouped = column.getIsGrouped() + if (isPinned === "left") { style.left = `${column.getStart("left")}px` style.position = "sticky" - style.zIndex = 20 + style.zIndex = 30 // Pinned columns needs to be higher than normal headers } else if (isPinned === "right") { style.right = `${column.getAfter("right")}px` style.position = "sticky" - style.zIndex = 20 + style.zIndex = 30 // Pinned columns needs to be higher than normal headers } // -- Handlers -- @@ -77,6 +91,7 @@ export function ClientTableColumnHeader({ const handlePinLeft = () => column.pin("left") const handlePinRight = () => column.pin("right") const handleUnpin = () => column.pin(false) + const handleToggleGrouping = () => column.toggleGrouping() // -- Content -- const content = ( @@ -100,12 +115,14 @@ export function ClientTableColumnHeader({ )} )} + {isGrouped && }
{/* Resize Handle */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} // Prevent sort trigger className={cn( "absolute right-0 top-0 h-full w-2 cursor-col-resize select-none touch-none z-10", @@ -117,6 +134,25 @@ export function ClientTableColumnHeader({ {/* Filter */} {column.getCanFilter() && } + + {/* Visual Feedback Indicators */} + {renderHeaderVisualFeedback ? ( + renderHeaderVisualFeedback({ + column, + isPinned, + isSorted, + isFiltered, + isGrouped, + }) + ) : ( + (isPinned || isFiltered || isGrouped) && ( +
+ {isPinned &&
} + {isFiltered &&
} + {isGrouped &&
} +
+ ) + )} ) @@ -141,9 +177,9 @@ export function ClientTableColumnHeader({ colSpan={header.colSpan} style={style} className={cn( - "border-b px-4 py-2 text-left text-sm font-medium bg-muted group", + "border-b px-4 py-2 text-left text-sm font-medium bg-muted group transition-colors", isDragging ? "opacity-50 bg-accent" : "", - isPinned ? "bg-muted shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "", + isPinned ? "shadow-[0_0_10px_rgba(0,0,0,0.1)]" : "", className )} {...attributes} @@ -158,6 +194,26 @@ export function ClientTableColumnHeader({ Hide Column + + {column.getCanGroup() && ( + <> + + + {isGrouped ? ( + <> + + Ungroup + + ) : ( + <> + + Group by {column.id} + + )} + + + )} + diff --git a/components/client-table/client-table-preset.tsx b/components/client-table/client-table-preset.tsx new file mode 100644 index 00000000..64930e7a --- /dev/null +++ b/components/client-table/client-table-preset.tsx @@ -0,0 +1,185 @@ +"use client"; + +import * as React from "react"; +import { Table } from "@tanstack/react-table"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Bookmark, Save, Trash2 } from "lucide-react"; +import { + getPresets, + savePreset, + deletePreset, +} from "./preset-actions"; +import { Preset } from "./preset-types"; +import { toast } from "sonner"; + +interface ClientTablePresetProps { + table: Table; + tableKey: string; +} + +export function ClientTablePreset({ + table, + tableKey, +}: ClientTablePresetProps) { + const { data: session } = useSession(); + const [savedPresets, setSavedPresets] = React.useState([]); + const [isPresetDialogOpen, setIsPresetDialogOpen] = React.useState(false); + const [newPresetName, setNewPresetName] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchSettings = React.useCallback(async () => { + const userIdVal = session?.user?.id; + if (!userIdVal) return; + + const userId = Number(userIdVal); + if (isNaN(userId)) return; + + const res = await getPresets(tableKey, userId); + if (res.success && res.data) { + setSavedPresets(res.data); + } + }, [session, tableKey]); + + React.useEffect(() => { + if (session) { + fetchSettings(); + } + }, [fetchSettings, session]); + + const handleSavePreset = async () => { + const userIdVal = session?.user?.id; + if (!newPresetName.trim() || !userIdVal) return; + const userId = Number(userIdVal); + if (isNaN(userId)) return; + + setIsLoading(true); + const state = table.getState(); + const settingToSave = { + sorting: state.sorting, + columnFilters: state.columnFilters, + globalFilter: state.globalFilter, + columnVisibility: state.columnVisibility, + columnPinning: state.columnPinning, + columnOrder: state.columnOrder, + grouping: state.grouping, + pagination: { pageSize: state.pagination.pageSize }, + }; + + const res = await savePreset(userId, tableKey, newPresetName, settingToSave); + setIsLoading(false); + + if (res.success) { + toast.success("Preset saved successfully"); + setIsPresetDialogOpen(false); + setNewPresetName(""); + fetchSettings(); + } else { + toast.error("Failed to save preset"); + } + }; + + const handleLoadPreset = (preset: Preset) => { + const s = preset.setting as Record; + if (!s) return; + + if (s.sorting) table.setSorting(s.sorting); + if (s.columnFilters) table.setColumnFilters(s.columnFilters); + if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter); + if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility); + if (s.columnPinning) table.setColumnPinning(s.columnPinning); + if (s.columnOrder) table.setColumnOrder(s.columnOrder); + if (s.grouping) table.setGrouping(s.grouping); + if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize); + + toast.success(`Preset "${preset.name}" loaded`); + }; + + const handleDeletePreset = async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (!confirm("Are you sure you want to delete this preset?")) return; + + const res = await deletePreset(id); + if (res.success) { + toast.success("Preset deleted"); + fetchSettings(); + } else { + toast.error("Failed to delete preset"); + } + }; + + if (!session) return null; + + return ( + <> + + + + + + Saved Presets + + {savedPresets.length === 0 ? ( +
No saved presets
+ ) : ( + savedPresets.map((preset) => ( + handleLoadPreset(preset)} className="flex justify-between cursor-pointer"> + {preset.name} + + + )) + )} + + setIsPresetDialogOpen(true)} className="cursor-pointer"> + + Save Current Preset + +
+
+ + + + + Save Preset + + Save the current table configuration as a preset. + + +
+ setNewPresetName(e.target.value)} + /> +
+ + + + +
+
+ + ); +} diff --git a/components/client-table/client-table-save-view.tsx b/components/client-table/client-table-save-view.tsx new file mode 100644 index 00000000..73935d00 --- /dev/null +++ b/components/client-table/client-table-save-view.tsx @@ -0,0 +1,185 @@ +"use client"; + +import * as React from "react"; +import { Table } from "@tanstack/react-table"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Bookmark, Save, Trash2 } from "lucide-react"; +import { + getUserCustomSettings, + saveUserCustomSetting, + deleteUserCustomSetting, +} from "@/actions/user-custom-data"; +import { toast } from "sonner"; + +interface ClientTableSaveViewProps { + table: Table; + tableKey: string; +} + +export function ClientTableSaveView({ + table, + tableKey, +}: ClientTableSaveViewProps) { + const { data: session } = useSession(); + const [savedViews, setSavedViews] = React.useState<{ id: string; customSettingName: string; customSetting: Record }[]>([]); + const [isSaveDialogOpen, setIsSaveDialogOpen] = React.useState(false); + const [newViewName, setNewViewName] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + + const fetchSettings = React.useCallback(async () => { + const userIdVal = session?.user?.id; + if (!userIdVal) return; + + const userId = Number(userIdVal); + if (isNaN(userId)) return; + + const res = await getUserCustomSettings(tableKey, userId); + if (res.success && res.data) { + // @ts-ignore - data from DB might need casting + setSavedViews(res.data); + } + }, [session, tableKey]); + + React.useEffect(() => { + if (session) { + fetchSettings(); + } + }, [fetchSettings, session]); + + const handleSaveView = async () => { + const userIdVal = session?.user?.id; + if (!newViewName.trim() || !userIdVal) return; + const userId = Number(userIdVal); + if (isNaN(userId)) return; + + setIsLoading(true); + const state = table.getState(); + const settingToSave = { + sorting: state.sorting, + columnFilters: state.columnFilters, + globalFilter: state.globalFilter, + columnVisibility: state.columnVisibility, + columnPinning: state.columnPinning, + columnOrder: state.columnOrder, + grouping: state.grouping, + pagination: { pageSize: state.pagination.pageSize }, + }; + + const res = await saveUserCustomSetting(userId, tableKey, newViewName, settingToSave); + setIsLoading(false); + + if (res.success) { + toast.success("View saved successfully"); + setIsSaveDialogOpen(false); + setNewViewName(""); + fetchSettings(); + } else { + toast.error("Failed to save view"); + } + }; + + const handleLoadView = (setting: { customSetting: Record | unknown; customSettingName: string }) => { + const s = setting.customSetting as Record; + if (!s) return; + + if (s.sorting) table.setSorting(s.sorting); + if (s.columnFilters) table.setColumnFilters(s.columnFilters); + if (s.globalFilter !== undefined) table.setGlobalFilter(s.globalFilter); + if (s.columnVisibility) table.setColumnVisibility(s.columnVisibility); + if (s.columnPinning) table.setColumnPinning(s.columnPinning); + if (s.columnOrder) table.setColumnOrder(s.columnOrder); + if (s.grouping) table.setGrouping(s.grouping); + if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize); + + toast.success(`View "${setting.customSettingName}" loaded`); + }; + + const handleDeleteView = async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + if (!confirm("Are you sure you want to delete this view?")) return; + + const res = await deleteUserCustomSetting(id); + if (res.success) { + toast.success("View deleted"); + fetchSettings(); + } else { + toast.error("Failed to delete view"); + } + }; + + if (!session) return null; + + return ( + <> + + + + + + Saved Views + + {savedViews.length === 0 ? ( +
No saved views
+ ) : ( + savedViews.map((view) => ( + handleLoadView(view)} className="flex justify-between cursor-pointer"> + {view.customSettingName} + + + )) + )} + + setIsSaveDialogOpen(true)} className="cursor-pointer"> + + Save Current View + +
+
+ + + + + Save View + + Save the current table configuration as a preset. + + +
+ setNewViewName(e.target.value)} + /> +
+ + + + +
+
+ + ); +} diff --git a/components/client-table/client-table-toolbar.tsx b/components/client-table/client-table-toolbar.tsx index 43b0a032..089501e1 100644 --- a/components/client-table/client-table-toolbar.tsx +++ b/components/client-table/client-table-toolbar.tsx @@ -12,6 +12,8 @@ interface ClientTableToolbarProps { visibleRows: number onExport?: () => void actions?: React.ReactNode + customToolbar?: React.ReactNode + viewOptions?: React.ReactNode } export function ClientTableToolbar({ @@ -21,11 +23,13 @@ export function ClientTableToolbar({ visibleRows, onExport, actions, + customToolbar, + viewOptions, }: ClientTableToolbarProps) { return ( -
-
-
+
+
+
Showing {visibleRows} of {totalRows}
-
- -
- {actions} + {viewOptions} {onExport && ( - )} + )} +
+ +
+ {customToolbar} + {actions}
) } - diff --git a/components/client-table/client-table-view-options.tsx b/components/client-table/client-table-view-options.tsx index b65049b4..3b659fcd 100644 --- a/components/client-table/client-table-view-options.tsx +++ b/components/client-table/client-table-view-options.tsx @@ -42,14 +42,21 @@ export function ClientTableViewOptions({ typeof column.accessorFn !== "undefined" && column.getCanHide() ) .map((column) => { + const header = column.columnDef.header + let label = column.id + if (typeof header === "string") { + label = header + } + return ( column.toggleVisibility(!!value)} + onSelect={(e) => e.preventDefault()} // default action close the select menu. > - {column.id} + {label} ) })} diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx index 4825741f..507057c7 100644 --- a/components/client-table/client-virtual-table.tsx +++ b/components/client-table/client-virtual-table.tsx @@ -8,6 +8,11 @@ import { getSortedRowModel, getFilteredRowModel, getPaginationRowModel, + getGroupedRowModel, + getExpandedRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFacetedMinMaxValues, ColumnDef, SortingState, ColumnFiltersState, @@ -21,6 +26,9 @@ import { Table, RowSelectionState, Row, + Column, + GroupingState, + ExpandedState, } from "@tanstack/react-table" import { useVirtualizer } from "@tanstack/react-virtual" import { @@ -38,23 +46,41 @@ import { horizontalListSortingStrategy, } from "@dnd-kit/sortable" import { cn } from "@/lib/utils" +import { Loader2, ChevronRight, ChevronDown } from "lucide-react" -import { ClientTableToolbar } from "./client-table-toolbar" -import { exportToExcel } from "./export-utils" +import { ClientTableToolbar } from "../client-table/client-table-toolbar" +import { exportToExcel } from "../client-table/export-utils" import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination" import { ClientTableColumnHeader } from "./client-table-column-header" -import { ClientTableViewOptions } from "./client-table-view-options" +import { ClientTableViewOptions } from "../client-table/client-table-view-options" +import { ClientTablePreset } from "./client-table-preset" + +// Moved outside for stability (Performance Optimization) +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value) + addMeta({ itemRank }) + return itemRank.passed +} export interface ClientVirtualTableProps { data: TData[] columns: ColumnDef[] height?: string | number + estimateRowHeight?: number className?: string actions?: React.ReactNode + customToolbar?: React.ReactNode enableExport?: boolean onExport?: (data: TData[]) => void - - // Pagination Props + isLoading?: boolean + + // --- User Preset Saving --- + enableUserPreset?: boolean + tableKey?: string + + // --- State Control (Controlled or Uncontrolled) --- + + // Pagination enablePagination?: boolean manualPagination?: boolean pageCount?: number @@ -62,88 +88,184 @@ export interface ClientVirtualTableProps { pagination?: PaginationState onPaginationChange?: OnChangeFn - // Style Props - getRowClassName?: (originalRow: TData, index: number) => string + // Sorting + sorting?: SortingState + onSortingChange?: OnChangeFn - // Table Meta - meta?: any - - // Row ID - getRowId?: (originalRow: TData, index: number, parent?: any) => string + // Filtering + columnFilters?: ColumnFiltersState + onColumnFiltersChange?: OnChangeFn + globalFilter?: string + onGlobalFilterChange?: OnChangeFn + + // Visibility + columnVisibility?: VisibilityState + onColumnVisibilityChange?: OnChangeFn + + // Pinning + columnPinning?: ColumnPinningState + onColumnPinningChange?: OnChangeFn - // Selection Props + // Order + columnOrder?: ColumnOrderState + onColumnOrderChange?: OnChangeFn + + // Selection enableRowSelection?: boolean | ((row: Row) => boolean) enableMultiRowSelection?: boolean | ((row: Row) => boolean) rowSelection?: RowSelectionState onRowSelectionChange?: OnChangeFn + + // Grouping + enableGrouping?: boolean + grouping?: GroupingState + onGroupingChange?: OnChangeFn + expanded?: ExpandedState + onExpandedChange?: OnChangeFn + + // --- Event Handlers --- + onRowClick?: (row: Row, event: React.MouseEvent) => void + + // --- Styling --- + getRowClassName?: (originalRow: TData, index: number) => string + + // --- Advanced --- + meta?: Record + getRowId?: (originalRow: TData, index: number, parent?: any) => string + + // Custom Header Visual Feedback + renderHeaderVisualFeedback?: (props: { + column: Column + isPinned: boolean | string + isSorted: boolean | string + isFiltered: boolean + isGrouped: boolean + }) => React.ReactNode } function ClientVirtualTableInner( { data, columns, - height = "500px", // Default height + height = "100%", + estimateRowHeight = 40, className, actions, + customToolbar, enableExport = true, onExport, - - // Pagination defaults + isLoading = false, + + // User Preset Saving + enableUserPreset = false, + tableKey, + + // Pagination enablePagination = false, manualPagination = false, pageCount, rowCount, - pagination: controlledPagination, + pagination: propPagination, onPaginationChange, + // Sorting + sorting: propSorting, + onSortingChange, + + // Filtering + columnFilters: propColumnFilters, + onColumnFiltersChange, + globalFilter: propGlobalFilter, + onGlobalFilterChange, + + // Visibility + columnVisibility: propColumnVisibility, + onColumnVisibilityChange, + + // Pinning + columnPinning: propColumnPinning, + onColumnPinningChange, + + // Order + columnOrder: propColumnOrder, + onColumnOrderChange, + + // Selection + enableRowSelection, + enableMultiRowSelection, + rowSelection: propRowSelection, + onRowSelectionChange, + + // Grouping + enableGrouping = false, + grouping: propGrouping, + onGroupingChange, + expanded: propExpanded, + onExpandedChange, + // Style defaults getRowClassName, - + // Meta & RowID meta, getRowId, - // Selection defaults - enableRowSelection, - enableMultiRowSelection, - rowSelection: controlledRowSelection, - onRowSelectionChange, + // Event Handlers + onRowClick, + + // Custom Header Visual Feedback + renderHeaderVisualFeedback, }: ClientVirtualTableProps, ref: React.Ref> ) { - // State - const [sorting, setSorting] = React.useState([]) - const [columnFilters, setColumnFilters] = React.useState([]) - const [globalFilter, setGlobalFilter] = React.useState("") - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnPinning, setColumnPinning] = React.useState({ left: [], right: [] }) - const [columnOrder, setColumnOrder] = React.useState( + // Internal States (used when props are undefined) + const [internalSorting, setInternalSorting] = React.useState([]) + const [internalColumnFilters, setInternalColumnFilters] = React.useState([]) + const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("") + const [internalColumnVisibility, setInternalColumnVisibility] = React.useState({}) + const [internalColumnPinning, setInternalColumnPinning] = React.useState({ left: [], right: [] }) + const [internalColumnOrder, setInternalColumnOrder] = React.useState( () => columns.map((c) => c.id || (c as any).accessorKey) as string[] ) - - // Internal Pagination State const [internalPagination, setInternalPagination] = React.useState({ pageIndex: 0, - pageSize: 50, + pageSize: 10, }) - - // Internal Row Selection State const [internalRowSelection, setInternalRowSelection] = React.useState({}) + const [internalGrouping, setInternalGrouping] = React.useState([]) + const [internalExpanded, setInternalExpanded] = React.useState({}) - // Fuzzy Filter - const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { - const itemRank = rankItem(row.getValue(columnId), value) - addMeta({ itemRank }) - return itemRank.passed - } + // Effective States + const sorting = propSorting ?? internalSorting + const setSorting = onSortingChange ?? setInternalSorting + + const columnFilters = propColumnFilters ?? internalColumnFilters + const setColumnFilters = onColumnFiltersChange ?? setInternalColumnFilters + + const globalFilter = propGlobalFilter ?? internalGlobalFilter + const setGlobalFilter = onGlobalFilterChange ?? setInternalGlobalFilter + + const columnVisibility = propColumnVisibility ?? internalColumnVisibility + const setColumnVisibility = onColumnVisibilityChange ?? setInternalColumnVisibility - // Combine controlled and uncontrolled states - const pagination = controlledPagination ?? internalPagination + const columnPinning = propColumnPinning ?? internalColumnPinning + const setColumnPinning = onColumnPinningChange ?? setInternalColumnPinning + + const columnOrder = propColumnOrder ?? internalColumnOrder + const setColumnOrder = onColumnOrderChange ?? setInternalColumnOrder + + const pagination = propPagination ?? internalPagination const setPagination = onPaginationChange ?? setInternalPagination - - const rowSelection = controlledRowSelection ?? internalRowSelection + + const rowSelection = propRowSelection ?? internalRowSelection const setRowSelection = onRowSelectionChange ?? setInternalRowSelection + const grouping = propGrouping ?? internalGrouping + const setGrouping = onGroupingChange ?? setInternalGrouping + + const expanded = propExpanded ?? internalExpanded + const setExpanded = onExpandedChange ?? setInternalExpanded + // Table Instance const table = useReactTable({ data, @@ -157,6 +279,8 @@ function ClientVirtualTableInner( columnPinning, columnOrder, rowSelection, + grouping, + expanded, }, manualPagination, pageCount: manualPagination ? pageCount : undefined, @@ -169,11 +293,28 @@ function ClientVirtualTableInner( onColumnPinningChange: setColumnPinning, onColumnOrderChange: setColumnOrder, onRowSelectionChange: setRowSelection, + onGroupingChange: setGrouping, + onExpandedChange: setExpanded, enableRowSelection, enableMultiRowSelection, + enableGrouping, getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), + + // Systematic Order of Operations: + // 1. Filtering (Rows are filtered first) getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + getFacetedMinMaxValues: getFacetedMinMaxValues(), + + // 2. Sorting (Filtered rows are then sorted) + getSortedRowModel: getSortedRowModel(), + + // 3. Grouping (Sorted rows are grouped) + getGroupedRowModel: enableGrouping ? getGroupedRowModel() : undefined, + getExpandedRowModel: enableGrouping ? getExpandedRowModel() : undefined, + + // 4. Pagination (Final rows are paginated) getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined, columnResizeMode: "onChange", filterFns: { @@ -191,7 +332,7 @@ function ClientVirtualTableInner( const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 8, // 8px movement required to start drag + distance: 8, }, }), useSensor(KeyboardSensor) @@ -201,11 +342,29 @@ function ClientVirtualTableInner( const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event if (active && over && active.id !== over.id) { - setColumnOrder((items) => { - const oldIndex = items.indexOf(active.id as string) - const newIndex = items.indexOf(over.id as string) - return arrayMove(items, oldIndex, newIndex) - }) + const activeId = active.id as string + const overId = over.id as string + + const activeColumn = table.getColumn(activeId) + const overColumn = table.getColumn(overId) + + if (activeColumn && overColumn) { + const activePinState = activeColumn.getIsPinned() + const overPinState = overColumn.getIsPinned() + + // If dragging between different pin states, update the pin state of the active column + if (activePinState !== overPinState) { + activeColumn.pin(overPinState) + } + + // Reorder the columns + setColumnOrder((items) => { + const currentItems = Array.isArray(items) ? items : [] + const oldIndex = items.indexOf(activeId) + const newIndex = items.indexOf(overId) + return arrayMove(items, oldIndex, newIndex) + }) + } } } @@ -216,7 +375,7 @@ function ClientVirtualTableInner( const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, - estimateSize: () => 40, // Estimated row height + estimateSize: () => estimateRowHeight, overscan: 10, }) @@ -236,30 +395,42 @@ function ClientVirtualTableInner( return } const currentData = table.getFilteredRowModel().rows.map((row) => row.original) - await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0,10)}.xlsx`) + await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0, 10)}.xlsx`) } return ( -
+
- {actions} + {enableUserPreset && tableKey && ( + + )} } + customToolbar={customToolbar} + actions={actions} />
+ {isLoading && ( +
+ +
+ )} + ( className="table-fixed border-collapse w-full min-w-full" style={{ width: table.getTotalSize() }} > - + {table.getHeaderGroups().map((headerGroup) => ( h.id)} strategy={horizontalListSortingStrategy} > {headerGroup.headers.map((header) => ( @@ -281,6 +452,7 @@ function ClientVirtualTableInner( key={header.id} header={header} enableReordering={true} + renderHeaderVisualFeedback={renderHeaderVisualFeedback} /> ))} @@ -293,51 +465,126 @@ function ClientVirtualTableInner( )} - {virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index] - return ( - - {row.getVisibleCells().map((cell) => { - // Handle pinned cells - const isPinned = cell.column.getIsPinned() - const style: React.CSSProperties = { - width: cell.column.getSize(), - } - if (isPinned === "left") { - style.position = "sticky" - style.left = `${cell.column.getStart("left")}px` - style.zIndex = 10 - style.backgroundColor = "var(--background)" // Ensure opacity - } else if (isPinned === "right") { - style.position = "sticky" - style.right = `${cell.column.getAfter("right")}px` - style.zIndex = 10 - style.backgroundColor = "var(--background)" - } - - return ( + {virtualRows.length === 0 && !isLoading ? ( + + + No results. + + + ) : ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] + + // --- Group Header Rendering --- + if (row.getIsGrouped()) { + const groupingColumnId = row.groupingColumnId ?? ""; + const groupingValue = row.getGroupingValue(groupingColumnId); + + return ( + - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} +
+ {row.getIsExpanded() ? ( + + ) : ( + + )} + + + {groupingColumnId}: + + + {String(groupingValue)} + + + ({row.subRows.length}) + + +
- ) - })} - - ) - })} + + ) + } + + // --- Normal Row Rendering --- + return ( + onRowClick?.(row, e)} + > + {row.getVisibleCells().map((cell) => { + // Handle pinned cells + const isPinned = cell.column.getIsPinned() + const isGrouped = cell.column.getIsGrouped() + + const style: React.CSSProperties = { + width: cell.column.getSize(), + } + if (isPinned === "left") { + style.position = "sticky" + style.left = `${cell.column.getStart("left")}px` + style.zIndex = 20 + } else if (isPinned === "right") { + style.position = "sticky" + style.right = `${cell.column.getAfter("right")}px` + style.zIndex = 20 + } + + return ( + + {cell.getIsGrouped() ? ( + // If this cell is grouped, usually we don't render it here if we have a group header row, + // but if we keep it, it acts as the expander for the next level (if multi-level grouping). + // Since we used a full-width row for the group header, this branch might not be hit for the group row itself, + // but for nested groups it might? + // Wait, row.getIsGrouped() is true for the group row. + // The cells inside the group row are not rendered because we return early above. + // The cells inside the "leaf" rows (normal rows) are rendered here. + // So cell.getIsGrouped() checks if the COLUMN is currently grouped. + // If the column is grouped, the cell value is usually redundant or hidden in normal rows. + // Standard practice: hide the cell content or dim it. + null + ) : cell.getIsAggregated() ? ( + // If this cell is an aggregation of the group + flexRender( + cell.column.columnDef.aggregatedCell ?? + cell.column.columnDef.cell, + cell.getContext() + ) + ) : ( + // Normal cell + cell.getIsPlaceholder() + ? null + : flexRender(cell.column.columnDef.cell, cell.getContext()) + )} + + ) + })} + + ) + }) + )} {paddingBottom > 0 && ( @@ -347,9 +594,9 @@ function ClientVirtualTableInner(
- + {enablePagination && ( - + )}
) diff --git a/components/client-table/preset-actions.ts b/components/client-table/preset-actions.ts new file mode 100644 index 00000000..0b8b3adb --- /dev/null +++ b/components/client-table/preset-actions.ts @@ -0,0 +1,87 @@ +"use server"; + +import db from "@/db/db"; +import { userCustomData } from "@/db/schema/user-custom-data/userCustomData"; +import { eq, and } from "drizzle-orm"; +import { Preset, PresetRepository } from "./preset-types"; + +// Drizzle Implementation of PresetRepository +// This file acts as the concrete repository implementation. +// To swap DBs, you would replace the logic here or create a new implementation file. + +export async function getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }> { + try { + const settings = await db + .select() + .from(userCustomData) + .where( + and( + eq(userCustomData.tableKey, tableKey), + eq(userCustomData.userId, userId) + ) + ) + .orderBy(userCustomData.createdDate); + + // Map DB entity to domain model + const data: Preset[] = settings.map(s => ({ + id: s.id, + name: s.customSettingName, + setting: s.customSetting, + createdAt: s.createdDate, + updatedAt: s.updatedDate, + })); + + return { success: true, data }; + } catch (error) { + console.error("Failed to fetch presets:", error); + return { success: false, error: "Failed to fetch presets" }; + } +} + +export async function savePreset( + userId: number, + tableKey: string, + name: string, + setting: any +): Promise<{ success: boolean; error?: string }> { + try { + const existing = await db.query.userCustomData.findFirst({ + where: and( + eq(userCustomData.userId, userId), + eq(userCustomData.tableKey, tableKey), + eq(userCustomData.customSettingName, name) + ) + }); + + if (existing) { + await db.update(userCustomData) + .set({ + customSetting: setting, + updatedDate: new Date() + }) + .where(eq(userCustomData.id, existing.id)); + } else { + await db.insert(userCustomData).values({ + userId, + tableKey, + customSettingName: name, + customSetting: setting, + }); + } + + return { success: true }; + } catch (error) { + console.error("Failed to save preset:", error); + return { success: false, error: "Failed to save preset" }; + } +} + +export async function deletePreset(id: string): Promise<{ success: boolean; error?: string }> { + try { + await db.delete(userCustomData).where(eq(userCustomData.id, id)); + return { success: true }; + } catch (error) { + console.error("Failed to delete preset:", error); + return { success: false, error: "Failed to delete preset" }; + } +} diff --git a/components/client-table/preset-types.ts b/components/client-table/preset-types.ts new file mode 100644 index 00000000..072d918b --- /dev/null +++ b/components/client-table/preset-types.ts @@ -0,0 +1,13 @@ +export interface Preset { + id: string; + name: string; + setting: any; // JSON object for table state + createdAt: Date; + updatedAt: Date; +} + +export interface PresetRepository { + getPresets(tableKey: string, userId: number): Promise<{ success: boolean; data?: Preset[]; error?: string }>; + savePreset(userId: number, tableKey: string, name: string, setting: any): Promise<{ success: boolean; error?: string }>; + deletePreset(id: string): Promise<{ success: boolean; error?: string }>; +} diff --git a/db/schema/index.ts b/db/schema/index.ts index cd54e032..fdc25afc 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -50,6 +50,8 @@ export * from './permissions'; export * from './fileSystem'; +export * from './user-custom-data/userCustomData'; + // 부서별 도메인 할당 관리 export * from './departmentDomainAssignments'; diff --git a/db/schema/user-custom-data/userCustomData.ts b/db/schema/user-custom-data/userCustomData.ts new file mode 100644 index 00000000..bf529679 --- /dev/null +++ b/db/schema/user-custom-data/userCustomData.ts @@ -0,0 +1,16 @@ +/** + * user custom data + * + * */ +import { integer, json, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; +import { users } from "../users"; + +export const userCustomData = pgTable("user_custom_data", { + id: uuid("id").primaryKey().defaultRandom(), + userId: integer("user_id").references(() => users.id), + tableKey: varchar("table_key", { length: 255 }).notNull(), + customSettingName: varchar("custom_setting_name", { length: 255 }).notNull(), + customSetting: json("custom_setting"), + createdDate: timestamp("created_date", { withTimezone: true }).defaultNow().notNull(), + updatedDate: timestamp("updated_date", { withTimezone: true }).defaultNow().notNull(), +}); -- cgit v1.2.3