diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib/bidding-projects | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'lib/bidding-projects')
| -rw-r--r-- | lib/bidding-projects/repository.ts | 44 | ||||
| -rw-r--r-- | lib/bidding-projects/service.ts | 117 | ||||
| -rw-r--r-- | lib/bidding-projects/table/project-series-dialog.tsx | 133 | ||||
| -rw-r--r-- | lib/bidding-projects/table/projects-table-columns.tsx | 102 | ||||
| -rw-r--r-- | lib/bidding-projects/table/projects-table-toolbar-actions.tsx | 89 | ||||
| -rw-r--r-- | lib/bidding-projects/table/projects-table.tsx | 156 | ||||
| -rw-r--r-- | lib/bidding-projects/validation.ts | 32 |
7 files changed, 673 insertions, 0 deletions
diff --git a/lib/bidding-projects/repository.ts b/lib/bidding-projects/repository.ts new file mode 100644 index 00000000..44e61553 --- /dev/null +++ b/lib/bidding-projects/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { biddingProjects } from "@/db/schema"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectProjectLists( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(biddingProjects) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } +/** 총 개수 count */ +export async function countProjectLists( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(biddingProjects).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/bidding-projects/service.ts b/lib/bidding-projects/service.ts new file mode 100644 index 00000000..569bd18f --- /dev/null +++ b/lib/bidding-projects/service.ts @@ -0,0 +1,117 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { countProjectLists, selectProjectLists } from "./repository"; +import { biddingProjects, ProjectSeries, projectSeries } from "@/db/schema"; +import { GetBidProjectListsSchema } from "./validation"; + +export async function getBidProjectLists(input: GetBidProjectListsSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: biddingProjects, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(biddingProjects.pspid, s), + ilike(biddingProjects.projNm, s), + ilike(biddingProjects.kunnrNm, s), + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(biddingProjects[item.id]) : asc(biddingProjects[item.id]) + ) + : [asc(biddingProjects.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectProjectLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countProjectLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["project-lists"], + } + )(); + } + + /** + * 특정 프로젝트의 시리즈 데이터를 가져오는 서버 액션 + * @param pspid 견적프로젝트번호 + * @returns 프로젝트 시리즈 데이터 배열 + */ +export async function getProjectSeriesForProject(pspid: string) { + try { + if (!pspid) { + throw new Error("프로젝트 ID가 제공되지 않았습니다.") + } + + // 트랜잭션을 사용하여 데이터 조회 + const seriesData = await db.transaction(async (tx) => { + const results = await tx + .select() + .from(projectSeries) + .where(eq(projectSeries.pspid, pspid)) + .orderBy(projectSeries.sersNo) + + return results + }) + + + + return seriesData + } catch (error) { + console.error(`프로젝트 시리즈 데이터 가져오기 실패 (pspid: ${pspid}):`, error) + return [] + } +} diff --git a/lib/bidding-projects/table/project-series-dialog.tsx b/lib/bidding-projects/table/project-series-dialog.tsx new file mode 100644 index 00000000..168ede7e --- /dev/null +++ b/lib/bidding-projects/table/project-series-dialog.tsx @@ -0,0 +1,133 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { BiddingProjects } from "@/db/schema" +import { useToast } from "@/hooks/use-toast" + +// Import the function +import { getProjectSeriesForProject } from "../service" + +// Define the ProjectSeries type based on the schema +interface ProjectSeries { + pspid: string; + sersNo: string; + scDt?: string | null; + klDt?: string | null; + lcDt?: string | null; + dlDt?: string | null; + dockNo?: string | null; + dockNm?: string | null; + projNo?: string | null; + post1?: string | null; +} + +interface ProjectSeriesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + project: BiddingProjects | null +} + +export function ProjectSeriesDialog({ + open, + onOpenChange, + project, +}: ProjectSeriesDialogProps) { + const { toast } = useToast() + + const [projectSeries, setProjectSeries] = React.useState<ProjectSeries[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadItems() { + if (!project?.pspid) return; + + setIsLoading(true) + try { + const result = await getProjectSeriesForProject(project.pspid) + setProjectSeries(result) + } catch (error) { + console.error("프로젝트 시리즈 로드 오류:", error) + toast({ + title: "오류", + description: "프로젝트 시리즈 로드 실패", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + if (open && project) { + loadItems() + } + }, [toast, project, open]) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[900px]"> + <DialogHeader> + <DialogTitle> + {project ? `시리즈 목록 - ${project.projNm || project.pspid}` : "시리즈 목록"} + </DialogTitle> + </DialogHeader> + {isLoading ? ( + <div className="flex items-center justify-center h-40"> + 로딩 중... + </div> + ) : ( + <div className="max-h-[500px] overflow-y-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead>시리즈번호</TableHead> + <TableHead>K/L 연도분기</TableHead> + <TableHead>도크코드</TableHead> + <TableHead>도크명</TableHead> + <TableHead>SN공사번호</TableHead> + <TableHead>SN공사명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {projectSeries && projectSeries.length > 0 ? ( + projectSeries.map((series) => ( + <TableRow key={`${series.pspid}-${series.sersNo}`}> + <TableCell>{series.sersNo}</TableCell> + <TableCell>{series.scDt}</TableCell> + <TableCell>{series.klDt}</TableCell> + <TableCell>{series.lcDt}</TableCell> + <TableCell>{series.dlDt}</TableCell> + <TableCell>{series.dockNo}</TableCell> + <TableCell>{series.dockNm}</TableCell> + <TableCell>{series.projNo}</TableCell> + <TableCell>{series.post1}</TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={6} className="text-center h-24"> + 시리즈 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/bidding-projects/table/projects-table-columns.tsx b/lib/bidding-projects/table/projects-table-columns.tsx new file mode 100644 index 00000000..08530ff0 --- /dev/null +++ b/lib/bidding-projects/table/projects-table-columns.tsx @@ -0,0 +1,102 @@ +"use client" +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { formatDate } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { BiddingProjects } from "@/db/schema" +import { bidProjectsColumnsConfig } from "@/config/bidProjectsColumnsConfig" +import { Button } from "@/components/ui/button" +import { ListFilter } from "lucide-react" // Import an icon for the button + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingProjects> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingProjects>[] { + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<BiddingProjects>[] } + const groupMap: Record<string, ColumnDef<BiddingProjects>[]> = {} + bidProjectsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + // child column 정의 + const childCol: ColumnDef<BiddingProjects> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + return row.getValue(cfg.id) ?? "" + }, + } + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<BiddingProjects>[] = [] + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // Add action column + const actionColumn: ColumnDef<BiddingProjects> = { + id: "actions", + header: "Actions", + cell: ({ row }) => { + return ( + <Button + variant="ghost" + size="sm" + className="flex items-center gap-1" + onClick={() => { + setRowAction({ row,type: "view-series" }) + }} + > + <ListFilter className="h-4 w-4" /> + 시리즈 보기 + </Button> + ) + }, + } + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: nestedColumns + actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionColumn, // Add the action column + ] +}
\ No newline at end of file diff --git a/lib/bidding-projects/table/projects-table-toolbar-actions.tsx b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx new file mode 100644 index 00000000..ee2f8c4e --- /dev/null +++ b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { BiddingProjects } from "@/db/schema" + +interface ItemsTableToolbarActionsProps { + table: Table<BiddingProjects> +} + +export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // 프로젝트 동기화 API 호출 함수 + const syncProjects = async () => { + try { + setIsLoading(true) + + // API 엔드포인트 호출 + const response = await fetch('/api/cron/bid-projects') + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to sync projects') + } + + const data = await response.json() + + // 성공 메시지 표시 + toast.success( + `Projects synced successfully! ${data.result.items} items processed.` + ) + + // 페이지 새로고침으로 테이블 데이터 업데이트 + window.location.reload() + } catch (error) { + console.error('Error syncing projects:', error) + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while syncing projects' + ) + } finally { + setIsLoading(false) + } + } + + return ( + <div className="flex items-center gap-2"> + <Button + variant="samsung" + size="sm" + className="gap-2" + onClick={syncProjects} + disabled={isLoading} + > + <RefreshCcw + className={`size-4 ${isLoading ? 'animate-spin' : ''}`} + aria-hidden="true" + /> + <span className="hidden sm:inline"> + {isLoading ? 'Syncing...' : 'Get Projects'} + </span> + </Button> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Projects", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + disabled={isLoading} + > + <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/bidding-projects/table/projects-table.tsx b/lib/bidding-projects/table/projects-table.tsx new file mode 100644 index 00000000..0e0c48f9 --- /dev/null +++ b/lib/bidding-projects/table/projects-table.tsx @@ -0,0 +1,156 @@ +"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 { getColumns } from "./projects-table-columns" +import { getBidProjectLists } from "../service" +import { BiddingProjects } from "@/db/schema" +import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions" +import { ProjectSeriesDialog } from "./project-series-dialog" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBidProjectLists>>, + ] + > +} + +export function BidProjectsTable({ promises }: ItemsTableProps) { + + const [{ data, pageCount }] = + React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<BiddingProjects> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<BiddingProjects>[] = [ + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<BiddingProjects>[] = [ + { + id: "pspid", + label: "견적프로젝트번호", + type: "text", + // group: "Basic Info", + }, + { + id: "projNm", + label: "견적프로젝트명", + type: "text", + // group: "Basic Info", + }, + { + id: "sector", + label: "부문(S / M)", + type: "text", + }, + { + id: "kunnrNm", + label: "선주명", + type: "text", + }, + { + id: "cls1Nm", + label: "선급명", + type: "text", + }, + { + id: "ptypeNm", + label: "선종명", + type: "text", + }, + { + id: "estmPm", + label: "견적대표PM 성명", + type: "text", + }, + { + id: "createdAt", + label: "Created At", + type: "date", + // group: "Metadata",a + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + // group: "Metadata", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.pspid), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <ProjectTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <ProjectSeriesDialog + open={rowAction?.type === "view-series"} + onOpenChange={() => setRowAction(null)} + project={rowAction?.row.original ?? null} + /> + </> + ) +} diff --git a/lib/bidding-projects/validation.ts b/lib/bidding-projects/validation.ts new file mode 100644 index 00000000..e5f8b121 --- /dev/null +++ b/lib/bidding-projects/validation.ts @@ -0,0 +1,32 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { BiddingProjects } from "@/db/schema" + +export const searchParamsBidProjectsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<BiddingProjects>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetBidProjectListsSchema = Awaited<ReturnType<typeof searchParamsBidProjectsCache.parse>> |
