diff options
| -rw-r--r-- | app/[lng]/evcp/poa/page.tsx | 61 | ||||
| -rw-r--r-- | config/poaColumnsConfig.ts | 131 | ||||
| -rw-r--r-- | db/migrations/0097_poa_initial_setup.sql | 95 | ||||
| -rw-r--r-- | db/schema/contract.ts | 104 | ||||
| -rw-r--r-- | db/seeds_2/poaSeed.ts | 109 | ||||
| -rw-r--r-- | lib/poa/service.ts | 132 | ||||
| -rw-r--r-- | lib/poa/table/poa-table-columns.tsx | 165 | ||||
| -rw-r--r-- | lib/poa/table/poa-table-toolbar-actions.tsx | 45 | ||||
| -rw-r--r-- | lib/poa/table/poa-table.tsx | 189 | ||||
| -rw-r--r-- | lib/poa/validations.ts | 66 |
10 files changed, 1096 insertions, 1 deletions
diff --git a/app/[lng]/evcp/poa/page.tsx b/app/[lng]/evcp/poa/page.tsx new file mode 100644 index 00000000..dec5e05b --- /dev/null +++ b/app/[lng]/evcp/poa/page.tsx @@ -0,0 +1,61 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" + +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getChangeOrders } from "@/lib/poa/service" +import { searchParamsCache } from "@/lib/poa/validations" +import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getChangeOrders({ + ...search, + filters: validFilters, + }), + ]) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + 변경 PO 확인 및 전자서명 + </h2> + <p className="text-muted-foreground"> + 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + </React.Suspense> + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <ChangeOrderListsTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/config/poaColumnsConfig.ts b/config/poaColumnsConfig.ts new file mode 100644 index 00000000..268a2259 --- /dev/null +++ b/config/poaColumnsConfig.ts @@ -0,0 +1,131 @@ +import { POADetail } from "@/db/schema/contract" + +export interface PoaColumnConfig { + id: keyof POADetail + label: string + group?: string + excelHeader?: string + type?: string +} + +export const poaColumnsConfig: PoaColumnConfig[] = [ + { + id: "id", + label: "ID", + excelHeader: "ID", + group: "Key Info", + type: "number", + }, + { + id: "projectId", + label: "Project ID", + excelHeader: "Project ID", + group: "Key Info", + type: "number", + }, + { + id: "vendorId", + label: "Vendor ID", + excelHeader: "Vendor ID", + group: "Key Info", + type: "number", + }, + { + id: "contractNo", + label: "Form Code", + excelHeader: "Form Code", + group: "Original Info", + type: "text", + }, + { + id: "originalContractName", + label: "Contract Name", + excelHeader: "Contract Name", + group: "Original Info", + type: "text", + }, + { + id: "originalStatus", + label: "Status", + excelHeader: "Status", + group: "Original Info", + type: "text", + }, + { + id: "deliveryTerms", + label: "Delivery Terms", + excelHeader: "Delivery Terms", + group: "Change Info", + type: "text", + }, + { + id: "deliveryDate", + label: "Delivery Date", + excelHeader: "Delivery Date", + group: "Change Info", + type: "date", + }, + { + id: "deliveryLocation", + label: "Delivery Location", + excelHeader: "Delivery Location", + group: "Change Info", + type: "text", + }, + { + id: "currency", + label: "Currency", + excelHeader: "Currency", + group: "Change Info", + type: "text", + }, + { + id: "totalAmount", + label: "Total Amount", + excelHeader: "Total Amount", + group: "Change Info", + type: "number", + }, + { + id: "discount", + label: "Discount", + excelHeader: "Discount", + group: "Change Info", + type: "number", + }, + { + id: "tax", + label: "Tax", + excelHeader: "Tax", + group: "Change Info", + type: "number", + }, + { + id: "shippingFee", + label: "Shipping Fee", + excelHeader: "Shipping Fee", + group: "Change Info", + type: "number", + }, + { + id: "netTotal", + label: "Net Total", + excelHeader: "Net Total", + group: "Change Info", + type: "number", + }, + { + id: "createdAt", + label: "Created At", + excelHeader: "Created At", + group: "System Info", + type: "date", + }, + { + id: "updatedAt", + label: "Updated At", + excelHeader: "Updated At", + group: "System Info", + type: "date", + }, +]
\ No newline at end of file diff --git a/db/migrations/0097_poa_initial_setup.sql b/db/migrations/0097_poa_initial_setup.sql new file mode 100644 index 00000000..fae3f4d1 --- /dev/null +++ b/db/migrations/0097_poa_initial_setup.sql @@ -0,0 +1,95 @@ +-- Drop existing tables and views +DROP VIEW IF EXISTS change_orders_detail_view; +DROP TABLE IF EXISTS change_order_items CASCADE; +DROP TABLE IF EXISTS change_orders CASCADE; +DROP VIEW IF EXISTS poa_detail_view; +DROP TABLE IF EXISTS poa CASCADE; + +-- Create POA table +CREATE TABLE poa ( + id SERIAL PRIMARY KEY, + contract_no VARCHAR(100) NOT NULL, + original_contract_no VARCHAR(100) NOT NULL, + project_id INTEGER NOT NULL, + vendor_id INTEGER NOT NULL, + original_contract_name VARCHAR(255) NOT NULL, + original_status VARCHAR(50) NOT NULL, + delivery_terms TEXT, + delivery_date DATE, + delivery_location VARCHAR(255), + currency VARCHAR(10), + total_amount NUMERIC(12,2), + discount NUMERIC(12,2), + tax NUMERIC(12,2), + shipping_fee NUMERIC(12,2), + net_total NUMERIC(12,2), + change_reason TEXT, + approval_status VARCHAR(50) DEFAULT 'PENDING', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + CONSTRAINT poa_original_contract_no_contracts_contract_no_fk + FOREIGN KEY (original_contract_no) + REFERENCES contracts(contract_no) + ON DELETE CASCADE, + CONSTRAINT poa_project_id_projects_id_fk + FOREIGN KEY (project_id) + REFERENCES projects(id) + ON DELETE CASCADE, + CONSTRAINT poa_vendor_id_vendors_id_fk + FOREIGN KEY (vendor_id) + REFERENCES vendors(id) + ON DELETE CASCADE +); + +-- Create POA detail view +CREATE VIEW poa_detail_view AS +SELECT + -- POA primary information + poa.id, + poa.contract_no, + poa.change_reason, + poa.approval_status, + + -- Original PO information + poa.original_contract_no, + poa.original_contract_name, + poa.original_status, + c.start_date as original_start_date, + c.end_date as original_end_date, + + -- Project information + poa.project_id, + p.code as project_code, + p.name as project_name, + + -- Vendor information + poa.vendor_id, + v.vendor_name, + + -- Changed delivery details + poa.delivery_terms, + poa.delivery_date, + poa.delivery_location, + + -- Changed financial information + poa.currency, + poa.total_amount, + poa.discount, + poa.tax, + poa.shipping_fee, + poa.net_total, + + -- Timestamps + poa.created_at, + poa.updated_at, + + -- Electronic signature status + EXISTS ( + SELECT 1 + FROM contract_envelopes + WHERE contract_envelopes.contract_id = poa.id + ) as has_signature +FROM poa +LEFT JOIN contracts c ON poa.original_contract_no = c.contract_no +LEFT JOIN projects p ON poa.project_id = p.id +LEFT JOIN vendors v ON poa.vendor_id = v.id;
\ No newline at end of file diff --git a/db/schema/contract.ts b/db/schema/contract.ts index 10721b4d..c14921bb 100644 --- a/db/schema/contract.ts +++ b/db/schema/contract.ts @@ -257,4 +257,106 @@ export const contractsDetailView = pgView("contracts_detail_view").as((qb) => { }); // Type inference for the view -export type ContractDetail = typeof contractsDetailView.$inferSelect;
\ No newline at end of file +export type ContractDetail = typeof contractsDetailView.$inferSelect; + + + + +// ============ poa (Purchase Order Amendment) ============ +export const poa = pgTable("poa", { + // 주 키 + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // Form code는 원본과 동일하게 유지 + contractNo: varchar("contract_no", { length: 100 }).notNull(), + + // 원본 PO 참조 + originalContractNo: varchar("original_contract_no", { length: 100 }) + .notNull() + .references(() => contracts.contractNo, { onDelete: "cascade" }), + + // 원본 계약 정보 + projectId: integer("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + vendorId: integer("vendor_id") + .notNull() + .references(() => vendors.id, { onDelete: "cascade" }), + originalContractName: varchar("original_contract_name", { length: 255 }).notNull(), + originalStatus: varchar("original_status", { length: 50 }).notNull(), + + // 변경된 납품 조건 + deliveryTerms: text("delivery_terms"), // 변경된 납품 조건 + deliveryDate: date("delivery_date"), // 변경된 납품 기한 + deliveryLocation: varchar("delivery_location", { length: 255 }), // 변경된 납품 장소 + + // 변경된 가격/금액 관련 + currency: varchar("currency", { length: 10 }), // 변경된 통화 + totalAmount: numeric("total_amount", { precision: 12, scale: 2 }), // 변경된 총 금액 + discount: numeric("discount", { precision: 12, scale: 2 }), // 변경된 할인 + tax: numeric("tax", { precision: 12, scale: 2 }), // 변경된 세금 + shippingFee: numeric("shipping_fee", { precision: 12, scale: 2 }), // 변경된 배송비 + netTotal: numeric("net_total", { precision: 12, scale: 2 }), // 변경된 순 총액 + + // 변경 사유 + changeReason: text("change_reason"), + + // 승인 상태 + approvalStatus: varchar("approval_status", { length: 50 }).default("PENDING"), + + // 생성/수정 시각 + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}) + +// 타입 추론 +export type POA = typeof poa.$inferSelect + +// ============ poa_detail_view ============ +export const poaDetailView = pgView("poa_detail_view").as((qb) => { + return qb + .select({ + // POA primary information + id: poa.id, + contractNo: poa.contractNo, + projectId: contracts.projectId, + vendorId: contracts.vendorId, + changeReason: poa.changeReason, + approvalStatus: poa.approvalStatus, + + // Original PO information + originalContractName: sql<string>`${contracts.contractName}`.as('original_contract_name'), + originalStatus: sql<string>`${contracts.status}`.as('original_status'), + originalStartDate: sql<Date>`${contracts.startDate}`.as('original_start_date'), + originalEndDate: sql<Date>`${contracts.endDate}`.as('original_end_date'), + + // Changed delivery details + deliveryTerms: poa.deliveryTerms, + deliveryDate: poa.deliveryDate, + deliveryLocation: poa.deliveryLocation, + + // Changed financial information + currency: poa.currency, + totalAmount: poa.totalAmount, + discount: poa.discount, + tax: poa.tax, + shippingFee: poa.shippingFee, + netTotal: poa.netTotal, + + // Timestamps + createdAt: poa.createdAt, + updatedAt: poa.updatedAt, + + // Electronic signature status + hasSignature: sql<boolean>`EXISTS ( + SELECT 1 + FROM ${contractEnvelopes} + WHERE ${contractEnvelopes.contractId} = ${poa.id} + )`.as('has_signature'), + }) + .from(poa) + .leftJoin(contracts, eq(poa.contractNo, contracts.contractNo)) +}); + +// Type inference for the view +export type POADetail = typeof poaDetailView.$inferSelect;
\ No newline at end of file diff --git a/db/seeds_2/poaSeed.ts b/db/seeds_2/poaSeed.ts new file mode 100644 index 00000000..d93cde14 --- /dev/null +++ b/db/seeds_2/poaSeed.ts @@ -0,0 +1,109 @@ +import { faker } from "@faker-js/faker" +import db from "../db" +import { contracts, poa } from "../schema/contract" +import { sql } from "drizzle-orm" + +export async function seedPOA({ count = 10 } = {}) { + try { + console.log(`📝 Inserting POA ${count}`) + + // 기존 POA 데이터 삭제 및 ID 시퀀스 초기화 + await db.delete(poa) + await db.execute(sql`ALTER SEQUENCE poa_id_seq RESTART WITH 1;`) + console.log("✅ 기존 POA 데이터 삭제 및 ID 초기화 완료") + + // 조선업 맥락에 맞는 예시 문구들 + const deliveryTermsExamples = [ + "FOB 부산항", + "CIF 상하이항", + "DAP 울산조선소", + "DDP 거제 옥포조선소", + ] + const deliveryLocations = [ + "부산 영도조선소", + "울산 본사 도크 #3", + "거제 옥포조선소 해양공장", + "목포신항 부두", + ] + const changeReasonExamples = [ + "납품 일정 조정 필요", + "자재 사양 변경", + "선박 설계 변경에 따른 수정", + "추가 부품 요청", + "납품 장소 변경", + "계약 조건 재협상" + ] + + // 1. 기존 계약(PO) 목록 가져오기 + const existingContracts = await db.select().from(contracts) + console.log(`Found ${existingContracts.length} existing contracts`) + + if (existingContracts.length === 0) { + throw new Error("계약(PO) 데이터가 없습니다. 먼저 계약 데이터를 생성해주세요.") + } + + // 2. POA 생성 + for (let i = 0; i < count; i++) { + try { + // 랜덤으로 원본 계약 선택 + const originalContract = faker.helpers.arrayElement(existingContracts) + console.log(`Selected original contract: ${originalContract.contractNo}`) + + // POA 생성 + const totalAmount = faker.number.float({ min: 5000000, max: 500000000 }) + const discount = faker.helpers.maybe(() => faker.number.float({ min: 0, max: 500000 }), { probability: 0.3 }) + const tax = faker.helpers.maybe(() => faker.number.float({ min: 0, max: 1000000 }), { probability: 0.8 }) + const shippingFee = faker.helpers.maybe(() => faker.number.float({ min: 0, max: 300000 }), { probability: 0.5 }) + const netTotal = totalAmount - (discount || 0) + (tax || 0) + (shippingFee || 0) + + const poaData = { + // Form code는 원본과 동일하게 유지 + contractNo: originalContract.contractNo, + originalContractNo: originalContract.contractNo, + projectId: originalContract.projectId, + vendorId: originalContract.vendorId, + originalContractName: originalContract.contractName, + originalStatus: originalContract.status, + + // 변경 가능한 정보들 + deliveryTerms: faker.helpers.arrayElement(deliveryTermsExamples), + deliveryDate: faker.helpers.maybe(() => faker.date.future().toISOString(), { probability: 0.7 }), + deliveryLocation: faker.helpers.arrayElement(deliveryLocations), + currency: "KRW", + totalAmount: totalAmount.toString(), + discount: discount?.toString(), + tax: tax?.toString(), + shippingFee: shippingFee?.toString(), + netTotal: netTotal.toString(), + changeReason: faker.helpers.arrayElement(changeReasonExamples), + approvalStatus: faker.helpers.arrayElement(["PENDING", "APPROVED", "REJECTED"]), + createdAt: new Date(), + updatedAt: new Date(), + } + + console.log("POA data:", poaData) + + await db.insert(poa).values(poaData) + console.log(`Created POA for contract: ${originalContract.contractNo}`) + } catch (error) { + console.error(`Error creating POA ${i + 1}:`, error) + throw error + } + } + + console.log(`✅ Successfully added ${count} new POAs`) + } catch (error) { + console.error("Error in seedPOA:", error) + throw error + } +} + +// 실행 +if (require.main === module) { + seedPOA({ count: 5 }) + .then(() => process.exit(0)) + .catch((error) => { + console.error(error) + process.exit(1) + }) +}
\ No newline at end of file diff --git a/lib/poa/service.ts b/lib/poa/service.ts new file mode 100644 index 00000000..a11cbdd8 --- /dev/null +++ b/lib/poa/service.ts @@ -0,0 +1,132 @@ +"use server"; + +import db from "@/db/db"; +import { GetChangeOrderSchema } from "./validations"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { + asc, + desc, + ilike, + and, + or, + count, +} from "drizzle-orm"; + +import { + poaDetailView, +} from "@/db/schema/contract"; + +/** + * POA 목록 조회 + */ +export async function getChangeOrders(input: GetChangeOrderSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. Build where clause + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: poaDetailView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(poaDetailView.contractNo, s), + ilike(poaDetailView.originalContractName, s), + ilike(poaDetailView.projectCode, s), + ilike(poaDetailView.projectName, s), + ilike(poaDetailView.vendorName, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 2. Combine where clauses + let finalWhere; + if (advancedWhere && globalWhere) { + finalWhere = and(advancedWhere, globalWhere); + } else { + finalWhere = advancedWhere || globalWhere; + } + + // 3. Build order by + let orderBy; + try { + orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(poaDetailView[item.id]) + : asc(poaDetailView[item.id]) + ) + : [desc(poaDetailView.createdAt)]; + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [desc(poaDetailView.createdAt)]; + } + + // 4. Execute queries + let data = []; + let total = 0; + + try { + const queryBuilder = db.select().from(poaDetailView); + + if (finalWhere) { + queryBuilder.where(finalWhere); + } + + queryBuilder.orderBy(...orderBy); + queryBuilder.offset(offset).limit(input.perPage); + + data = await queryBuilder; + + const countBuilder = db + .select({ count: count() }) + .from(poaDetailView); + + if (finalWhere) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error in getChangeOrders:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`poa`], + } + )(); +}
\ No newline at end of file diff --git a/lib/poa/table/poa-table-columns.tsx b/lib/poa/table/poa-table-columns.tsx new file mode 100644 index 00000000..b362e54c --- /dev/null +++ b/lib/poa/table/poa-table-columns.tsx @@ -0,0 +1,165 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon, PenIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { POADetail } from "@/db/schema/contract" +import { poaColumnsConfig } from "@/config/poaColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<POADetail> | null>> +} + +/** + * tanstack table column definitions with nested headers + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<POADetail>[] { + // ---------------------------------------------------------------- + // 1) actions column (buttons for item info) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<POADetail> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const hasSignature = row.original.hasSignature; + + return ( + <div className="flex items-center space-x-1"> + {/* Item Info Button */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => setRowAction({ row, type: "items" })} + > + <InfoIcon className="h-4 w-4" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent> + View Item Info + </TooltipContent> + </Tooltip> + </TooltipProvider> + + {/* Signature Request Button - only show if no signature exists */} + {!hasSignature && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => setRowAction({ row, type: "signature" })} + > + <PenIcon className="h-4 w-4" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent> + Request Electronic Signature + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + }, + size: 80, + }; + + // ---------------------------------------------------------------- + // 2) Regular columns grouped by group name + // ---------------------------------------------------------------- + // 2-1) groupMap: { [groupName]: ColumnDef<POADetail>[] } + const groupMap: Record<string, ColumnDef<POADetail>[]> = {}; + + poaColumnsConfig.forEach((cfg) => { + // Use "_noGroup" if no group is specified + const groupName = cfg.group || "_noGroup"; + + if (!groupMap[groupName]) { + groupMap[groupName] = []; + } + + // Child column definition + const childCol: ColumnDef<POADetail> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ cell }) => { + const value = cell.getValue(); + + if (cfg.type === "date") { + const dateVal = value as Date; + return ( + <div className="text-sm"> + {formatDate(dateVal)} + </div> + ); + } + if (cfg.type === "number") { + const numVal = value as number; + return ( + <div className="text-sm"> + {numVal ? numVal.toLocaleString() : "-"} + </div> + ); + } + return ( + <div className="text-sm"> + {value ?? "-"} + </div> + ); + }, + }; + + groupMap[groupName].push(childCol); + }); + + // ---------------------------------------------------------------- + // 2-2) Create actual parent columns (groups) from the groupMap + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<POADetail>[] = []; + + // Order can be fixed by pre-defining group order or sorting + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // No group → Add as top-level columns + nestedColumns.push(...colDefs); + } else { + // Parent column + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }); + } + }); + + // ---------------------------------------------------------------- + // 3) Final column array: nestedColumns + actionsColumn + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionsColumn, + ]; +}
\ No newline at end of file diff --git a/lib/poa/table/poa-table-toolbar-actions.tsx b/lib/poa/table/poa-table-toolbar-actions.tsx new file mode 100644 index 00000000..97a9cc55 --- /dev/null +++ b/lib/poa/table/poa-table-toolbar-actions.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { POADetail } from "@/db/schema/contract" + +interface ItemsTableToolbarActionsProps { + table: Table<POADetail> +} + +export function PoaTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + {/** Refresh 버튼 */} + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <RefreshCcw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Get POAs</span> + </Button> + + {/** Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "poa-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/poa/table/poa-table.tsx b/lib/poa/table/poa-table.tsx new file mode 100644 index 00000000..a5cad02a --- /dev/null +++ b/lib/poa/table/poa-table.tsx @@ -0,0 +1,189 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getChangeOrders } from "../service" +import { POADetail } from "@/db/schema/contract" +import { getColumns } from "./poa-table-columns" +import { PoaTableToolbarActions } from "./poa-table-toolbar-actions" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getChangeOrders>>, + ] + > +} + +export function ChangeOrderListsTable({ promises }: ItemsTableProps) { + const [result] = React.use(promises) + const { data, pageCount } = result + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<POADetail> | null>(null) + + // Handle row actions + React.useEffect(() => { + if (!rowAction) return + + if (rowAction.type === "items") { + // Handle items view action + setRowAction(null) + } + }, [rowAction]) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<POADetail>[] = [ + { + id: "contractNo", + label: "계약번호", + }, + { + id: "originalContractName", + label: "계약명", + }, + { + id: "approvalStatus", + label: "승인 상태", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<POADetail>[] = [ + { + id: "contractNo", + label: "계약번호", + type: "text", + }, + { + id: "originalContractName", + label: "계약명", + type: "text", + }, + { + id: "projectId", + label: "프로젝트 ID", + type: "number", + }, + { + id: "vendorId", + label: "벤더 ID", + type: "number", + }, + { + id: "originalStatus", + label: "상태", + type: "text", + }, + { + id: "deliveryTerms", + label: "납품조건", + type: "text", + }, + { + id: "deliveryDate", + label: "납품기한", + type: "date", + }, + { + id: "deliveryLocation", + label: "납품장소", + type: "text", + }, + { + id: "currency", + label: "통화", + type: "text", + }, + { + id: "totalAmount", + label: "총 금액", + type: "number", + }, + { + id: "discount", + label: "할인", + type: "number", + }, + { + id: "tax", + label: "세금", + type: "number", + }, + { + id: "shippingFee", + label: "배송비", + type: "number", + }, + { + id: "netTotal", + label: "최종 금액", + type: "number", + }, + { + id: "changeReason", + label: "변경 사유", + type: "text", + }, + { + id: "approvalStatus", + label: "승인 상태", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + { + id: "updatedAt", + label: "수정일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + className="h-[calc(100vh-12rem)]" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <PoaTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/poa/validations.ts b/lib/poa/validations.ts new file mode 100644 index 00000000..eae1b5ab --- /dev/null +++ b/lib/poa/validations.ts @@ -0,0 +1,66 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { POADetail } from "@/db/schema/contract" + +export const searchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<POADetail>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 원본 PO 관련 + contractNo: parseAsString.withDefault(""), + originalContractName: parseAsString.withDefault(""), + originalStatus: parseAsString.withDefault(""), + originalStartDate: parseAsString.withDefault(""), + originalEndDate: parseAsString.withDefault(""), + + // 프로젝트 정보 + projectId: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + + // 벤더 정보 + vendorId: parseAsString.withDefault(""), + vendorName: parseAsString.withDefault(""), + + // 납품 관련 + deliveryTerms: parseAsString.withDefault(""), + deliveryDate: parseAsString.withDefault(""), + deliveryLocation: parseAsString.withDefault(""), + + // 금액 관련 + currency: parseAsString.withDefault(""), + totalAmount: parseAsString.withDefault(""), + discount: parseAsString.withDefault(""), + tax: parseAsString.withDefault(""), + shippingFee: parseAsString.withDefault(""), + netTotal: parseAsString.withDefault(""), + + // 변경 사유 및 승인 상태 + changeReason: parseAsString.withDefault(""), + approvalStatus: parseAsString.withDefault(""), + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// 최종 타입 +export type GetChangeOrderSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
\ No newline at end of file |
