feat: add template checkpoint edit dialog and vendor management components

- Implemented TemplateCheckpointEditDialog for creating and editing inspection checkpoints.
- Added VendorActions component for managing vendor actions including edit, activate/deactivate, and delete.
- Created VendorContext for managing vendor state across components.
- Developed VendorGeneralInfo component to display detailed vendor information.
- Introduced AedSymbol and Money components for consistent currency representation.
- Added PromptDialog for user input prompts throughout the application.
- Implemented RelationLink component for unified related-data display in CRUD tables.
- Created InspectionTemplatesClient for API interactions related to inspection templates.
This commit is contained in:
2026-05-18 12:08:42 +04:00
parent 54d11f01b4
commit 4bfd8c84a9
111 changed files with 5002 additions and 1047 deletions
@@ -0,0 +1,30 @@
import { cn } from "@/shared/lib/utils"
type AedSymbolProps = {
className?: string
/** Accessible label for screen readers. Defaults to "Dirham" (do not show "AED" alongside the symbol per CBUAE guidelines). */
title?: string
}
/**
* Official UAE Dirham symbol (CBUAE, 2025).
* Glyph source: Central Bank of the UAE, released under CC0.
* Per CBUAE guidelines: place immediately before the amount, match numeral size/style,
* preserve aspect ratio (no distortion), and do not pair with the visible text "AED".
*/
export function AedSymbol({ className, title = "Dirham" }: AedSymbolProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 870"
role="img"
aria-label={title}
className={cn("inline-block h-[0.85em] w-auto align-[-0.08em]", className)}
fill="currentColor"
preserveAspectRatio="xMidYMid meet"
>
<title>{title}</title>
<path d="m88.3 1c0.4 0.6 2.6 3.3 4.7 5.9 15.3 18.2 26.8 47.8 33 85.1 4.1 24.5 4.3 32.2 4.3 125.6v87h-41.8c-38.2 0-42.6-0.2-50.1-1.7-11.8-2.5-24-9.2-32.2-17.8-6.5-6.9-6.3-7.3-5.9 13.6 0.5 17.3 0.7 19.2 3.2 28.6 4 14.9 9.5 26 17.8 35.9 11.3 13.6 22.8 21.2 39.2 26.3 3.5 1 10.9 1.4 37.1 1.6l32.7 0.5v43.3 43.4l-46.1-0.3-46.3-0.3-8-3.2c-9.5-3.8-13.8-6.6-23.1-14.9l-6.8-6.1 0.4 19.1c0.5 17.7 0.6 19.7 3.1 28.7 8.7 31.8 29.7 54.5 57.4 61.9 6.9 1.9 9.6 2 38.5 2.4l30.9 0.4v89.6c0 54.1-0.3 94-0.8 100.8-0.5 6.2-2.1 17.8-3.5 25.9-6.5 37.3-18.2 65.4-35 83.6l-3.4 3.7h169.1c101.1 0 176.7-0.4 187.8-0.9 19.5-1 63-5.3 72.8-7.4 3.1-0.6 8.9-1.5 12.7-2.1 8.1-1.2 21.5-4 40.8-8.9 27.2-6.8 52-15.3 76.3-26.1 7.6-3.4 29.4-14.5 35.2-18 3.1-1.8 6.8-4 8.2-4.7 3.9-2.1 10.4-6.3 19.9-13.1 4.7-3.4 9.4-6.7 10.4-7.4 4.2-2.8 18.7-14.9 25.3-21 25.1-23.1 46.1-48.8 62.4-76.3 2.3-4 5.3-9 6.6-11.1 3.3-5.6 16.9-33.6 18.2-37.8 0.6-1.9 1.4-3.9 1.8-4.3 2.6-3.4 17.6-50.6 19.4-60.9 0.6-3.3 0.9-3.8 3.4-4.3 1.6-0.3 24.9-0.3 51.8-0.1 53.8 0.4 53.8 0.4 65.7 5.9 6.7 3.1 8.7 4.5 16.1 11.2 9.7 8.7 8.8 10.1 8.2-11.7-0.4-12.8-0.9-20.7-1.8-23.9-3.4-12.3-4.2-14.9-7.2-21.1-9.8-21.4-26.2-36.7-47.2-44l-8.2-3-33.4-0.4-33.3-0.5 0.4-11.7c0.4-15.4 0.4-45.9-0.1-61.6l-0.4-12.6 44.6-0.2c38.2-0.2 45.3 0 49.5 1.1 12.6 3.5 21.1 8.3 31.5 17.8l5.8 5.4v-14.8c0-17.6-0.9-25.4-4.5-37-7.1-23.5-21.1-41-41.1-51.8-13-7-13.8-7.2-58.5-7.5-26.2-0.2-39.9-0.6-40.6-1.2-0.6-0.6-1.1-1.6-1.1-2.4 0-0.8-1.5-7.1-3.5-13.9-23.4-82.7-67.1-148.4-131-197.1-8.7-6.7-30-20.8-38.6-25.6-3.3-1.9-6.9-3.9-7.8-4.5-4.2-2.3-28.3-14.1-34.3-16.6-3.6-1.6-8.3-3.6-10.4-4.4-35.3-15.3-94.5-29.8-139.7-34.3-7.4-0.7-17.2-1.8-21.7-2.2-20.4-2.3-48.7-2.6-209.4-2.6-135.8 0-169.9 0.3-169.4 1zm330.7 43.3c33.8 2 54.6 4.6 78.9 10.5 74.2 17.6 126.4 54.8 164.3 117 3.5 5.8 18.3 36 20.5 42.1 10.5 28.3 15.6 45.1 20.1 67.3 1.1 5.4 2.6 12.6 3.3 16 0.7 3.3 1 6.4 0.7 6.7-0.5 0.4-100.9 0.6-223.3 0.5l-222.5-0.2-0.3-128.5c-0.1-70.6 0-129.3 0.3-130.4l0.4-1.9h71.1c39 0 78 0.4 86.5 0.9zm297.5 350.3c0.7 4.3 0.7 77.3 0 80.9l-0.6 2.7-227.5-0.2-227.4-0.3-0.2-42.4c-0.2-23.3 0-42.7 0.2-43.1 0.3-0.5 97.2-0.8 227.7-0.8h227.2zm-10.2 171.7c0.5 1.5-1.9 13.8-6.8 33.8-5.6 22.5-13.2 45.2-20.9 62-3.8 8.6-13.3 27.2-15.6 30.7-1.1 1.6-4.3 6.7-7.1 11.2-18 28.2-43.7 53.9-73 72.9-10.7 6.8-32.7 18.4-38.6 20.2-1.2 0.3-2.5 0.9-3 1.3-0.7 0.6-9.8 4-20.4 7.8-19.5 6.9-56.6 14.4-86.4 17.5-19.3 1.9-22.4 2-96.7 2h-76.9v-129.7-129.8l220.9-0.4c121.5-0.2 221.6-0.5 222.4-0.7 0.9-0.1 1.8 0.5 2.1 1.2z" />
</svg>
)
}
@@ -1,9 +1,10 @@
"use client"
import type { ReactNode } from "react"
import { Separator } from "@/shared/components/ui/separator"
import { formatCurrency } from "@/shared/utils/formatters"
import { cn } from "@/shared/lib/utils"
import type { DocumentTotals } from "@/shared/hooks/use-document-totals"
import { Money } from "@/shared/components/money"
export type DocumentTotalsSummaryProps = {
totals: DocumentTotals
@@ -20,7 +21,7 @@ function SummaryRow({
className,
}: {
label: string
value: string
value: ReactNode
muted?: boolean
className?: string
}) {
@@ -58,7 +59,7 @@ export function DocumentTotalsSummary({
<SummaryRow
key={label}
label={label}
value={formatCurrency(amount)}
value={<Money value={amount} />}
muted
/>
))}
@@ -66,13 +67,13 @@ export function DocumentTotalsSummary({
{groupLabels && Object.keys(groupLabels).length > 0 && <Separator />}
{/* Subtotal */}
<SummaryRow label="Subtotal" value={formatCurrency(subTotal)} />
<SummaryRow label="Subtotal" value={<Money value={subTotal} />} />
{/* Line-item discount */}
{discountType === "line_item_level" && lineItemDiscount > 0 && (
<SummaryRow
label="Line Discounts"
value={`– ${formatCurrency(lineItemDiscount)}`}
value={<Money value={lineItemDiscount} prefix="– " />}
muted
/>
)}
@@ -81,7 +82,7 @@ export function DocumentTotalsSummary({
{discountType === "transaction_level" && transactionDiscount > 0 && (
<SummaryRow
label="Discount"
value={`– ${formatCurrency(transactionDiscount)}`}
value={<Money value={transactionDiscount} prefix="– " />}
muted
/>
)}
@@ -90,7 +91,7 @@ export function DocumentTotalsSummary({
{taxAmount > 0 && (
<SummaryRow
label={taxLabel ?? "Tax"}
value={`+ ${formatCurrency(taxAmount)}`}
value={<Money value={taxAmount} prefix="+ " />}
muted
/>
)}
@@ -103,7 +104,7 @@ export function DocumentTotalsSummary({
"bg-primary/10 text-primary",
)}>
<span>Total</span>
<span>{formatCurrency(total)}</span>
<span><Money value={total} /></span>
</div>
</div>
)
@@ -1,47 +0,0 @@
"use client"
import { useState } from "react"
import { Button } from "@/shared/components/ui/button"
import { Download, Loader2 } from "lucide-react"
import { toast } from "sonner"
type DownloadSampleButtonProps = {
onDownload: () => Promise<Blob>
fileName?: string
label?: string
}
export function DownloadSampleButton({
onDownload,
fileName = "import-sample",
label = "Sample",
}: DownloadSampleButtonProps) {
const [isPending, setIsPending] = useState(false)
const handleDownload = async () => {
setIsPending(true)
try {
const blob = await onDownload()
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `${fileName}.xlsx`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
toast.success("Sample downloaded successfully")
} catch (err: any) {
toast.error(err?.message ?? "Failed to download sample")
} finally {
setIsPending(false)
}
}
return (
<Button size="sm" variant="outline" disabled={isPending} onClick={handleDownload}>
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
{label}
</Button>
)
}
@@ -2,7 +2,15 @@
import React, { useRef, useState } from "react"
import { Button } from "@/shared/components/ui/button"
import { Download, Loader2 } from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Download, FileSpreadsheet, Loader2, UploadCloud } from "lucide-react"
import { toast } from "sonner"
import { ImportResultsDialog } from "@/shared/components/import-results-dialog"
import type { ImportDataResponse } from "@garage/api"
@@ -13,29 +21,70 @@ type ImportDataButtonProps = {
accept?: string
label?: string
entityLabel?: string
onDownloadSample?: () => Promise<Blob>
sampleFileName?: string
notes?: React.ReactNode[]
}
const DEFAULT_NOTES: React.ReactNode[] = [
"Download the sample file and use it as a template — keep the column headers exactly as provided.",
"Supported file formats: .xlsx, .xls and .csv.",
"Fields marked as required in the sample must not be empty.",
"Existing records will not be overwritten. Each row is imported as a new record.",
"After uploading, you'll see a results summary with any rows that failed and why.",
]
export function ImportDataButton({
onImport,
onSuccess,
accept = ".xlsx,.xls,.csv",
label = "Import",
entityLabel,
onDownloadSample,
sampleFileName,
notes,
}: ImportDataButtonProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isPending, setIsPending] = useState(false)
const [stepsOpen, setStepsOpen] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isDownloadingSample, setIsDownloadingSample] = useState(false)
const [result, setResult] = useState<ImportDataResponse["data"] | null>(null)
const [resultOpen, setResultOpen] = useState(false)
const effectiveNotes = notes && notes.length > 0 ? notes : DEFAULT_NOTES
const entityForCopy = entityLabel ?? "records"
const handleDownloadSample = async () => {
if (!onDownloadSample) return
setIsDownloadingSample(true)
try {
const blob = await onDownloadSample()
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `${sampleFileName ?? "import-sample"}.xlsx`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
toast.success("Sample downloaded successfully")
} catch (err: any) {
toast.error(err?.message ?? "Failed to download sample")
} finally {
setIsDownloadingSample(false)
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsPending(true)
setIsImporting(true)
try {
const response = await onImport(file)
const data = response.data ?? { imported_count: 0, failed_count: 0, failed_rows: [] }
setResult(data)
setStepsOpen(false)
setResultOpen(true)
if (data.failed_count === 0) {
@@ -46,7 +95,7 @@ export function ImportDataButton({
} catch (err: any) {
toast.error(err?.message ?? "Failed to import data")
} finally {
setIsPending(false)
setIsImporting(false)
if (inputRef.current) inputRef.current.value = ""
}
}
@@ -63,12 +112,86 @@ export function ImportDataButton({
<Button
size="sm"
variant="outline"
disabled={isPending}
onClick={() => inputRef.current?.click()}
onClick={() => setStepsOpen(true)}
>
{isPending ? <Loader2 className="animate-spin" /> : <Download />}
<Download />
{label}
</Button>
<Dialog
open={stepsOpen}
onOpenChange={(open) => {
if (isImporting) return
setStepsOpen(open)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Import {entityForCopy.toLowerCase()}</DialogTitle>
<DialogDescription>
Follow these steps to import {entityForCopy.toLowerCase()} from a spreadsheet.
</DialogDescription>
</DialogHeader>
{onDownloadSample && (
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-3">
<FileSpreadsheet className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm font-medium">Step 1 — Download the sample file</p>
<p className="text-xs text-muted-foreground">
Use it as a template so columns are recognized correctly.
</p>
</div>
<Button
size="sm"
variant="outline"
disabled={isDownloadingSample}
onClick={handleDownloadSample}
>
{isDownloadingSample ? (
<Loader2 className="animate-spin" />
) : (
<Download />
)}
Sample
</Button>
</div>
)}
<div>
<p className="mb-2 text-sm font-medium">
{onDownloadSample ? "Step 2 — Notes before you upload" : "Notes before you upload"}
</p>
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
{effectiveNotes.map((note, idx) => (
<li key={idx}>{note}</li>
))}
</ul>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setStepsOpen(false)}
disabled={isImporting}
>
Cancel
</Button>
<Button
disabled={isImporting}
onClick={() => inputRef.current?.click()}
>
{isImporting ? (
<Loader2 className="animate-spin" />
) : (
<UploadCloud />
)}
{isImporting ? "Uploading..." : "Choose file & upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{result && (
<ImportResultsDialog
open={resultOpen}
@@ -0,0 +1,48 @@
import { AedSymbol } from "./aed-symbol"
import { cn } from "@/shared/lib/utils"
type MoneyProps = {
value?: number | string | null
/** Prepended before the symbol (e.g. "–" or "+"). */
prefix?: string
/** Hide the AED symbol — useful when the column header already conveys currency. */
hideSymbol?: boolean
/** Locale override for number grouping. */
locale?: string
/** Minimum/maximum fraction digits. Defaults to 2. */
fractionDigits?: number
/** Fallback rendered when value is null/undefined/NaN. */
fallback?: string
className?: string
}
function formatAmount(value: number | string | null | undefined, locale: string | undefined, digits: number): string | null {
if (value == null || value === "") return null
const num = typeof value === "string" ? Number(value) : value
if (Number.isNaN(num)) return null
return new Intl.NumberFormat(locale, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(num)
}
export function Money({
value,
prefix,
hideSymbol = false,
locale,
fractionDigits = 2,
fallback = "—",
className,
}: MoneyProps) {
const formatted = formatAmount(value, locale, fractionDigits)
if (formatted == null) return <span className={className}>{fallback}</span>
return (
<span className={cn("inline-flex items-center gap-[0.2em] whitespace-nowrap", className)}>
{prefix ? <span>{prefix}</span> : null}
{!hideSymbol ? <AedSymbol /> : null}
<span>{formatted}</span>
</span>
)
}
@@ -0,0 +1,121 @@
"use client"
import { useEffect, useState } from "react"
import { create } from "zustand"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
// ── Types ──
export type PromptOptions = {
title?: string
description?: string
label?: string
placeholder?: string
defaultValue?: string
confirmLabel?: string
cancelLabel?: string
required?: boolean
}
type PromptStore = {
open: boolean
options: PromptOptions
resolve: ((value: string | null) => void) | null
_show: (options: PromptOptions) => Promise<string | null>
_close: (value: string | null) => void
}
// ── Store ──
const usePromptStore = create<PromptStore>((set, get) => ({
open: false,
options: {},
resolve: null,
_show: (options) =>
new Promise<string | null>((resolve) => {
set({ open: true, options, resolve })
}),
_close: (value) => {
const { resolve } = get()
resolve?.(value)
set({ open: false, resolve: null })
},
}))
// ── Imperative API ──
export function prompt(options: PromptOptions = {}): Promise<string | null> {
return usePromptStore.getState()._show(options)
}
// ── Dialog component (mount once in root layout) ──
export function PromptDialog() {
const { open, options, _close } = usePromptStore()
const [value, setValue] = useState("")
// Reset value whenever the dialog opens
useEffect(() => {
if (open) setValue(options.defaultValue ?? "")
}, [open, options.defaultValue])
const submit = () => {
if (options.required !== false && value.trim() === "") return
_close(value.trim())
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) _close(null)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{options.title ?? "Enter a value"}</DialogTitle>
{options.description && (
<DialogDescription>{options.description}</DialogDescription>
)}
</DialogHeader>
<div className="grid gap-2">
{options.label && <Label htmlFor="prompt-input">{options.label}</Label>}
<Input
id="prompt-input"
autoFocus
value={value}
placeholder={options.placeholder}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
submit()
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => _close(null)}>
{options.cancelLabel ?? "Cancel"}
</Button>
<Button onClick={submit} disabled={options.required !== false && value.trim() === ""}>
{options.confirmLabel ?? "OK"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,78 @@
"use client"
import Link from "next/link"
import type { ComponentType, MouseEvent, ReactNode } from "react"
import { Button, buttonVariants } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
type IconComponent = ComponentType<{ className?: string }>
export type RelationLinkProps = {
/** Destination href. When falsy the chip renders as a non-interactive button-styled span. */
href?: string | null
/** Primary label rendered inside the button. */
label?: ReactNode
/** Optional icon shown before the label. */
icon?: IconComponent
/** Rendered when label is empty (with or without href). Defaults to "—". */
fallback?: ReactNode
/** Optional secondary text rendered after the label (e.g. phone, plate). */
meta?: ReactNode
className?: string
}
/**
* Unified cell for related-data columns across every CRUD table.
* Renders an outlined button-style chip either way:
* - With href: clickable next/link wrapped by a Button (asChild).
* - Without href: same outline styling on a plain span so visual columns
* stay consistent across resources that don't have detail routes.
*/
export function RelationLink({
href,
label,
icon: Icon,
fallback = "—",
meta,
className,
}: RelationLinkProps) {
const hasLabel = label != null && label !== ""
if (!hasLabel && !href) {
return <span className="text-muted-foreground">{fallback}</span>
}
const body = (
<>
{Icon ? <Icon /> : null}
<span>{hasLabel ? label : fallback}</span>
{meta ? <span className="ms-1 text-muted-foreground">· {meta}</span> : null}
</>
)
if (!href) {
return (
<span
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"cursor-default pointer-events-none",
className,
)}
>
{body}
</span>
)
}
return (
<Button
variant="outline"
size="sm"
asChild
className={className}
onClick={(event: MouseEvent<HTMLButtonElement>) => event.stopPropagation()}
>
<Link href={href}>{body}</Link>
</Button>
)
}
@@ -11,7 +11,6 @@ import {
} from "@/shared/components/ui/dialog"
import { CrudResource, type CrudResourceProps } from "@/shared/data-view/resource-page"
import type { ResourcePageClient, ResourceItem } from "@/shared/data-view/resource-page"
import { Card, CardContent } from "@/shared/components/ui/card"
export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
/** Dialog title shown in the header */
@@ -31,6 +30,10 @@ export type ResourceSelectorDialogProps<TClient extends ResourcePageClient> = {
* - "single": row click immediately fires onConfirm with the clicked row
*/
selectionMode?: "single" | "multi"
/** Show a search input above the table. Defaults to true. */
searchable?: boolean
/** Placeholder for the search input. */
searchPlaceholder?: string
}
export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
@@ -41,6 +44,8 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
crudProps,
rowKey,
selectionMode = "multi",
searchable = true,
searchPlaceholder = "Search...",
}: ResourceSelectorDialogProps<TClient>) {
type TItem = ResourceItem<TClient>
@@ -70,33 +75,36 @@ export function ResourceSelectorDialog<TClient extends ResourcePageClient>({
onOpenChange(false)
}
const sharedCrudProps = {
...crudProps,
localState: crudProps.localState ?? true,
searchable: crudProps.searchable ?? searchable,
searchPlaceholder: crudProps.searchPlaceholder ?? searchPlaceholder,
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) handleCancel() }}>
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col">
<DialogContent className="min-w-4xl max-h-[90vh] flex flex-col gap-3 p-4">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto">
<Card className="rounded-none border-0 shadow-none">
<CardContent className="p-0">
{selectionMode === "single" ? (
<CrudResource<TClient>
{...crudProps}
onRowClick={handleRowClick}
/>
) : (
<CrudResource<TClient>
{...crudProps}
tableProps={{
selection: {
onSelectionChange: handleSelectionChange,
...(rowKey ? { rowKey } : {}),
},
}}
/>
)}
</CardContent>
</Card>
<div className="flex-1 overflow-auto min-h-0">
{selectionMode === "single" ? (
<CrudResource<TClient>
{...sharedCrudProps}
onRowClick={handleRowClick}
/>
) : (
<CrudResource<TClient>
{...sharedCrudProps}
tableProps={{
selection: {
onSelectionChange: handleSelectionChange,
...(rowKey ? { rowKey } : {}),
},
}}
/>
)}
</div>
{selectionMode === "multi" && (
<DialogFooter>
@@ -1,10 +1,27 @@
"use client"
import React from "react"
import React, { useEffect, useMemo, useRef, useState } from "react"
import { SearchIcon } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import { formatEnum } from "@/shared/utils/formatters"
import { DataTable, type ActionsColumnOptions, type DataViewProps } from "@/shared/data-view/table-view"
import { useResourcePage, type UseResourcePageOptions, type ResourceItem, type ResourcePageClient } from "./use-resource-page"
import type { ColumnDef } from "@tanstack/react-table"
export type StatusFilterConfig = {
/** Allowed status values (typically the matching `<Resource>Status` enum). */
statuses: readonly string[]
/** Query-param key forwarded to the API. Defaults to "status". */
paramKey?: string
/** Initial selected tab. Defaults to "all". */
defaultValue?: string
/** Label for the catch-all tab. Defaults to "All". */
allLabel?: string
/** Per-status label transform. Defaults to formatEnum (snake_case → Title Case). */
formatLabel?: (status: string) => string
}
type LooseColumnDef<TData> = ColumnDef<TData, unknown> | (Omit<ColumnDef<TData, unknown>, "accessorKey"> & { accessorKey: string })
export type CrudResourceColumnHelpers<TClient extends ResourcePageClient> = {
@@ -38,6 +55,12 @@ export type CrudResourceProps<TClient extends ResourcePageClient> = UseResourceP
tableHeader?: ReactNodeOrRender<TClient>
tableProps?: Omit<Partial<DataViewProps<ResourceItem<TClient>>>, ManagedTableProps>
render?: (table: React.ReactElement, context: CrudResourceContext<TClient>) => React.ReactElement
/** Render a built-in search input above the table and forward `search` to the API. */
searchable?: boolean
/** Placeholder for the built-in search input. */
searchPlaceholder?: string
/** Render a built-in status tab strip above the table and forward the selected status to the API. */
statusFilter?: StatusFilterConfig
}
export function CrudResource<TClient extends ResourcePageClient>({
@@ -47,14 +70,62 @@ export function CrudResource<TClient extends ResourcePageClient>({
queryOptions,
paramKey,
extraParams,
localState,
onRowClick,
tableHeader,
tableProps,
render,
searchable = false,
searchPlaceholder = "Search...",
statusFilter,
}: CrudResourceProps<TClient>) {
type TItem = ResourceItem<TClient>
const page = useResourcePage<TClient>({ routeKey, getClient, queryOptions, paramKey, extraParams })
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const statusParamKey = statusFilter?.paramKey ?? "status"
const statusAllValue = statusFilter?.defaultValue ?? "all"
const [statusValue, setStatusValue] = useState<string>(statusAllValue)
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timer)
}, [searchInput])
const mergedExtraParams = useMemo(() => {
const base = { ...(extraParams ?? {}) } as Record<string, unknown>
if (searchable && search) base.search = search
if (statusFilter && statusValue !== statusAllValue) {
base[statusParamKey] = statusValue
}
return base
}, [extraParams, search, searchable, statusFilter, statusValue, statusAllValue, statusParamKey])
const page = useResourcePage<TClient>({
routeKey,
getClient,
queryOptions,
paramKey,
extraParams: mergedExtraParams,
localState,
})
const prevSearchRef = useRef(search)
const prevStatusRef = useRef(statusValue)
const setParamsRef = useRef(page.setParams)
setParamsRef.current = page.setParams
useEffect(() => {
if (prevSearchRef.current !== search) {
prevSearchRef.current = search
setParamsRef.current({ page: 1 })
}
}, [search])
useEffect(() => {
if (prevStatusRef.current !== statusValue) {
prevStatusRef.current = statusValue
setParamsRef.current({ page: 1 })
}
}, [statusValue])
const columns = typeof columnsProp === "function"
? columnsProp({
@@ -81,9 +152,49 @@ export function CrudResource<TClient extends ResourcePageClient>({
invalidateQuery: () => page.invalidateQuery(),
}
const searchInputEl = searchable ? (
<div className="relative w-64">
<SearchIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={searchPlaceholder}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8"
/>
</div>
) : null
const statusTabsEl = statusFilter ? (
<Tabs value={statusValue} onValueChange={setStatusValue}>
<TabsList variant="line">
<TabsTrigger value={statusAllValue}>{statusFilter.allLabel ?? "All"}</TabsTrigger>
{statusFilter.statuses.map((status) => (
<TabsTrigger key={status} value={status}>
{(statusFilter.formatLabel ?? formatEnum)(status)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
) : null
const resolvedTableHeader = tableHeader
? (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)
: null
const leftHeader = statusTabsEl ?? resolvedTableHeader
const extraHeaderAbove = statusTabsEl && resolvedTableHeader ? resolvedTableHeader : null
const headerRow = (searchInputEl || leftHeader) ? (
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex-1 min-w-0">{leftHeader}</div>
{searchInputEl}
</div>
) : null
const table = (
<>
{tableHeader && (typeof tableHeader === "function" ? tableHeader(context) : tableHeader)}
{extraHeaderAbove}
{headerRow}
<DataTable
{...tableProps}
columns={columns as ColumnDef<TItem, any>[]}
@@ -20,4 +20,5 @@ export type {
CrudResourceColumnHelpers,
CrudResourceContext,
CrudResourceProps,
StatusFilterConfig,
} from "./crud-resource"
@@ -26,6 +26,8 @@ export type UseResourcePageOptions<TClient extends ResourcePageClient> = {
queryOptions?: Omit<UseQueryOptions<CrudListResponse<TClient>>, "queryKey" | "queryFn">
paramKey?: string
extraParams?: Record<string, unknown>
/** When true, pagination/sorting state is local instead of URL-synced. Useful for tables inside dialogs. */
localState?: boolean
}
export function useResourcePage<TClient extends ResourcePageClient>({
@@ -34,6 +36,7 @@ export function useResourcePage<TClient extends ResourcePageClient>({
queryOptions,
paramKey,
extraParams,
localState,
}: UseResourcePageOptions<TClient>) {
type TItem = ResourceItem<TClient>
@@ -51,6 +54,7 @@ export function useResourcePage<TClient extends ResourcePageClient>({
client,
queryOptions,
extraParams,
localState,
})
const { mutateAsync: deleteItem } = useMutation({
@@ -59,8 +63,11 @@ export function useResourcePage<TClient extends ResourcePageClient>({
const promise = client.destroy(id)
toast.promise(promise, {
loading: "Deleting...",
success: "Deleted successfully",
error: "Failed to delete",
success: (data: any) => data?.message ?? "Deleted successfully",
// Surface the backend's actual error (validation/constraint message)
// so a failed delete doesn't appear silent to the user.
error: (err: any) =>
err?.payload?.message ?? err?.message ?? "Failed to delete",
})
return promise
},
@@ -87,7 +94,13 @@ export function useResourcePage<TClient extends ResourcePageClient>({
confirmLabel: "Delete",
variant: "destructive",
})
if (confirmed) await deleteItem(String(row.id))
if (!confirmed) return
try {
await deleteItem(String(row.id))
} catch {
// toast.promise already surfaced the error; swallow to avoid
// an unhandled promise rejection bubbling up from the menu click.
}
},
...options,
})
@@ -1,6 +1,7 @@
"use client"
import type { ColumnDef, Row } from "@tanstack/react-table"
import type { ComponentType } from "react"
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import {
@@ -10,9 +11,20 @@ import {
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
export type ActionsColumnExtraItem<TData> = {
label: string
icon?: ComponentType<{ className?: string }>
onClick: (row: TData) => void | Promise<unknown>
variant?: "default" | "destructive"
/** Hide the item entirely when false. Useful for permission gates. */
hidden?: boolean
}
export type ActionsColumnOptions<TData> = {
onEdit?: (row: TData) => void
onDelete?: (row: TData) => Promise<unknown>
/** Extra menu items rendered between Edit and Delete. */
extraItems?: (row: TData) => ActionsColumnExtraItem<TData>[]
}
export function createActionsColumn<TData extends { id: string | number }>(
@@ -53,6 +65,24 @@ function ActionsCell<TData extends { id: string | number }>({
Edit
</DropdownMenuItem>
)}
{options.extraItems?.(row.original)
.filter((item) => !item.hidden)
.map((item) => {
const Icon = item.icon
return (
<DropdownMenuItem
key={item.label}
variant={item.variant}
onClick={(e) => {
e.stopPropagation()
void item.onClick(row.original)
}}
>
{Icon ? <Icon className="size-3.5 text-muted-foreground" /> : null}
{item.label}
</DropdownMenuItem>
)
})}
{options.onDelete && (
<DropdownMenuItem
variant="destructive"
@@ -1,20 +1,30 @@
"use client"
import { useState } from "react"
import { useQueryStates } from "nuqs"
import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"
import type { DataViewChangeEvent, DataViewPaginationState, DataViewSorting } from "./types"
import { dataTableSearchParams } from "./search-params"
import { type CrudListResponse } from "@garage/api"
import { DEFAULT_PER_PAGE, type CrudListResponse } from "@garage/api"
type DataTableClient = {
list(query?: any): Promise<any>
}
type DataTableParams = {
page: number
per_page: number
sort_by: string | null
sort_order: "asc" | "desc" | null
}
type UseDataTableQueryOptions<C extends DataTableClient> = {
queryKey: string[]
client: C
queryOptions?: Omit<UseQueryOptions<CrudListResponse<C>>, "queryKey" | "queryFn">
extraParams?: Record<string, unknown>
/** When true, pagination/sorting state lives in component state instead of URL params. Used for tables inside dialogs so they don't share state with the parent page. */
localState?: boolean
}
export function useDataTableQuery<C extends DataTableClient>({
@@ -22,8 +32,25 @@ export function useDataTableQuery<C extends DataTableClient>({
client,
queryOptions,
extraParams,
localState = false,
}: UseDataTableQueryOptions<C>) {
const [params, setParams] = useQueryStates(dataTableSearchParams)
const [urlParams, setUrlParams] = useQueryStates(dataTableSearchParams)
const [localParams, setLocalParams] = useState<DataTableParams>({
page: 1,
per_page: DEFAULT_PER_PAGE,
sort_by: null,
sort_order: null,
})
const params: DataTableParams = localState ? localParams : urlParams
const setParams = (next: Partial<DataTableParams>) => {
if (localState) {
setLocalParams((prev) => ({ ...prev, ...next }))
} else {
setUrlParams(next)
}
}
const _queryKey = [...queryKey, params, ...(extraParams ? [extraParams] : [])]
const query = useQuery<CrudListResponse<C>>({
queryKey: _queryKey,
@@ -74,7 +101,10 @@ export function useDataTableQuery<C extends DataTableClient>({
const queryClient = useQueryClient();
const invalidateQuery = () => {
queryClient.invalidateQueries({ queryKey: _queryKey })
// Prefix-match on the base route key so all paginated/sorted variants of
// the list refetch — exact-matching the params object is flaky because
// nuqs returns a fresh reference per render.
queryClient.invalidateQueries({ queryKey, refetchType: "active" })
}
return {
+12 -1
View File
@@ -2,10 +2,21 @@ import { useMemo } from "react";
import { createApi } from "@garage/api";
import { useAuthStore } from "./stores/auth-store";
// The dashboard UI is in English. Tell the backend explicitly so error messages
// don't default to Arabic when no Accept-Language is sent / saved preference
// matches. The backend `SetApiLocale` middleware honors this header.
const UI_LANG = "en"
export const useAuthApi = () => {
const token = useAuthStore(s => s.token)
return useMemo(
() => createApi({ headers: token ? { Authorization: `Bearer ${token}` } : undefined }),
() => createApi({
headers: {
"X-Lang": UI_LANG,
"Accept-Language": UI_LANG,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
}),
[token],
)
}
+9 -3
View File
@@ -87,17 +87,23 @@ export function formatNumber(value?: number | string | null): string {
}
/**
* Format a numeric value as currency: "$1,500.00"
* Plain-string currency formatter. For JSX use the <Money/> component which
* renders the new AED dirham SVG. This string variant falls back to the "AED"
* prefix so toasts, exports, and other non-JSX contexts stay readable.
*/
export function formatCurrency(
value?: number | string | null,
currency = "AED",
locale?: string,
fractionDigits = 2,
): string {
if (value == null || value === "") return "—"
const num = typeof value === "string" ? Number(value) : value
if (isNaN(num)) return "—"
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(num)
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(num)
return `AED ${formatted}`
}
/**