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>
|
||||
|
||||
Reference in New Issue
Block a user