summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-28 02:39:39 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-28 02:39:39 +0000
commit7865b6a4d86b22af6a4b5b8258a91fd736449d57 (patch)
treefe0ff360335fd7505c390b7129c1b1126eadd252
parentb8e8328b1ffffb80bf4ebb776a4a24e5680fc5bc (diff)
parent281a4060cff0396253192f4e852be6770ad97cbd (diff)
Merge branch 'dev' of https://github.com/DTS-Development/SHI_EVCP into dev
-rw-r--r--app/[lng]/evcp/poa/page.tsx61
-rw-r--r--config/poaColumnsConfig.ts131
-rw-r--r--db/migrations/0097_poa_initial_setup.sql95
-rw-r--r--db/seeds_2/poaSeed.ts109
-rw-r--r--lib/poa/service.ts132
-rw-r--r--lib/poa/table/poa-table-columns.tsx165
-rw-r--r--lib/poa/table/poa-table-toolbar-actions.tsx45
-rw-r--r--lib/poa/table/poa-table.tsx189
-rw-r--r--lib/poa/validations.ts66
9 files changed, 993 insertions, 0 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/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