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:
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user