@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useDashboardData } from "./use-dashboard-data"
|
||||
import { FinancialTotalsCards } from "./financial-totals-cards"
|
||||
@@ -12,9 +14,38 @@ import { ItemsTotalsCard } from "./items-totals-card"
|
||||
import { CustomersTotalsCard } from "./customers-totals-card"
|
||||
import { SalesPurchaseCards } from "./sales-purchase-cards"
|
||||
import { VehicleStatsCards } from "./vehicle-stats-cards"
|
||||
import { DashboardPeriods, type DashboardPeriod, type HomeDashboardQuery } from "@garage/api"
|
||||
|
||||
const DEFAULT_PERIOD: DashboardPeriod = "this_month"
|
||||
|
||||
function isDashboardPeriod(value: string | null): value is DashboardPeriod {
|
||||
return Boolean(value && (DashboardPeriods as readonly string[]).includes(value))
|
||||
}
|
||||
|
||||
export function DashboardContent() {
|
||||
const { data, isLoading, isError, error } = useDashboardData()
|
||||
const searchParams = useSearchParams()
|
||||
const filters = useMemo<HomeDashboardQuery>(() => {
|
||||
const periodValue = searchParams.get("period")
|
||||
const period = isDashboardPeriod(periodValue) ? periodValue : DEFAULT_PERIOD
|
||||
|
||||
const nextFilters: HomeDashboardQuery = { period }
|
||||
|
||||
if (period === "custom") {
|
||||
const startDate = searchParams.get("start_date")
|
||||
const endDate = searchParams.get("end_date")
|
||||
|
||||
if (startDate) {
|
||||
nextFilters.start_date = startDate
|
||||
}
|
||||
if (endDate) {
|
||||
nextFilters.end_date = endDate
|
||||
}
|
||||
}
|
||||
|
||||
return nextFilters
|
||||
}, [searchParams])
|
||||
|
||||
const { data, isLoading, isError, error } = useDashboardData(filters)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
import { DashboardPeriods, type DashboardPeriod } from "@garage/api"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
const DEFAULT_PERIOD: DashboardPeriod = "this_month"
|
||||
|
||||
const PERIOD_LABELS: Record<DashboardPeriod, string> = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
this_week: "This Week",
|
||||
this_month: "This Month",
|
||||
last_month: "Last Month",
|
||||
last_3_months: "Last 3 Months",
|
||||
last_6_months: "Last 6 Months",
|
||||
last_year: "Last Year",
|
||||
year_to_date: "Year to Date",
|
||||
all_time: "All Time",
|
||||
custom: "Custom",
|
||||
}
|
||||
|
||||
function isDashboardPeriod(value: string | null): value is DashboardPeriod {
|
||||
return Boolean(value && (DashboardPeriods as readonly string[]).includes(value))
|
||||
}
|
||||
|
||||
export function DashboardFiltersToolbar() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const period = useMemo<DashboardPeriod>(() => {
|
||||
const value = searchParams.get("period")
|
||||
return isDashboardPeriod(value) ? value : DEFAULT_PERIOD
|
||||
}, [searchParams])
|
||||
|
||||
const startDate = searchParams.get("start_date") ?? ""
|
||||
const endDate = searchParams.get("end_date") ?? ""
|
||||
|
||||
const updateParams = (updates: Record<string, string | null>) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
if (!value) {
|
||||
params.delete(key)
|
||||
return
|
||||
}
|
||||
params.set(key, value)
|
||||
})
|
||||
|
||||
const query = params.toString()
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="w-44">
|
||||
<Select
|
||||
value={period}
|
||||
onValueChange={(value) => {
|
||||
const nextPeriod = value as DashboardPeriod
|
||||
updateParams({
|
||||
period: nextPeriod,
|
||||
start_date: nextPeriod === "custom" ? startDate || null : null,
|
||||
end_date: nextPeriod === "custom" ? endDate || null : null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DashboardPeriods.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{PERIOD_LABELS[value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{period === "custom" ? (
|
||||
<>
|
||||
<div className="w-36">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => {
|
||||
updateParams({ start_date: event.target.value || null })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => {
|
||||
updateParams({ end_date: event.target.value || null })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import type { HomeDashboardResponse } from "@garage/api"
|
||||
import type { HomeDashboardQuery, HomeDashboardResponse } from "@garage/api"
|
||||
|
||||
export type DashboardData = HomeDashboardResponse & Record<string, any>
|
||||
|
||||
export function useDashboardData() {
|
||||
export function useDashboardData(filters?: HomeDashboardQuery) {
|
||||
const api = useAuthApi()
|
||||
|
||||
return useQuery<DashboardData>({
|
||||
queryKey: ["home", "dashboard"],
|
||||
queryFn: () => api.home.dashboard(),
|
||||
queryKey: ["home", "dashboard", filters],
|
||||
queryFn: () => api.home.dashboard(filters),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { toast } from "sonner"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
@@ -21,16 +21,16 @@ export function DocumentTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { title: "" },
|
||||
defaultValues: { name: "" },
|
||||
})
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const result = await api.vehicleDocuments.createDocumentType({ title: values.title } as any)
|
||||
const result = await api.vehicleDocuments.createDocumentType({ name: values.name } as any)
|
||||
toast.success("Document type created")
|
||||
form.reset()
|
||||
const item = (result as any)?.data ?? result
|
||||
onSuccess({ value: String(item.id), label: item.title ?? item.name ?? String(item.id) })
|
||||
onSuccess({ value: String(item.id), label: item.name ?? String(item.id) })
|
||||
} catch {
|
||||
toast.error("Failed to create document type")
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export function DocumentTypeInlineForm({ onSuccess }: InlineCreateFormProps) {
|
||||
<Rhform form={form} onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
<RhfTextField
|
||||
name="title"
|
||||
label="Title"
|
||||
name="name"
|
||||
label="Name"
|
||||
placeholder="e.g. Registration Certificate"
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { useFormDialog } from "@/shared/components/form-dialog"
|
||||
import { VehicleForm } from "./vehicle-form"
|
||||
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
type VehicleActionsProps = {
|
||||
@@ -18,10 +22,7 @@ type VehicleActionsProps = {
|
||||
export function VehicleActions({ vehicleId }: VehicleActionsProps) {
|
||||
const api = useAuthApi()
|
||||
const router = useRouter()
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/sales/vehicles/${vehicleId}/edit`)
|
||||
}
|
||||
const editDialog = useFormDialog("vehicle-details-edit")
|
||||
|
||||
const handleDelete = async () => {
|
||||
await api.vehicles.destroy(vehicleId)
|
||||
@@ -36,7 +37,7 @@ export function VehicleActions({ vehicleId }: VehicleActionsProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleEdit}>
|
||||
<DropdownMenuItem onSelect={() => editDialog.open(vehicleId)}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
@@ -45,6 +46,23 @@ export function VehicleActions({ vehicleId }: VehicleActionsProps) {
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
<Dialog open={editDialog.isOpen} onOpenChange={(isOpen) => { if (!isOpen) editDialog.close() }}>
|
||||
<DialogContent className="min-w-xl lg:min-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-bold">Vehicle</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[80vh] px-4">
|
||||
<VehicleForm
|
||||
|
||||
resourceId={editDialog.resourceId}
|
||||
onSuccess={() => {
|
||||
editDialog.close()
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Alert, AlertTitle } from "@/shared/components/ui/alert"
|
||||
import { FieldGroup } from "@/shared/components/ui/field"
|
||||
import {
|
||||
Rhform,
|
||||
RhfTextareaField,
|
||||
RhfTextField,
|
||||
RhfDateField,
|
||||
RhfAsyncSelectField,
|
||||
RhfDocumentField,
|
||||
} from "@/shared/components/form"
|
||||
@@ -17,6 +18,7 @@ import { useAuthApi } from "@/shared/useApi"
|
||||
import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { getFullName } from "@/shared/utils/getFullName"
|
||||
|
||||
import { vehicleDocumentFormSchema, type VehicleDocumentFormValues } from "./vehicle-document.schema"
|
||||
|
||||
@@ -33,8 +35,10 @@ export type VehicleDocumentFormProps = {
|
||||
|
||||
const DEFAULT_VALUES: VehicleDocumentFormValues = {
|
||||
document_type: null,
|
||||
customer: null,
|
||||
document_number: "",
|
||||
document_expire: "",
|
||||
file: null,
|
||||
note: "",
|
||||
}
|
||||
|
||||
// ── Mapping helpers ──
|
||||
@@ -51,17 +55,21 @@ function mapToFormValues(data: unknown): VehicleDocumentFormValues {
|
||||
|
||||
return {
|
||||
document_type: toRelation(d.document_type_id, d.document_type?.name ?? d.document_type?.title),
|
||||
customer: toRelation(d.customer_id, getFullName(d.customer) || d.customer?.name),
|
||||
document_number: d.document_number || "",
|
||||
document_expire: d.document_expire || "",
|
||||
file: null,
|
||||
note: d.note || "",
|
||||
}
|
||||
}
|
||||
|
||||
function mapToPayload(values: VehicleDocumentFormValues, vehicleId: string) {
|
||||
return {
|
||||
vehicle_id: Number(vehicleId),
|
||||
vehicle_id: vehicleId,
|
||||
document_type_id: toId(values.document_type),
|
||||
file: values.file instanceof File ? values.file : undefined,
|
||||
note: values.note || undefined,
|
||||
customer_id: toId(values.customer),
|
||||
document_number: values.document_number || undefined,
|
||||
document_expire: values.document_expire || undefined,
|
||||
document_file: values.file instanceof File ? values.file : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,18 +130,36 @@ export function VehicleDocumentForm({ vehicleId, resourceId, initialData, onSucc
|
||||
{...STORE_OBJECT}
|
||||
/>
|
||||
|
||||
{/* <RhfAsyncSelectField
|
||||
name="customer"
|
||||
label="Customer"
|
||||
placeholder="Select customer"
|
||||
queryKey={["customers"]}
|
||||
listFn={() => api.customers.list()}
|
||||
mapOption={(item: any) => ({
|
||||
value: String(item.id),
|
||||
label: getFullName(item) || item.name || `#${item.id}`,
|
||||
})}
|
||||
{...STORE_OBJECT}
|
||||
/> */}
|
||||
|
||||
<RhfTextField
|
||||
name="document_number"
|
||||
label="Document Number"
|
||||
placeholder="e.g. DOC-001"
|
||||
/>
|
||||
|
||||
<RhfDateField
|
||||
name="document_expire"
|
||||
label="Expiry Date"
|
||||
/>
|
||||
|
||||
<RhfDocumentField
|
||||
name="file"
|
||||
label="Document File"
|
||||
required
|
||||
/>
|
||||
|
||||
<RhfTextareaField
|
||||
name="note"
|
||||
label="Note"
|
||||
placeholder="Optional notes about this document"
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
{isPending ? null : isEditing ? <Save /> : <Plus />}
|
||||
{isPending ? "Saving..." : isEditing ? "Update Document" : "Upload Document"}
|
||||
|
||||
@@ -4,8 +4,10 @@ const relationFieldSchema = z.object({ value: z.string(), label: z.string() }).n
|
||||
|
||||
export const vehicleDocumentFormSchema = z.object({
|
||||
document_type: relationFieldSchema,
|
||||
customer: relationFieldSchema,
|
||||
document_number: z.string().optional(),
|
||||
document_expire: z.string().optional(),
|
||||
file: z.instanceof(File, { message: "File is required" }).nullable(),
|
||||
note: z.string().optional(),
|
||||
})
|
||||
|
||||
export type VehicleDocumentFormValues = z.infer<typeof vehicleDocumentFormSchema>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useResourceForm } from "@/shared/hooks/use-resource-form"
|
||||
import { useFormMutation } from "@/shared/hooks/use-form-mutation"
|
||||
import { toRelation, toId } from "@/shared/lib/utils"
|
||||
import { formatUppercase } from "@/shared/utils/formatters"
|
||||
import { VEHICLE_ROUTES } from "@garage/api"
|
||||
|
||||
import { vehicleFormSchema, type VehicleFormValues } from "./vehicle.schema"
|
||||
import { CustomerForm } from "../customers/customer-form"
|
||||
@@ -125,6 +126,8 @@ export function VehicleForm({ resourceId, initialData, onSuccess }: VehicleFormP
|
||||
defaultValues: DEFAULT_VALUES,
|
||||
resourceId,
|
||||
initialData,
|
||||
initialize: (id) => api.vehicles.getById(id),
|
||||
queryKey: [VEHICLE_ROUTES.BY_ID, resourceId],
|
||||
mapToFormValues,
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user