diff options
Diffstat (limited to 'lib/pq')
| -rw-r--r-- | lib/pq/pq-review-table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/pq/pq-review-table/vendors-table-columns.tsx | 212 | ||||
| -rw-r--r-- | lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx | 41 | ||||
| -rw-r--r-- | lib/pq/pq-review-table/vendors-table.tsx | 97 | ||||
| -rw-r--r-- | lib/pq/repository.ts | 44 | ||||
| -rw-r--r-- | lib/pq/service.ts | 987 | ||||
| -rw-r--r-- | lib/pq/table/add-pq-dialog.tsx | 299 | ||||
| -rw-r--r-- | lib/pq/table/delete-pqs-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/pq/table/pq-table-column.tsx | 185 | ||||
| -rw-r--r-- | lib/pq/table/pq-table-toolbar-actions.tsx | 55 | ||||
| -rw-r--r-- | lib/pq/table/pq-table.tsx | 125 | ||||
| -rw-r--r-- | lib/pq/table/update-pq-sheet.tsx | 272 | ||||
| -rw-r--r-- | lib/pq/validations.ts | 36 |
13 files changed, 2610 insertions, 0 deletions
diff --git a/lib/pq/pq-review-table/feature-flags-provider.tsx b/lib/pq/pq-review-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/pq/pq-review-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/pq/pq-review-table/vendors-table-columns.tsx b/lib/pq/pq-review-table/vendors-table-columns.tsx new file mode 100644 index 00000000..8673443f --- /dev/null +++ b/lib/pq/pq-review-table/vendors-table-columns.tsx @@ -0,0 +1,212 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, PaperclipIcon } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { useRouter } from "next/navigation" + +import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" +import { Separator } from "@/components/ui/separator" + + +type NextRouter = ReturnType<typeof useRouter>; + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>; + router: NextRouter; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<Vendor> = { + 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<Vendor> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <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={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/pq/${row.original.id}`); + }} + > + Details + </DropdownMenuItem> + + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] } + const groupMap: Record<string, ColumnDef<Vendor>[]> = {} + + vendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<Vendor> = { + 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 === "status") { + const statusVal = row.original.status + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */} + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<Vendor>[] = [] + + // 순서를 고정하고 싶다면 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, + }) + } + }) + + + + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..98fef170 --- /dev/null +++ b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx @@ -0,0 +1,41 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Check } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Vendor } from "@/db/schema/vendors" + +interface VendorsTableToolbarActionsProps { + table: Table<Vendor> +} + +export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( + <div className="flex items-center gap-2"> + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + 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/pq/pq-review-table/vendors-table.tsx b/lib/pq/pq-review-table/vendors-table.tsx new file mode 100644 index 00000000..7eb8f7de --- /dev/null +++ b/lib/pq/pq-review-table/vendors-table.tsx @@ -0,0 +1,97 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +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 { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./vendors-table-columns" +import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" +import { getVendorsInPQ } from "../service" + + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorsInPQ>>, + ] + > +} + +export function VendorsPQReviewTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null) + + // **router** 획득 + const router = useRouter() + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<Vendor>[] = [ + + + { id: "vendorCode", label: "Vendor Code" }, + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", 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} + // floatingBar={<VendorsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/repository.ts b/lib/pq/repository.ts new file mode 100644 index 00000000..95daf9a3 --- /dev/null +++ b/lib/pq/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { pqCriterias } from "@/db/schema/pq"; +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 selectPqs( + 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(pqCriterias) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countPqs( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(pqCriterias).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/pq/service.ts b/lib/pq/service.ts new file mode 100644 index 00000000..a1373dae --- /dev/null +++ b/lib/pq/service.ts @@ -0,0 +1,987 @@ +"use server" + +import db from "@/db/db" +import { GetPQSchema } from "./validations" +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { getErrorMessage } from "@/lib/handle-error"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm"; +import { z } from "zod" +import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache"; +import { pqCriterias, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs } from "@/db/schema/pq" +import { countPqs, selectPqs } from "./repository"; +import { sendEmail } from "../mail/sendEmail"; +import { vendorAttachments, vendors } from "@/db/schema/vendors"; +import path from 'path'; +import fs from 'fs/promises'; +import { randomUUID } from 'crypto'; +import { writeFile, mkdir } from 'fs/promises'; +import { GetVendorsSchema } from "../vendors/validations"; +import { countVendors, selectVendors } from "../vendors/repository"; + +/** + * PQ 목록 조회 + */ +export async function getPQs(input: GetPQSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: pqCriterias, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(pqCriterias.code, s), ilike(pqCriterias.groupName, s), ilike(pqCriterias.remarks, s), ilike(pqCriterias.checkPoint, s), ilike(pqCriterias.description, s) + ) + } + + const finalWhere = and(advancedWhere, globalWhere); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(pqCriterias[item.id]) : asc(pqCriterias[item.id]) + ) + : [asc(pqCriterias.createdAt)]; + + const { data, total } = await db.transaction(async (tx) => { + const data = await selectPqs(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countPqs(tx, finalWhere); + 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: [`pq`], + } + )(); +} + +// PQ 생성을 위한 입력 스키마 정의 +const createPqSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + description: z.string().optional(), + remarks: z.string().optional(), + groupName: z.string().optional() +}); + +export type CreatePqInputType = z.infer<typeof createPqSchema>; + +/** + * PQ 기준 생성 + */ +export async function createPq(input: CreatePqInputType) { + try { + // 입력 유효성 검증 + const validatedData = createPqSchema.parse(input); + + // 트랜잭션 사용하여 PQ 기준 생성 + return await db.transaction(async (tx) => { + // PQ 기준 생성 + const [newPqCriteria] = await tx + .insert(pqCriterias) + .values({ + code: validatedData.code, + checkPoint: validatedData.checkPoint, + description: validatedData.description || null, + remarks: validatedData.remarks || null, + groupName: validatedData.groupName || null, + }) + .returning({ id: pqCriterias.id }); + + // 성공 결과 반환 + return { + success: true, + pqId: newPqCriteria.id, + message: "PQ criteria created successfully" + }; + }); + } catch (error) { + console.error("Error creating PQ criteria:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + message: "Validation failed", + errors: error.errors + }; + } + + // 기타 에러 처리 + return { + success: false, + message: "Failed to create PQ criteria" + }; + } +} + +// PQ 캐시 무효화 함수 +export async function invalidatePqCache() { + revalidatePath(`/evcp/pq-criteria`); + revalidateTag(`pq`); +} + +// PQ 삭제를 위한 스키마 정의 +const removePqsSchema = z.object({ + ids: z.array(z.number()).min(1, "At least one PQ ID is required") +}); + +export type RemovePqsInputType = z.infer<typeof removePqsSchema>; + +/** + * PQ 기준 삭제 + */ +export async function removePqs(input: RemovePqsInputType) { + try { + // 입력 유효성 검증 + const validatedData = removePqsSchema.parse(input); + + // 트랜잭션 사용하여 PQ 기준 삭제 + await db.transaction(async (tx) => { + // PQ 기준 테이블에서 삭제 + await tx + .delete(pqCriterias) + .where(inArray(pqCriterias.id, validatedData.ids)); + }); + + // 캐시 무효화 + await invalidatePqCache(); + + return { success: true }; + } catch (error) { + console.error("Error removing PQ criteria:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + error: "Validation failed: " + error.errors.map(e => e.message).join(', ') + }; + } + + // 기타 에러 처리 + return { + success: false, + error: "Failed to remove PQ criteria" + }; + } +} + +// PQ 수정을 위한 스키마 정의 +const modifyPqSchema = z.object({ + id: z.number().positive("ID is required"), + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +export type ModifyPqInputType = z.infer<typeof modifyPqSchema>; + + +export async function modifyPq(input: ModifyPqInputType) { + try { + // 입력 유효성 검증 + const validatedData = modifyPqSchema.parse(input); + + // 트랜잭션 사용하여 PQ 기준 수정 + return await db.transaction(async (tx) => { + // PQ 기준 수정 + await tx + .update(pqCriterias) + .set({ + code: validatedData.code, + checkPoint: validatedData.checkPoint, + description: validatedData.description || null, + remarks: validatedData.remarks || null, + groupName: validatedData.groupName, + updatedAt: new Date(), + }) + .where(eq(pqCriterias.id, validatedData.id)); + + // 성공 결과 반환 + return { + success: true, + message: "PQ criteria updated successfully" + }; + }); + } catch (error) { + console.error("Error updating PQ criteria:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + error: "Validation failed: " + error.errors.map(e => e.message).join(', ') + }; + } + + // 기타 에러 처리 + return { + success: false, + error: "Failed to update PQ criteria" + }; + } finally { + // 캐시 무효화 + revalidatePath(`/partners/pq`); + revalidateTag(`pq`); + } +} + +export interface PQAttachment { + attachId: number + fileName: string + filePath: string + fileSize?: number +} + +export interface PQItem { + answerId: number | null; // null도 허용하도록 변경 + criteriaId: number + code: string + checkPoint: string + description: string | null + answer: string // or null + attachments: PQAttachment[] +} + +export interface PQGroupData { + groupName: string + items: PQItem[] +} + + +export async function getPQDataByVendorId(vendorId: number): Promise<PQGroupData[]> { + // 1) Query: pqCriterias + // LEFT JOIN vendorPqCriteriaAnswers (to get `answer`) + // LEFT JOIN vendorCriteriaAttachments (to get each attachment row) + const rows = await db + .select({ + criteriaId: pqCriterias.id, + groupName: pqCriterias.groupName, + code: pqCriterias.code, + checkPoint: pqCriterias.checkPoint, + description: pqCriterias.description, + + // From vendorPqCriteriaAnswers + answer: vendorPqCriteriaAnswers.answer, // can be null if no row exists + answerId: vendorPqCriteriaAnswers.id, // internal PK if needed + + // From vendorCriteriaAttachments + attachId: vendorCriteriaAttachments.id, + fileName: vendorCriteriaAttachments.fileName, + filePath: vendorCriteriaAttachments.filePath, + fileSize: vendorCriteriaAttachments.fileSize, + }) + .from(pqCriterias) + .leftJoin( + vendorPqCriteriaAnswers, + and( + eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), + eq(vendorPqCriteriaAnswers.vendorId, vendorId) + ) + ) + .leftJoin( + vendorCriteriaAttachments, + eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) + ) + .orderBy(pqCriterias.groupName, pqCriterias.code) + + // 2) Group by groupName => each group has a map of criteriaId => PQItem + // so we can gather attachments properly. + const groupMap = new Map<string, Record<number, PQItem>>() + + for (const row of rows) { + const g = row.groupName || "Others" + + // Ensure we have an object for this group + if (!groupMap.has(g)) { + groupMap.set(g, {}) + } + + const groupItems = groupMap.get(g)! + // If we haven't seen this criteriaId yet, create a PQItem + if (!groupItems[row.criteriaId]) { + groupItems[row.criteriaId] = { + answerId: row.answerId, + criteriaId: row.criteriaId, + code: row.code, + checkPoint: row.checkPoint, + description: row.description, + answer: row.answer || "", // if row.answer is null, just empty string + attachments: [], + } + } + + // If there's an attachment row (attachId not null), push it onto `attachments` + if (row.attachId) { + groupItems[row.criteriaId].attachments.push({ + attachId: row.attachId, + fileName: row.fileName || "", + filePath: row.filePath || "", + fileSize: row.fileSize || undefined, + }) + } + } + + // 3) Convert groupMap into an array of { groupName, items[] } + const data: PQGroupData[] = [] + for (const [groupName, itemsMap] of groupMap.entries()) { + // Convert the itemsMap (key=criteriaId => PQItem) into an array + const items = Object.values(itemsMap) + data.push({ groupName, items }) + } + + return data +} + + +interface PQAttachmentInput { + fileName: string // original user-friendly file name + url: string // the UUID-based path stored on server + size?: number // optional file size +} + +interface SavePQAnswer { + criteriaId: number + answer: string + attachments: PQAttachmentInput[] +} + +interface SavePQInput { + vendorId: number + answers: SavePQAnswer[] +} + +/** + * 여러 항목을 한 번에 Upsert + */ +export async function savePQAnswersAction(input: SavePQInput) { + const { vendorId, answers } = input + + try { + for (const ans of answers) { + // 1) Check if a row already exists for (vendorId, criteriaId) + const existing = await db + .select() + .from(vendorPqCriteriaAnswers) + .where( + and( + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId) + ) + ) + + let answerId: number + + // 2) If it exists, update the row; otherwise insert + if (existing.length === 0) { + // Insert new + const inserted = await db + .insert(vendorPqCriteriaAnswers) + .values({ + vendorId, + criteriaId: ans.criteriaId, + answer: ans.answer, + // no attachmentPaths column anymore + }) + .returning({ id: vendorPqCriteriaAnswers.id }) + + answerId = inserted[0].id + } else { + // Update existing + answerId = existing[0].id + + await db + .update(vendorPqCriteriaAnswers) + .set({ + answer: ans.answer, + updatedAt: new Date(), + }) + .where(eq(vendorPqCriteriaAnswers.id, answerId)) + } + + // 3) Now manage attachments in vendorCriteriaAttachments + // We'll do a "diff": remove old ones not in the new list, insert new ones not in DB. + + // 3a) Load old attachments from DB + const oldAttachments = await db + .select({ + id: vendorCriteriaAttachments.id, + filePath: vendorCriteriaAttachments.filePath, + }) + .from(vendorCriteriaAttachments) + .where(eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, answerId)) + + // 3b) Gather the new filePaths (urls) from the client + const newPaths = ans.attachments.map(a => a.url) + + // 3c) Find attachments to remove + const toRemove = oldAttachments.filter(old => !newPaths.includes(old.filePath)) + if (toRemove.length > 0) { + const removeIds = toRemove.map(r => r.id) + await db + .delete(vendorCriteriaAttachments) + .where(inArray(vendorCriteriaAttachments.id, removeIds)) + } + + // 3d) Insert new attachments that aren’t in DB + const oldPaths = oldAttachments.map(o => o.filePath) + const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url)) + + for (const attach of toAdd) { + await db.insert(vendorCriteriaAttachments).values({ + vendorCriteriaAnswerId: answerId, + fileName: attach.fileName, // original filename + filePath: attach.url, // random/UUID path on server + fileSize: attach.size ?? null, + // fileType if you have it, etc. + }) + } + } + + return { ok: true } + } catch (error) { + console.error("savePQAnswersAction error:", error) + return { ok: false, error: String(error) } + } +} + + + +/** + * PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트 + * @param vendorId 벤더 ID + */ +export async function submitPQAction(vendorId: number) { + unstable_noStore(); + + try { + // 1. 모든 PQ 항목에 대한 응답이 있는지 검증 + const pqCriteriaCount = await db + .select({ count: count() }) + .from(vendorPqCriteriaAnswers) + .where(eq(vendorPqCriteriaAnswers.vendorId, vendorId)); + + const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0; + + // 응답 데이터 검증 + if (totalPqCriteriaCount === 0) { + return { ok: false, error: "No PQ answers found" }; + } + + // 2. 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 3. 벤더 상태가 제출 가능한 상태인지 확인 + const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; + if (!allowedStatuses.includes(vendor.status)) { + return { + ok: false, + error: `Cannot submit PQ in current status: ${vendor.status}` + }; + } + + // 4. 벤더 상태 업데이트 + await db + .update(vendors) + .set({ + status: "PQ_SUBMITTED", + updatedAt: new Date(), + }) + .where(eq(vendors.id, vendorId)); + + // 5. 관리자에게 이메일 알림 발송 + if (process.env.ADMIN_EMAIL) { + try { + await sendEmail({ + to: process.env.ADMIN_EMAIL, + subject: `[eVCP] PQ Submitted: ${vendor.vendorName}`, + template: "pq-submitted-admin", + context: { + vendorName: vendor.vendorName, + vendorId: vendor.id, + submittedDate: new Date().toLocaleString(), + adminUrl: `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`, + } + }); + } catch (emailError) { + console.error("Failed to send admin notification:", emailError); + // 이메일 실패는 전체 프로세스를 중단하지 않음 + } + } + + // 6. 벤더에게 확인 이메일 발송 + if (vendor.email) { + try { + await sendEmail({ + to: vendor.email, + subject: "[eVCP] PQ Submission Confirmation", + template: "pq-submitted-vendor", + context: { + vendorName: vendor.vendorName, + submittedDate: new Date().toLocaleString(), + portalUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, + } + }); + } catch (emailError) { + console.error("Failed to send vendor confirmation:", emailError); + // 이메일 실패는 전체 프로세스를 중단하지 않음 + } + } + + // 7. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + + return { ok: true }; + } catch (error) { + console.error("PQ submit error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + +/** + * 향상된 파일 업로드 서버 액션 + * - 직접 파일 처리 (file 객체로 받음) + * - 디렉토리 자동 생성 + * - 중복 방지를 위한 UUID 적용 + */ +export async function uploadFileAction(file: File) { + unstable_noStore(); + + try { + // 파일 유효성 검사 + if (!file || file.size === 0) { + throw new Error("Invalid file"); + } + + const maxSize = 6e8; + if (file.size > maxSize) { + throw new Error(`File size exceeds limit (${Math.round(maxSize / 1024 / 1024)}MB)`); + } + + // 파일 확장자 가져오기 + const originalFilename = file.name; + const fileExt = path.extname(originalFilename); + const fileNameWithoutExt = path.basename(originalFilename, fileExt); + + // 저장 경로 설정 + const uploadDir = process.env.UPLOAD_DIR + ? process.env.UPLOAD_DIR + : path.join(process.cwd(), "public", "uploads") + const datePrefix = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD + const targetDir = path.join(uploadDir, 'pq', datePrefix); + + // UUID로 고유 파일명 생성 + const uuid = randomUUID(); + const sanitizedFilename = fileNameWithoutExt + .replace(/[^a-zA-Z0-9-_]/g, '_') // 안전한 문자만 허용 + .slice(0, 50); // 이름 길이 제한 + + const filename = `${sanitizedFilename}-${uuid}${fileExt}`; + const filePath = path.join(targetDir, filename); + const relativeFilePath = path.join('pq', datePrefix, filename); + + // 디렉토리 생성 (없는 경우) + try { + await mkdir(targetDir, { recursive: true }); + } catch (err) { + console.error("Error creating directory:", err); + throw new Error("Failed to create upload directory"); + } + + // 파일 저장 + const buffer = await file.arrayBuffer(); + await writeFile(filePath, Buffer.from(buffer)); + + // 상대 경로를 반환 (DB에 저장하기 용이함) + const publicUrl = `/uploads/${relativeFilePath.replace(/\\/g, '/')}`; + + return { + fileName: originalFilename, + url: publicUrl, + size: file.size, + }; + } catch (error) { + console.error("File upload error:", error); + throw new Error(`Upload failed: ${getErrorMessage(error)}`); + } +} + +/** + * 여러 파일 일괄 업로드 + */ +export async function uploadMultipleFilesAction(files: File[]) { + unstable_noStore(); + + try { + const results = []; + + for (const file of files) { + try { + const result = await uploadFileAction(file); + results.push({ + success: true, + ...result + }); + } catch (error) { + results.push({ + success: false, + fileName: file.name, + error: getErrorMessage(error) + }); + } + } + + return { + ok: true, + results + }; + } catch (error) { + console.error("Batch upload error:", error); + return { + ok: false, + error: getErrorMessage(error) + }; + } +} + +export async function getVendorsInPQ(input: GetVendorsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 + const advancedWhere = filterColumns({ + table: vendors, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 2) 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(vendors.vendorName, s), + ilike(vendors.vendorCode, s), + ilike(vendors.email, s), + ilike(vendors.status, s) + ); + } + + // 최종 where 결합 + const finalWhere = and(advancedWhere, globalWhere, eq(vendors.status ,"PQ_SUBMITTED")); + + // 간단 검색 (advancedTable=false) 시 예시 + const simpleWhere = and( + input.vendorName + ? ilike(vendors.vendorName, `%${input.vendorName}%`) + : undefined, + input.status ? ilike(vendors.status, input.status) : undefined, + input.country + ? ilike(vendors.country, `%${input.country}%`) + : undefined + ); + + // 실제 사용될 where + const where = finalWhere; + + // 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + ) + : [asc(vendors.createdAt)]; + + // 트랜잭션 내에서 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 1) vendor 목록 조회 + const vendorsData = await selectVendors(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + // 2) 각 vendor의 attachments 조회 + const vendorsWithAttachments = await Promise.all( + vendorsData.map(async (vendor) => { + const attachments = await tx + .select({ + id: vendorAttachments.id, + fileName: vendorAttachments.fileName, + filePath: vendorAttachments.filePath, + }) + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, vendor.id)); + + return { + ...vendor, + hasAttachments: attachments.length > 0, + attachmentsList: attachments, + }; + }) + ); + + // 3) 전체 개수 + const total = await countVendors(tx, where); + return { data: vendorsWithAttachments, total }; + }); + + // 페이지 수 + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["vendors-in-pq"], // revalidateTag("vendors") 호출 시 무효화 + } + )(); +} + + +export type VendorStatus = + | "PENDING_REVIEW" + | "IN_REVIEW" + | "REJECTED" + | "IN_PQ" + | "PQ_SUBMITTED" + | "PQ_FAILED" + | "APPROVED" + | "ACTIVE" + | "INACTIVE" + | "BLACKLISTED" + + export async function updateVendorStatusAction( + vendorId: number, + newStatus: VendorStatus + ) { + try { + // 1) Update DB + await db.update(vendors) + .set({ status: newStatus }) + .where(eq(vendors.id, vendorId)) + + // 2) Load vendor’s email & name + const vendor = await db.select().from(vendors).where(eq(vendors.id, vendorId)).then(r => r[0]) + if (!vendor) { + return { ok: false, error: "Vendor not found" } + } + + // 3) Send email + await sendEmail({ + to: vendor.email || "", + subject: `Your PQ Status is now ${newStatus}`, + template: "vendor-pq-status", // matches .hbs file + context: { + name: vendor.vendorName, + status: newStatus, + loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, // etc. + }, + }) + revalidateTag("vendors") + revalidateTag("vendors-in-pq") + return { ok: true } + } catch (error) { + console.error("updateVendorStatusAction error:", error) + return { ok: false, error: String(error) } + } + } +// 코멘트 타입 정의 +interface ItemComment { + answerId: number; + checkPoint: string; // 체크포인트 정보 추가 + code: string; // 코드 정보 추가 + comment: string; +} + +/** + * PQ 변경 요청 처리 서버 액션 + * + * @param vendorId 벤더 ID + * @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성) + * @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항) + */ +export async function requestPqChangesAction({ + vendorId, + comment, + generalComment, +}: { + vendorId: number; + comment: ItemComment[]; + generalComment?: string; +}) { + try { + // 1) 벤더 상태 업데이트 + await db.update(vendors) + .set({ + status: "IN_PQ", // 변경 요청 상태로 설정 + updatedAt: new Date(), + }) + .where(eq(vendors.id, vendorId)); + + // 2) 벤더 정보 가져오기 + const vendor = await db.select() + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(r => r[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 3) 각 항목별 코멘트 저장 + const currentDate = new Date(); + const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다) + const reviewerName = "AdminUser"; // 실제 구현에서는 세션에서 가져옵니다 + + // 병렬로 모든 코멘트 저장 + if (comment && comment.length > 0) { + const insertPromises = comment.map(item => + db.insert(vendorPqReviewLogs) + .values({ + vendorPqCriteriaAnswerId: item.answerId, + // reviewerId: reviewerId, + reviewerName: reviewerName, + reviewerComment: item.comment, + createdAt: currentDate, + // 추가 메타데이터 필드가 있다면 저장 + // 이런 메타데이터는 DB 스키마에 해당 필드가 있어야 함 + // meta: JSON.stringify({ checkPoint: item.checkPoint, code: item.code }) + }) + ); + + // 모든 삽입 기다리기 + await Promise.all(insertPromises); + } + + // 4) 변경 요청 이메일 보내기 + // 코멘트 목록 준비 + const commentItems = comment.map(item => ({ + id: item.answerId, + code: item.code, + checkPoint: item.checkPoint, + text: item.comment + })); + + await sendEmail({ + to: vendor.email || "", + subject: `[IMPORTANT] Your PQ submission requires changes`, + template: "vendor-pq-comment", // matches .hbs file + context: { + name: vendor.vendorName, + vendorCode: vendor.vendorCode, + loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, + comments: commentItems, + generalComment: generalComment || "", + hasGeneralComment: !!generalComment, + commentCount: commentItems.length, + }, + }); + + revalidateTag("vendors") + revalidateTag("vendors-in-pq") + + return { ok: true }; + } catch (error) { + console.error("requestPqChangesAction error:", error); + return { ok: false, error: String(error) }; + } +} +interface AddReviewCommentInput { + answerId: number // vendorPqCriteriaAnswers.id + comment: string + reviewerName?: string +} + +export async function addReviewCommentAction(input: AddReviewCommentInput) { + try { + // 1) Check that the answer row actually exists + const existing = await db + .select({ id: vendorPqCriteriaAnswers.id }) + .from(vendorPqCriteriaAnswers) + .where(eq(vendorPqCriteriaAnswers.id, input.answerId)) + + if (existing.length === 0) { + return { ok: false, error: "Item not found" } + } + + // 2) Insert the log + await db.insert(vendorPqReviewLogs).values({ + vendorPqCriteriaAnswerId: input.answerId, + reviewerComment: input.comment, + reviewerName: input.reviewerName ?? "AdminUser", + }) + + return { ok: true } + } catch (error) { + console.error("addReviewCommentAction error:", error) + return { ok: false, error: String(error) } + } +} + +interface GetItemReviewLogsInput { + answerId: number +} + +export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) { + try { + + const logs = await db + .select() + .from(vendorPqReviewLogs) + .where(eq(vendorPqReviewLogs.vendorPqCriteriaAnswerId, input.answerId)) + .orderBy(desc(vendorPqReviewLogs.createdAt)); + + return { ok: true, data: logs }; + } catch (error) { + console.error("getItemReviewLogsAction error:", error); + return { ok: false, error: String(error) }; + } +}
\ No newline at end of file diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx new file mode 100644 index 00000000..8164dbaf --- /dev/null +++ b/lib/pq/table/add-pq-dialog.tsx @@ -0,0 +1,299 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useToast } from "@/hooks/use-toast" +import { createPq, invalidatePqCache } from "../service" + +// PQ 생성을 위한 Zod 스키마 정의 +const createPqSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +type CreatePqInputType = z.infer<typeof createPqSchema>; + +// 그룹 이름 옵션 +const groupOptions = [ + "GENERAL", + "Quality Management System", + "Workshop & Environment", + "Warranty", +]; + +// 설명 예시 텍스트 +const descriptionExample = `Address : +Tel. / Fax : +e-mail :`; + +export function AddPqDialog() { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const router = useRouter() + const { toast } = useToast() + + // react-hook-form 설정 + const form = useForm<CreatePqInputType>({ + resolver: zodResolver(createPqSchema), + defaultValues: { + code: "", + checkPoint: "", + groupName: groupOptions[0], + description: "", + remarks: "" + }, + }) + + // 예시 텍스트를 description 필드에 채우는 함수 + const fillExampleText = () => { + form.setValue("description", descriptionExample); + }; + + async function onSubmit(data: CreatePqInputType) { + try { + setIsSubmitting(true) + + // 서버 액션 호출 + const result = await createPq(data) + + if (!result.success) { + toast({ + title: "Error", + description: result.message || "Failed to create PQ criteria", + variant: "destructive", + }) + return + } + + await invalidatePqCache(); + + // 성공 시 처리 + toast({ + title: "Success", + description: "PQ criteria created successfully", + }) + + // 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + + // 페이지 새로고침 + router.refresh() + + } catch (error) { + console.error('Error creating PQ criteria:', error) + toast({ + title: "Error", + description: "An unexpected error occurred", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="size-4" /> + Add PQ + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[550px]"> + <DialogHeader> + <DialogTitle>Create New PQ Criteria</DialogTitle> + <DialogDescription> + 새 PQ 기준 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> + {/* Code 필드 */} + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>Code <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 1-1, A.2.3" + {...field} + /> + </FormControl> + <FormDescription> + PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3") + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Check Point 필드 */} + <FormField + control={form.control} + name="checkPoint" + render={({ field }) => ( + <FormItem> + <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="검증 항목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Group Name 필드 (Select) */} + <FormField + control={form.control} + name="groupName" + render={({ field }) => ( + <FormItem> + <FormLabel>Group <span className="text-destructive">*</span></FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="그룹을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {groupOptions.map((group) => ( + <SelectItem key={group} value={group}> + {group} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + PQ 항목의 분류 그룹을 선택하세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description 필드 - 예시 템플릿 버튼 추가 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <div className="flex items-center justify-between"> + <FormLabel>Description</FormLabel> + <Button + type="button" + variant="outline" + size="sm" + onClick={fillExampleText} + > + 예시 채우기 + </Button> + </div> + <FormControl> + <Textarea + placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`} + className="min-h-[120px] font-mono" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remarks 필드 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="비고 사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset(); + setOpen(false); + }} + > + Cancel + </Button> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/pq/table/delete-pqs-dialog.tsx b/lib/pq/table/delete-pqs-dialog.tsx new file mode 100644 index 00000000..c6a2ce82 --- /dev/null +++ b/lib/pq/table/delete-pqs-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { PqCriterias } from "@/db/schema/pq" +import { removePqs } from "../service" + + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + pqs: Row<PqCriterias>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeletePqsDialog({ + pqs, + showTrigger = true, + onSuccess, + ...props +}: DeleteTasksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removePqs({ + ids: pqs.map((pq) => pq.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({pqs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{pqs.length}</span> + {pqs.length === 1 ? " PQ" : " PQs"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({pqs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{pqs.length}</span> + {pqs.length === 1 ? " task" : " pqs"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/pq/table/pq-table-column.tsx b/lib/pq/table/pq-table-column.tsx new file mode 100644 index 00000000..7efed645 --- /dev/null +++ b/lib/pq/table/pq-table-column.tsx @@ -0,0 +1,185 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Ellipsis } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { PqCriterias } from "@/db/schema/pq" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PqCriterias> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<PqCriterias>[] { + return [ + { + 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, + }, + + { + accessorKey: "groupName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Group Name" /> + ), + cell: ({ row }) => <div>{row.getValue("groupName")}</div>, + meta: { + excelHeader: "Group Name" + }, + enableResizing: true, + minSize: 60, + size: 100, + }, + { + accessorKey: "code", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Code" /> + ), + cell: ({ row }) => <div>{row.getValue("code")}</div>, + meta: { + excelHeader: "Code" + }, + enableResizing: true, + minSize: 50, + size: 100, + }, + { + accessorKey: "checkPoint", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Check Point" /> + ), + cell: ({ row }) => <div>{row.getValue("checkPoint")}</div>, + meta: { + excelHeader: "Check Point" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + + { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Description" /> + ), + cell: ({ row }) => { + const text = row.getValue("description") as string + return ( + <div style={{ whiteSpace: "pre-wrap" }}> + {text} + </div> + ) + }, + meta: { + excelHeader: "Description" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 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: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/lib/pq/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx new file mode 100644 index 00000000..1d151520 --- /dev/null +++ b/lib/pq/table/pq-table-toolbar-actions.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Send, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeletePqsDialog } from "./delete-pqs-dialog" +import { AddPqDialog } from "./add-pq-dialog" +import { PqCriterias } from "@/db/schema/pq" + + +interface DocTableToolbarActionsProps { + table: Table<PqCriterias> +} + +export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeletePqsDialog + pqs={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + <AddPqDialog /> + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Document-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/pq/table/pq-table.tsx b/lib/pq/table/pq-table.tsx new file mode 100644 index 00000000..73876c72 --- /dev/null +++ b/lib/pq/table/pq-table.tsx @@ -0,0 +1,125 @@ +"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 { getPQs } from "../service" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { PqCriterias } from "@/db/schema/pq" +import { DeletePqsDialog } from "./delete-pqs-dialog" +import { PqTableToolbarActions } from "./pq-table-toolbar-actions" +import { getColumns } from "./pq-table-column" +import { UpdatePqSheet } from "./update-pq-sheet" + +interface DocumentListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getPQs>>]> +} + +export function PqsTable({ + promises, +}: DocumentListTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PqCriterias> | null>(null) + + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<PqCriterias>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<PqCriterias>[] = [ + { + id: "code", + label: "Code", + type: "text", + }, + { + id: "checkPoint", + label: "Check Point", + type: "text", + }, + { + id: "description", + label: "Description", + type: "text", + }, + { + id: "remarks", + label: "Remarks", + type: "text", + }, + { + id: "groupName", + label: "Group Name", + type: "text", + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + { + id: "updatedAt", + label: "Updated at", + type: "date", + }, + ] + + // useDataTable 훅으로 react-table 구성 + const { table } = useDataTable({ + data: data, // <-- 여기서 tableData 사용 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + // grouping:['groupName'] + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + return ( + <> + <DataTable table={table} > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <PqTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <UpdatePqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + pq={rowAction?.row.original ?? null} + /> + + <DeletePqsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + pqs={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/table/update-pq-sheet.tsx b/lib/pq/table/update-pq-sheet.tsx new file mode 100644 index 00000000..3bac3558 --- /dev/null +++ b/lib/pq/table/update-pq-sheet.tsx @@ -0,0 +1,272 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Save } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +import { modifyPq } from "../service" + +// PQ 수정을 위한 Zod 스키마 정의 +const updatePqSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +type UpdatePqSchema = z.infer<typeof updatePqSchema>; + +// 그룹 이름 옵션 +const groupOptions = [ + "GENERAL", + "Quality Management System", + "Organization", + "Resource Management", + "Other" +]; + +interface UpdatePqSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + pq: { + id: number; + code: string; + checkPoint: string; + description: string | null; + remarks: string | null; + groupName: string | null; + } | null +} + +export function UpdatePqSheet({ pq, ...props }: UpdatePqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<UpdatePqSchema>({ + resolver: zodResolver(updatePqSchema), + defaultValues: { + code: pq?.code ?? "", + checkPoint: pq?.checkPoint ?? "", + groupName: pq?.groupName ?? groupOptions[0], + description: pq?.description ?? "", + remarks: pq?.remarks ?? "", + }, + }) + + // 폼 초기화 (pq가 변경될 때) + React.useEffect(() => { + if (pq) { + form.reset({ + code: pq.code, + checkPoint: pq.checkPoint, + groupName: pq.groupName ?? groupOptions[0], + description: pq.description ?? "", + remarks: pq.remarks ?? "", + }); + } + }, [pq, form]); + + function onSubmit(input: UpdatePqSchema) { + startUpdateTransition(async () => { + if (!pq) return + + const result = await modifyPq({ + id: pq.id, + ...input, + }) + + if (!result.success && 'error' in result) { + toast.error(result.error) + } else { + toast.error("Failed to update PQ criteria") + } + + form.reset() + props.onOpenChange?.(false) + toast.success("PQ criteria updated successfully") + router.refresh() + }) + } + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update PQ Criteria</SheetTitle> + <SheetDescription> + Update the PQ criteria details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* Code 필드 */} + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>Code <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 1-1, A.2.3" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Check Point 필드 */} + <FormField + control={form.control} + name="checkPoint" + render={({ field }) => ( + <FormItem> + <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="검증 항목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Group Name 필드 (Select) */} + <FormField + control={form.control} + name="groupName" + render={({ field }) => ( + <FormItem> + <FormLabel>Group <span className="text-destructive">*</span></FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="그룹을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {groupOptions.map((group) => ( + <SelectItem key={group} value={group}> + {group} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description 필드 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Textarea + placeholder="상세 설명을 입력하세요" + className="min-h-[120px] whitespace-pre-wrap" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remarks 필드 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="비고 사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button + type="button" + variant="outline" + onClick={() => form.reset()} + > + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <Save className="mr-2 size-4" /> Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts new file mode 100644 index 00000000..27e065ba --- /dev/null +++ b/lib/pq/validations.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { PqCriterias } from "@/db/schema/pq" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<PqCriterias>().withDefault([ + { id: "createdAt", desc: true }, + ]), + code: parseAsString.withDefault(""), + checkPoint: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + remarks: parseAsString.withDefault(""), + groupName: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> |
