diff options
Diffstat (limited to 'lib/vendor-investigation/table')
5 files changed, 857 insertions, 0 deletions
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 |
