diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-23 08:59:54 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-23 08:59:54 +0000 |
| commit | 96ba777cda69af8caf3a6e0e8bfc1aca5016fe58 (patch) | |
| tree | bf7310e201fae6910e06eb8fde3ecdd14dd32ecf | |
| parent | 994defd6446ce20c4b4e0d6cc91688b0e64230a4 (diff) | |
(최겸) 기술영업 해양 프로젝트 AVL 개발
5 files changed, 618 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/tech-project-avl/page.tsx b/app/[lng]/evcp/(evcp)/tech-project-avl/page.tsx new file mode 100644 index 00000000..d942c5c5 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/tech-project-avl/page.tsx @@ -0,0 +1,85 @@ +import * as React from "react"
+import { redirect } from "next/navigation"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { SearchParams } from "@/types/table"
+import { searchParamsCache } from "@/lib/tech-project-avl/validations"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Shell } from "@/components/shell"
+import { AcceptedQuotationsTable } from "@/lib/tech-project-avl/table/accepted-quotations-table"
+import { getAcceptedTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Ellipsis } from "lucide-react"
+
+export interface PageProps {
+ params: Promise<{ lng: string }>
+ searchParams: Promise<SearchParams>
+}
+
+export default async function AcceptedQuotationsPage({
+ params,
+ searchParams,
+}: PageProps) {
+ const { lng } = await params
+
+ const session = await getServerSession(authOptions)
+ if (!session) {
+ redirect(`/${lng}/auth/signin`)
+ }
+
+ const search = await searchParams
+ const { page, perPage, sort, filters, search: searchText } = searchParamsCache.parse(search)
+ const validFilters = getValidFilters(filters ?? [])
+
+ const { data, pageCount } = await getAcceptedTechSalesVendorQuotations({
+ page,
+ perPage: perPage ?? 10,
+ sort,
+ search: searchText,
+ 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">
+ 승인된 견적서(해양TOP,HULL)
+ </h2>
+ <p className="text-muted-foreground">
+ 기술영업 승인 견적서에 대한 요약 정보를 확인하고{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 RFQ 코드, 설명, 업체명, 업체 코드 등의 상세 정보를 확인할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* Date range picker can be added here if needed */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={4}
+ cellWidths={["10rem", "15rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem", "10rem", "8rem", "10rem", "10rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AcceptedQuotationsTable
+ data={data}
+ pageCount={pageCount}
+ />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx new file mode 100644 index 00000000..68a61f0a --- /dev/null +++ b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx @@ -0,0 +1,324 @@ +"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import {
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+// Accepted Quotation 타입 정의
+export interface AcceptedQuotationItem {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ quotationVersion: number | null
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ status: string
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+
+ // RFQ 정보
+ rfqCode: string | null
+ rfqType: string | null
+ description: string | null
+ dueDate: Date | null
+ rfqStatus: string | null
+ materialCode: string | null
+
+ // Vendor 정보
+ vendorName: string
+ vendorCode: string | null
+ vendorEmail: string | null
+ vendorCountry: string | null
+
+ // Project 정보
+ projNm: string | null
+ pspid: string | null
+ sector: string | null
+}
+
+export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<AcceptedQuotationItem> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ // const actionsColumn: ColumnDef<AcceptedQuotationItem> = {
+ // id: "actions",
+ // cell: ({ row }) => (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button
+ // aria-label="Open menu"
+ // variant="ghost"
+ // className="flex size-8 p-0 data-[state=open]:bg-muted"
+ // >
+ // <Ellipsis className="size-4" aria-hidden="true" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end" className="w-40">
+ // <DropdownMenuItem
+ // onSelect={() => setRowAction({ row, type: "open" })}
+ // >
+ // 견적서 보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 40,
+ // enableSorting: false,
+ // enableHiding: false,
+ // }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들 정의
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<AcceptedQuotationItem>[] = [
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.rfqCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "RFQ 코드",
+ },
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 설명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate">
+ {row.original.description || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "RFQ 설명",
+ },
+ },
+ {
+ accessorKey: "rfqType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 타입" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.rfqType || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.vendorName}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "업체명",
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체 코드" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.vendorCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "업체 코드",
+ },
+ },
+ {
+ accessorKey: "quotationCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적서 코드" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.quotationCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "견적서 코드",
+ },
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총 금액" />
+ ),
+ cell: ({ row }) => {
+ const price = row.original.totalPrice;
+ const currency = row.original.currency || "USD";
+ return (
+ <div className="text-right font-medium">
+ {price ? `${Number(price).toLocaleString()} ${currency}` : "-"}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "총 금액",
+ },
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => (
+ <Badge variant="default" className="bg-green-100 text-green-800">
+ {row.original.status}
+ </Badge>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "상태",
+ },
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate">
+ {row.original.projNm || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "프로젝트명",
+ },
+ },
+ {
+ accessorKey: "materialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate">
+ {row.original.materialCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "자재 코드",
+ },
+ },
+ {
+ accessorKey: "vendorCountry",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="국가" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.vendorCountry || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "국가",
+ },
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.dueDate ? formatDate(row.original.dueDate) : "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "마감일",
+ },
+ },
+ {
+ accessorKey: "acceptedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="승인일" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.acceptedAt ? formatDate(row.original.acceptedAt) : "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "승인일",
+ },
+ },
+ ]
+
+ return [selectColumn, ...dataColumns]
+}
\ No newline at end of file diff --git a/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx b/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx new file mode 100644 index 00000000..ae9aea60 --- /dev/null +++ b/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx @@ -0,0 +1,51 @@ +"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 { AcceptedQuotationItem } from "./accepted-quotations-table-columns"
+
+interface AcceptedQuotationsTableToolbarActionsProps {
+ table: Table<AcceptedQuotationItem>
+ onRefresh?: () => void
+}
+
+export function AcceptedQuotationsTableToolbarActions({
+ table,
+ onRefresh
+}: AcceptedQuotationsTableToolbarActionsProps) {
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "accepted-tech-sales-quotations",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Excel 내보내기</span>
+ </Button>
+
+ {onRefresh && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="gap-2"
+ >
+ <RefreshCcw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+ )}
+ </div>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-project-avl/table/accepted-quotations-table.tsx b/lib/tech-project-avl/table/accepted-quotations-table.tsx new file mode 100644 index 00000000..da33d0d5 --- /dev/null +++ b/lib/tech-project-avl/table/accepted-quotations-table.tsx @@ -0,0 +1,117 @@ +"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField } 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 { getColumns, type AcceptedQuotationItem } from "./accepted-quotations-table-columns"
+import { AcceptedQuotationsTableToolbarActions } from "./accepted-quotations-table-toolbar-actions"
+
+interface AcceptedQuotationsTableProps {
+ data: AcceptedQuotationItem[]
+ pageCount: number
+ onRefresh?: () => void
+}
+
+export function AcceptedQuotationsTable({
+ data,
+ pageCount,
+ onRefresh,
+}: AcceptedQuotationsTableProps) {
+
+ // 필터 필드 정의
+ const filterFields: DataTableAdvancedFilterField<AcceptedQuotationItem>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ type: "text",
+ placeholder: "RFQ 코드로 필터...",
+ },
+ {
+ id: "vendorName",
+ label: "업체명",
+ type: "text",
+ placeholder: "업체명으로 필터...",
+ },
+ {
+ id: "vendorCode",
+ label: "업체 코드",
+ type: "text",
+ placeholder: "업체 코드로 필터...",
+ },
+ {
+ id: "projNm",
+ label: "프로젝트명",
+ type: "text",
+ placeholder: "프로젝트명으로 필터...",
+ },
+ {
+ id: "vendorCountry",
+ label: "국가",
+ type: "text",
+ placeholder: "국가로 필터...",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "select",
+ options: [
+ { label: "USD", value: "USD" },
+ { label: "EUR", value: "EUR" },
+ { label: "KRW", value: "KRW" },
+ { label: "JPY", value: "JPY" },
+ { label: "CNY", value: "CNY" },
+ ],
+ },
+ {
+ id: "rfqType",
+ label: "RFQ 타입",
+ type: "select",
+ options: [
+ { label: "TOP", value: "TOP" },
+ { label: "HULL", value: "HULL" },
+ ],
+ },
+ {
+ id: "dueDate",
+ label: "마감일",
+ type: "date",
+ },
+ {
+ id: "acceptedAt",
+ label: "승인일",
+ type: "date",
+ },
+ ]
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ initialState: {
+ sorting: [{ id: "acceptedAt", desc: true }],
+ columnPinning: { left: ["select"] },
+ },
+ getRowId: (originalRow) => `${originalRow.id}`,
+ })
+
+ return (
+ <div className="w-full space-y-2.5 overflow-auto">
+ <DataTableAdvancedToolbar table={table} filterFields={filterFields}>
+ <AcceptedQuotationsTableToolbarActions
+ table={table}
+ onRefresh={onRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ <DataTable table={table} />
+ </div>
+ )
+}
\ No newline at end of file diff --git a/lib/tech-project-avl/validations.ts b/lib/tech-project-avl/validations.ts new file mode 100644 index 00000000..3e08b641 --- /dev/null +++ b/lib/tech-project-avl/validations.ts @@ -0,0 +1,41 @@ +import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { AcceptedQuotationItem } from "./table/accepted-quotations-table-columns"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<AcceptedQuotationItem>().withDefault([
+ { id: "acceptedAt", desc: true },
+ ]),
+
+ // 검색 필드
+ rfqCode: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ projNm: parseAsString.withDefault(""),
+
+ // 필터 필드
+ rfqType: parseAsStringEnum(["SHIP", "TOP", "HULL"]),
+ currency: parseAsStringEnum(["USD", "EUR", "KRW", "JPY", "CNY"]),
+
+ // 날짜 범위
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
\ No newline at end of file |
