diff options
Diffstat (limited to 'lib/vendor-investigation')
| -rw-r--r-- | lib/vendor-investigation/service.ts | 229 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table-columns.tsx | 251 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx | 41 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table.tsx | 133 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/update-investigation-sheet.tsx | 324 | ||||
| -rw-r--r-- | lib/vendor-investigation/validations.ts | 93 |
7 files changed, 1179 insertions, 0 deletions
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts new file mode 100644 index 00000000..b731a95c --- /dev/null +++ b/lib/vendor-investigation/service.ts @@ -0,0 +1,229 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView } from "@/db/schema/vendors" +import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations" +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; +import { revalidateTag, unstable_noStore } from "next/cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; +import db from "@/db/db"; +import { sendEmail } from "../mail/sendEmail"; +import fs from "fs" +import path from "path" +import { v4 as uuid } from "uuid" + +export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // 1) Advanced filters + const advancedWhere = filterColumns({ + table: vendorInvestigationsView, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // 2) Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(vendorInvestigationsView.vendorName, s), + ilike(vendorInvestigationsView.vendorCode, s), + ilike(vendorInvestigationsView.investigationNotes, s), + ilike(vendorInvestigationsView.vendorEmail, s) + // etc. + ) + } + + // 3) Combine finalWhere + // Example: Only show vendorStatus = "PQ_SUBMITTED" + const finalWhere = and( + advancedWhere, + globalWhere, + eq(vendorInvestigationsView.vendorStatus, "PQ_SUBMITTED") + ) + + + + // 5) Sorting + const orderBy = + input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(vendorInvestigationsView[item.id]) + : asc(vendorInvestigationsView[item.id]) + ) + : [desc(vendorInvestigationsView.investigationCreatedAt)] + + // 6) Query & count + const { data, total } = await db.transaction(async (tx) => { + // a) Select from the view + const investigationsData = await tx + .select() + .from(vendorInvestigationsView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(input.perPage) + + // b) Count total + const resCount = await tx + .select({ count: count() }) + .from(vendorInvestigationsView) + .where(finalWhere) + + return { data: investigationsData, total: resCount[0]?.count } + }) + + // 7) Calculate pageCount + const pageCount = Math.ceil(total / input.perPage) + + // Now 'data' already contains JSON arrays of contacts & items + // thanks to the subqueries in the view definition! + return { data, pageCount } + } catch (err) { + console.error(err) + return { data: [], pageCount: 0 } + } + }, + // Cache key + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["vendors-in-investigation"], + } + )() +} + + +interface RequestInvestigateVendorsInput { + ids: number[] +} + +export async function requestInvestigateVendors({ + ids, +}: RequestInvestigateVendorsInput) { + try { + if (!ids || ids.length === 0) { + return { error: "No vendor IDs provided." } + } + + // 1. Create a new investigation row for each vendor + // You could also check if an investigation already exists for each vendor + // before inserting. For now, we’ll assume we always insert new ones. + const newRecords = await db + .insert(vendorInvestigations) + .values( + ids.map((vendorId) => ({ + vendorId + })) + ) + .returning() + + // 2. Optionally, send an email notification + // Adjust recipient, subject, and body as needed. + await sendEmail({ + to: "dujin.kim@dtsolution.io", + subject: "New Vendor Investigation(s) Requested", + // This template name could match a Handlebars file like: `investigation-request.hbs` + template: "investigation-request", + context: { + // For example, if you're translating in Korean: + language: "ko", + // Add any data you want to use within the template + vendorIds: ids, + notes: "Please initiate the planned investigations soon." + }, + }) + + // 3. Optionally, revalidate any pages that might show updated data + // revalidatePath("/your-vendors-page") // or wherever you list the vendors + + return { data: newRecords, error: null } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err) + return { error: errorMessage } + } +} + + +export async function updateVendorInvestigationAction(formData: FormData) { + try { + // 1) Separate text fields from file fields + const textEntries: Record<string, string> = {} + for (const [key, value] of formData.entries()) { + if (typeof value === "string") { + textEntries[key] = value + } + } + + // 2) Convert text-based "investigationId" to a number + if (textEntries.investigationId) { + textEntries.investigationId = String(Number(textEntries.investigationId)) + } + + // 3) Parse/validate with Zod + const parsed = updateVendorInvestigationSchema.parse(textEntries) + // parsed is type UpdateVendorInvestigationSchema + + // 4) Update the vendor_investigations table + await db + .update(vendorInvestigations) + .set({ + investigationStatus: parsed.investigationStatus, + scheduledStartAt: parsed.scheduledStartAt + ? new Date(parsed.scheduledStartAt) + : null, + scheduledEndAt: parsed.scheduledEndAt ? new Date(parsed.scheduledEndAt) : null, + completedAt: parsed.completedAt ? new Date(parsed.completedAt) : null, + investigationNotes: parsed.investigationNotes ?? "", + updatedAt: new Date(), + }) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + + // 5) Handle file attachments + // formData.getAll("attachments") can contain multiple files + const files = formData.getAll("attachments") as File[] + + // Make sure the folder exists + const uploadDir = path.join(process.cwd(), "public", "vendor-investigation") + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + + for (const file of files) { + if (file && file.size > 0) { + // Create a unique filename + const ext = path.extname(file.name) // e.g. ".pdf" + const newFileName = `${uuid()}${ext}` + + const filePath = path.join(uploadDir, newFileName) + + // 6) Write file to disk + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + fs.writeFileSync(filePath, buffer) + + // 7) Insert a record in vendor_investigation_attachments + await db.insert(vendorInvestigationAttachments).values({ + investigationId: parsed.investigationId, + fileName: file.name, // original name + filePath: `/vendor-investigation/${newFileName}`, // relative path in public/ + attachmentType: "REPORT", // or user-specified + }) + } + } + + // Revalidate anything if needed + revalidateTag("vendors-in-investigation") + + return { data: "OK", error: null } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err) + return { error: message } + } +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/feature-flags-provider.tsx b/lib/vendor-investigation/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-investigation/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/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx new file mode 100644 index 00000000..fd76a9a5 --- /dev/null +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -0,0 +1,251 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Ellipsis, Users, Boxes } from "lucide-react" +// import { toast } from "sonner" // If needed +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" // or your date util + +// Example: If you have a type for row actions +import { type DataTableRowAction } from "@/types/table" +import { ContactItem, PossibleItem, vendorInvestigationsColumnsConfig, VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + +// Props that define how we handle special columns (contacts, items, actions, etc.) +interface GetVendorInvestigationsColumnsProps { + setRowAction?: React.Dispatch< + React.SetStateAction< + DataTableRowAction<VendorInvestigationsViewWithContacts> | null + > + > + openContactsModal?: (investigationId: number, contacts: ContactItem[]) => void + openItemsDrawer?: (investigationId: number, items: PossibleItem[]) => void +} + +// This function returns the array of columns for TanStack Table +export function getColumns({ + setRowAction, + openContactsModal, + openItemsDrawer, +}: GetVendorInvestigationsColumnsProps): ColumnDef< + VendorInvestigationsViewWithContacts +>[] { + // -------------------------------------------- + // 1) Select (checkbox) column + // -------------------------------------------- + const selectColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + 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 column (optional) + // -------------------------------------------- + const actionsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const inv = row.original + + return ( + <Button + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + aria-label="Open menu" + onClick={() => { + // e.g. open a dropdown or set your row action + setRowAction?.({ type: "update", row }) + }} + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + ) + }, + size: 40, + } + + // -------------------------------------------- + // 3) Contacts column (badge count -> open modal) + // -------------------------------------------- + const contactsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "contacts", + header: "Contacts", + cell: ({ row }) => { + const { contacts, investigationId } = row.original + const count = contacts?.length ?? 0 + + const handleClick = () => { + openContactsModal?.(investigationId, contacts) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + count > 0 ? `View ${count} contacts` : "Add contacts" + } + > + <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + <span className="sr-only"> + {count > 0 ? `${count} Contacts` : "Add Contacts"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // -------------------------------------------- + // 4) Possible Items column (badge count -> open drawer) + // -------------------------------------------- + const possibleItemsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + id: "possibleItems", + header: "Items", + cell: ({ row }) => { + const { possibleItems, investigationId } = row.original + const count = possibleItems?.length ?? 0 + + const handleClick = () => { + openItemsDrawer?.(investigationId, possibleItems) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + count > 0 ? `View ${count} items` : "Add items" + } + > + <Boxes className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + <span className="sr-only"> + {count > 0 ? `${count} Items` : "Add Items"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // -------------------------------------------- + // 5) Build "grouped" columns from config + // -------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorInvestigationsViewWithContacts>[]> = {} + + vendorInvestigationsColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + const childCol: ColumnDef<VendorInvestigationsViewWithContacts> = { + accessorKey: cfg.id, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + const val = cell.getValue() + + // Example: Format date fields + if ( + cfg.id === "investigationCreatedAt" || + cfg.id === "investigationUpdatedAt" || + cfg.id === "scheduledStartAt" || + cfg.id === "scheduledEndAt" || + cfg.id === "completedAt" + ) { + const dateVal = val ? new Date(val as string) : null + return dateVal ? formatDate(dateVal) : "" + } + + // Example: You could show an icon for "investigationStatus" + if (cfg.id === "investigationStatus") { + return <span className="capitalize">{val as string}</span> + } + + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // Turn the groupMap into nested columns + const nestedColumns: ColumnDef<VendorInvestigationsViewWithContacts>[] = [] + for (const [groupName, colDefs] of Object.entries(groupMap)) { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + } + + // -------------------------------------------- + // 6) Return final columns array + // (You can reorder these as you wish.) + // -------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + contactsColumn, + possibleItemsColumn, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx new file mode 100644 index 00000000..9f89a6ac --- /dev/null +++ b/lib/vendor-investigation/table/investigation-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 { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + + +interface VendorsTableToolbarActionsProps { + table: Table<VendorInvestigationsViewWithContacts> +} + +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/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx new file mode 100644 index 00000000..fa4e2ab8 --- /dev/null +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -0,0 +1,133 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +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 { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./investigation-table-columns" +import { getVendorsInvestigation } from "../service" +import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions" +import { + VendorInvestigationsViewWithContacts, + ContactItem, + PossibleItem +} from "@/config/vendorInvestigationsColumnsConfig" +import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorsInvestigation>>, + ] + > +} + +export function VendorsInvestigationTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Get data from Suspense + const [rawResponse] = React.use(promises) + + // Transform the data to match the expected types + const transformedData: VendorInvestigationsViewWithContacts[] = React.useMemo(() => { + return rawResponse.data.map(item => { + // Parse contacts field if it's a string + let contacts: ContactItem[] = [] + if (typeof item.contacts === 'string') { + try { + contacts = JSON.parse(item.contacts) as ContactItem[] + } catch (e) { + console.error('Failed to parse contacts:', e) + } + } else if (Array.isArray(item.contacts)) { + contacts = item.contacts + } + + // Parse possibleItems field if it's a string + let possibleItems: PossibleItem[] = [] + if (typeof item.possibleItems === 'string') { + try { + possibleItems = JSON.parse(item.possibleItems) as PossibleItem[] + } catch (e) { + console.error('Failed to parse possibleItems:', e) + } + } else if (Array.isArray(item.possibleItems)) { + possibleItems = item.possibleItems + } + + // Return a new object with the transformed fields + return { + ...item, + contacts, + possibleItems + } as VendorInvestigationsViewWithContacts + }) + }, [rawResponse.data]) + + const pageCount = rawResponse.pageCount + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null) + + // Get router + const router = useRouter() + + // Call getColumns() with router injection + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [ + { id: "vendorCode", label: "Vendor Code" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorInvestigationsViewWithContacts>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + ] + + const { table } = useDataTable({ + data: transformedData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "investigationCreatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.investigationId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + <UpdateVendorInvestigationSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + investigation={rowAction?.row.original ?? null} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx new file mode 100644 index 00000000..fe30c892 --- /dev/null +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -0,0 +1,324 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Loader } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { + updateVendorInvestigationSchema, + type UpdateVendorInvestigationSchema, +} from "../validations" +import { updateVendorInvestigationAction } from "../service" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" + +/** + * The shape of `vendorInvestigation` + * might come from your `vendorInvestigationsView` row + * or your existing type for a single investigation. + */ + +interface UpdateVendorInvestigationSheetProps + extends React.ComponentPropsWithoutRef<typeof Sheet> { + investigation: VendorInvestigationsViewWithContacts | null +} + +/** + * A sheet for updating a vendor investigation (plus optional attachments). + */ +export function UpdateVendorInvestigationSheet({ + investigation, + ...props +}: UpdateVendorInvestigationSheetProps) { + const [isPending, startTransition] = React.useTransition() + + // RHF + Zod + const form = useForm<UpdateVendorInvestigationSchema>({ + resolver: zodResolver(updateVendorInvestigationSchema), + defaultValues: { + investigationId: investigation?.investigationId ?? 0, + investigationStatus: investigation?.investigationStatus ?? "PLANNED", + scheduledStartAt: investigation?.scheduledStartAt ?? undefined, + scheduledEndAt: investigation?.scheduledEndAt ?? undefined, + completedAt: investigation?.completedAt ?? undefined, + investigationNotes: investigation?.investigationNotes ?? "", + }, + }) + + React.useEffect(() => { + if (investigation) { + form.reset({ + investigationId: investigation.investigationId, + investigationStatus: investigation.investigationStatus || "PLANNED", + scheduledStartAt: investigation.scheduledStartAt ?? undefined, + scheduledEndAt: investigation.scheduledEndAt ?? undefined, + completedAt: investigation.completedAt ?? undefined, + investigationNotes: investigation.investigationNotes ?? "", + }) + } + }, [investigation, form]) + + // Format date for form data + const formatDateForFormData = (date: Date | undefined): string | null => { + if (!date) return null; + return date.toISOString(); + } + + // Submit handler + async function onSubmit(values: UpdateVendorInvestigationSchema) { + if (!values.investigationId) return + + startTransition(async () => { + // 1) Build a FormData object for the server action + const formData = new FormData() + + // Add text fields + formData.append("investigationId", String(values.investigationId)) + formData.append("investigationStatus", values.investigationStatus) + + // Format dates properly before appending to FormData + if (values.scheduledStartAt) { + const formattedDate = formatDateForFormData(values.scheduledStartAt) + if (formattedDate) formData.append("scheduledStartAt", formattedDate) + } + + if (values.scheduledEndAt) { + const formattedDate = formatDateForFormData(values.scheduledEndAt) + if (formattedDate) formData.append("scheduledEndAt", formattedDate) + } + + if (values.completedAt) { + const formattedDate = formatDateForFormData(values.completedAt) + if (formattedDate) formData.append("completedAt", formattedDate) + } + + if (values.investigationNotes) { + formData.append("investigationNotes", values.investigationNotes) + } + + // Add attachments (if any) + // Note: If you have multiple files in "attachments", we store them in the form under the same key. + const attachmentValue = form.getValues("attachments"); + if (attachmentValue instanceof FileList) { + for (let i = 0; i < attachmentValue.length; i++) { + formData.append("attachments", attachmentValue[i]); + } + } + + const { error } = await updateVendorInvestigationAction(formData) + if (error) { + toast.error(error) + return + } + + toast.success("Investigation updated!") + form.reset() + props.onOpenChange?.(false) + }) + } + + // Format date value for input field + const formatDateForInput = (date: Date | undefined): string => { + if (!date) return ""; + return date instanceof Date ? date.toISOString().slice(0, 10) : ""; + } + + // Handle date input change + const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>, onChange: (...event: any[]) => void) => { + const val = e.target.value; + if (val) { + // Ensure proper date handling by setting to noon to avoid timezone issues + const newDate = new Date(`${val}T12:00:00`); + onChange(newDate); + } else { + onChange(undefined); + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update Investigation</SheetTitle> + <SheetDescription> + Change the investigation details & attachments + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + // Must use multipart to support file uploads + encType="multipart/form-data" + > + {/* investigationStatus */} + <FormField + control={form.control} + name="investigationStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="PLANNED">PLANNED</SelectItem> + <SelectItem value="IN_PROGRESS">IN_PROGRESS</SelectItem> + <SelectItem value="COMPLETED">COMPLETED</SelectItem> + <SelectItem value="CANCELED">CANCELED</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* scheduledStartAt */} + <FormField + control={form.control} + name="scheduledStartAt" + render={({ field }) => ( + <FormItem> + <FormLabel>Scheduled Start</FormLabel> + <FormControl> + <Input + type="date" + value={formatDateForInput(field.value)} + onChange={(e) => handleDateChange(e, field.onChange)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* scheduledEndAt */} + <FormField + control={form.control} + name="scheduledEndAt" + render={({ field }) => ( + <FormItem> + <FormLabel>Scheduled End</FormLabel> + <FormControl> + <Input + type="date" + value={formatDateForInput(field.value)} + onChange={(e) => handleDateChange(e, field.onChange)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* completedAt */} + <FormField + control={form.control} + name="completedAt" + render={({ field }) => ( + <FormItem> + <FormLabel>Completed At</FormLabel> + <FormControl> + <Input + type="date" + value={formatDateForInput(field.value)} + onChange={(e) => handleDateChange(e, field.onChange)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* investigationNotes */} + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( + <FormItem> + <FormLabel>Notes</FormLabel> + <FormControl> + <Input placeholder="Notes about the investigation..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* attachments: multiple file upload */} + <FormField + control={form.control} + name="attachments" + render={({ field: { value, onChange, ...fieldProps } }) => ( + <FormItem> + <FormLabel>Attachments</FormLabel> + <FormControl> + <Input + type="file" + multiple + onChange={(e) => { + onChange(e.target.files); // Store the FileList directly + }} + {...fieldProps} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Footer Buttons */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts new file mode 100644 index 00000000..18a50022 --- /dev/null +++ b/lib/vendor-investigation/validations.ts @@ -0,0 +1,93 @@ +import { vendorInvestigationsView } from "@/db/schema/vendors" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsInvestigationCache = createSearchParamsCache({ + // Common flags + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // Paging + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // Sorting - adjusting for vendorInvestigationsView + sort: getSortingStateParser<typeof vendorInvestigationsView.$inferSelect>().withDefault([ + { id: "investigationCreatedAt", desc: true }, + ]), + + // Advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // Global search + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // Fields specific to vendor investigations + // ----------------------------------------------------------------- + + // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED + investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), + + // In case you also want to filter by vendorName, vendorCode, etc. + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + + // If you need to filter by vendor status (e.g., PQ_SUBMITTED, ACTIVE, etc.), + // you can include it here too. Example: + // vendorStatus: parseAsStringEnum([ + // "PENDING_REVIEW", + // "IN_REVIEW", + // "REJECTED", + // "IN_PQ", + // "PQ_SUBMITTED", + // "PQ_FAILED", + // "PQ_APPROVED", + // "APPROVED", + // "ACTIVE", + // "INACTIVE", + // "BLACKLISTED", + // ]).optional(), +}) + +// Finally, export the type you can use in your server action: +export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>> + + +export const updateVendorInvestigationSchema = z.object({ + investigationId: z.number(), + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), + + // If the user might send empty strings, we'll allow it by unioning with z.literal('') + // Then transform empty string to undefined + scheduledStartAt: z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + + scheduledEndAt:z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + + completedAt: z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + investigationNotes: z.string().optional(), + attachments: z.any().optional(), + }) + +export type UpdateVendorInvestigationSchema = z.infer< + typeof updateVendorInvestigationSchema +>
\ No newline at end of file |
