updates
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarCheck, CalendarX, Eye, Ban } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function AppointmentsSummaryCard({ data }: Props) {
|
||||
const totals = data.appointments_summary?.totals
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: "Completed",
|
||||
value: totals?.completed?.text ?? "0 Appt.",
|
||||
icon: CalendarCheck,
|
||||
color: "text-emerald-600",
|
||||
bg: "bg-emerald-500/10",
|
||||
},
|
||||
{
|
||||
label: "No Shows",
|
||||
value: totals?.no_shows?.text ?? "0 Appt.",
|
||||
icon: Eye,
|
||||
color: "text-amber-600",
|
||||
bg: "bg-amber-500/10",
|
||||
},
|
||||
{
|
||||
label: "No-Show Rate",
|
||||
value: totals?.no_shows_rate?.text ?? "0%",
|
||||
icon: Ban,
|
||||
color: "text-red-600",
|
||||
bg: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
label: "Cancelled",
|
||||
value: totals?.cancelled?.text ?? "0 Appt.",
|
||||
icon: CalendarX,
|
||||
color: "text-slate-600",
|
||||
bg: "bg-slate-500/10",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appointments Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex items-center gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className={`rounded-md p-2 ${stat.bg}`}>
|
||||
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{stat.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import { Users, Building2, Truck, Shield } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function CustomersTotalsCard({ data }: Props) {
|
||||
const customers = data.customers_totals
|
||||
|
||||
const stats = [
|
||||
{ label: "Individuals", value: customers?.individuals ?? 0, icon: Users, color: "text-sky-600", bg: "bg-sky-500/10" },
|
||||
{ label: "Companies", value: customers?.companies ?? 0, icon: Building2, color: "text-indigo-600", bg: "bg-indigo-500/10" },
|
||||
{ label: "Fleets", value: customers?.fleets ?? 0, icon: Truck, color: "text-teal-600", bg: "bg-teal-500/10" },
|
||||
{ label: "Insurers", value: customers?.insurers ?? 0, icon: Shield, color: "text-rose-600", bg: "bg-rose-500/10" },
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">Customers</CardTitle>
|
||||
<span className="text-2xl font-bold">{customers?.total_customers ?? 0}</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`rounded-md p-1.5 ${stat.bg}`}>
|
||||
<stat.icon className={`h-3.5 w-3.5 ${stat.color}`} />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useDashboardData } from "./use-dashboard-data"
|
||||
import { FinancialTotalsCards } from "./financial-totals-cards"
|
||||
import { IncomeExpenseChart } from "./income-expense-chart"
|
||||
import { FinancialSummaryChart } from "./financial-summary-chart"
|
||||
import { WorkOrdersStatusCard } from "./work-orders-status-card"
|
||||
import { AppointmentsSummaryCard } from "./appointments-summary-card"
|
||||
import { UpcomingAppointmentsCard } from "./upcoming-appointments-card"
|
||||
import { ItemsTotalsCard } from "./items-totals-card"
|
||||
import { CustomersTotalsCard } from "./customers-totals-card"
|
||||
import { SalesPurchaseCards } from "./sales-purchase-cards"
|
||||
import { VehicleStatsCards } from "./vehicle-stats-cards"
|
||||
|
||||
export function DashboardContent() {
|
||||
const { data, isLoading, isError, error } = useDashboardData()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<p className="text-lg font-medium">Failed to load dashboard</p>
|
||||
<p className="text-sm">{error?.message ?? "An unexpected error occurred"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Financial Overview */}
|
||||
<FinancialTotalsCards data={data} />
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<IncomeExpenseChart data={data} />
|
||||
<FinancialSummaryChart data={data} />
|
||||
</div>
|
||||
|
||||
{/* Work Orders + Appointments */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<WorkOrdersStatusCard data={data} />
|
||||
<AppointmentsSummaryCard data={data} />
|
||||
</div>
|
||||
|
||||
{/* Upcoming Appointments */}
|
||||
<UpcomingAppointmentsCard data={data} />
|
||||
|
||||
{/* Sales & Purchase Documents */}
|
||||
<SalesPurchaseCards data={data} />
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ItemsTotalsCard data={data} />
|
||||
<CustomersTotalsCard data={data} />
|
||||
</div>
|
||||
|
||||
{/* Vehicle Statistics */}
|
||||
<VehicleStatsCards data={data} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client"
|
||||
|
||||
import { Bar, BarChart, XAxis, YAxis, CartesianGrid } from "recharts"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
const chartConfig = {
|
||||
amount: {
|
||||
label: "Amount",
|
||||
color: "var(--color-blue-500, #3b82f6)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function FinancialSummaryChart({ data }: Props) {
|
||||
const summary = data.financial_summary
|
||||
const currency = summary?.currency ?? ""
|
||||
|
||||
const chartData = (summary?.chart ?? []).map((item) => ({
|
||||
label: item.label ?? "",
|
||||
amount: item.amount ?? 0,
|
||||
count: item.count ?? 0,
|
||||
}))
|
||||
|
||||
const colors = ["#3b82f6", "#f59e0b", "#ef4444"]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Financial Summary</CardTitle>
|
||||
<CardDescription>
|
||||
Invoices, expenses & bills breakdown ({currency})
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-75 w-full">
|
||||
<BarChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis dataKey="label" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v: number) => v.toLocaleString()} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, _name, item) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{currency} {Number(value).toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">{item.payload.count} document(s)</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
radius={[6, 6, 0, 0]}
|
||||
fill="var(--color-amount)"
|
||||
barSize={48}
|
||||
>
|
||||
{chartData.map((_entry, index) => (
|
||||
<rect key={index} fill={colors[index % colors.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function FinancialTotalsCards({ data }: Props) {
|
||||
const totals = data.totals
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="relative overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Income
|
||||
</CardTitle>
|
||||
<div className="rounded-md bg-emerald-500/10 p-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-emerald-600">
|
||||
{totals?.total_income_text ?? `${totals?.currency ?? ""} 0.00`}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ArrowUpRight className="h-3 w-3 text-emerald-500" />
|
||||
Income this period
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="absolute -bottom-4 -right-4 h-24 w-24 rounded-full bg-emerald-500/5" />
|
||||
</Card>
|
||||
|
||||
<Card className="relative overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Total Expenses
|
||||
</CardTitle>
|
||||
<div className="rounded-md bg-red-500/10 p-2">
|
||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{totals?.total_expense_text ?? `${totals?.currency ?? ""} 0.00`}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ArrowDownRight className="h-3 w-3 text-red-500" />
|
||||
Expenses this period
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="absolute -bottom-4 -right-4 h-24 w-24 rounded-full bg-red-500/5" />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
const chartConfig = {
|
||||
income: {
|
||||
label: "Income",
|
||||
color: "var(--color-emerald-500, #10b981)",
|
||||
},
|
||||
expense: {
|
||||
label: "Expense",
|
||||
color: "var(--color-red-500, #ef4444)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function IncomeExpenseChart({ data }: Props) {
|
||||
const series = data.chart?.series ?? []
|
||||
const currency = data.chart?.currency ?? ""
|
||||
|
||||
const chartData = series.map((item) => ({
|
||||
date: item.date ?? "",
|
||||
income: item.income ?? 0,
|
||||
expense: item.expense ?? 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Income vs Expenses</CardTitle>
|
||||
<CardDescription>
|
||||
Financial trend over the selected period ({currency})
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-75 w-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="fillIncome" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-income)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-income)" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="fillExpense" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-expense)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-expense)" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: string) => {
|
||||
const date = new Date(value)
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
}}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickFormatter={(v: number) => `${v.toLocaleString()}`} />
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={(value) => {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="var(--color-income)"
|
||||
fill="url(#fillIncome)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expense"
|
||||
stroke="var(--color-expense)"
|
||||
fill="url(#fillExpense)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Package, Wrench, Layers } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function ItemsTotalsCard({ data }: Props) {
|
||||
const items = data.items_totals
|
||||
|
||||
const stats = [
|
||||
{ label: "Parts", value: items?.parts ?? 0, icon: Package, color: "text-blue-600", bg: "bg-blue-500/10" },
|
||||
{ label: "Services", value: items?.services ?? 0, icon: Wrench, color: "text-violet-600", bg: "bg-violet-500/10" },
|
||||
{ label: "Service Groups", value: items?.service_groups ?? 0, icon: Layers, color: "text-amber-600", bg: "bg-amber-500/10" },
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">Items</CardTitle>
|
||||
<span className="text-2xl font-bold">{items?.total_items ?? 0}</span>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`rounded-md p-1.5 ${stat.bg}`}>
|
||||
<stat.icon className={`h-3.5 w-3.5 ${stat.color}`} />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { FileText, FileSearch, Receipt, ShoppingCart } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function SalesPurchaseCards({ data }: Props) {
|
||||
const sales = data.sales_totals
|
||||
const purchase = data.purchase_totals
|
||||
|
||||
const salesStats = [
|
||||
{ label: "Inspections", value: sales?.inspections ?? 0, icon: FileSearch },
|
||||
{ label: "Estimates", value: sales?.estimates ?? 0, icon: FileText },
|
||||
{ label: "Invoices", value: sales?.invoices ?? 0, icon: Receipt },
|
||||
]
|
||||
|
||||
const purchaseStats = [
|
||||
{ label: "Purchase Orders", value: purchase?.purchase_orders ?? 0, icon: ShoppingCart },
|
||||
{ label: "Bills", value: purchase?.bills ?? 0, icon: Receipt },
|
||||
{ label: "Expenses", value: purchase?.expenses ?? 0, icon: FileText },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">Sales Documents</CardTitle>
|
||||
<span className="text-2xl font-bold text-emerald-600">
|
||||
{sales?.total_sales_documents ?? 0}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{salesStats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex flex-col items-center gap-1 rounded-lg border p-3 text-center"
|
||||
>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-lg font-bold">{stat.value}</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">Purchase Documents</CardTitle>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{purchase?.total_purchase_documents ?? 0}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{purchaseStats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex flex-col items-center gap-1 rounded-lg border p-3 text-center"
|
||||
>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-lg font-bold">{stat.value}</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import { Calendar, Clock } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
confirmed: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
pending: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
cancelled: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
}
|
||||
|
||||
type AppointmentDetail = NonNullable<NonNullable<NonNullable<DashboardData["upcoming_appointments"]>["today"]>["details"]>[number]
|
||||
|
||||
function AppointmentRow({ appt }: { appt: AppointmentDetail }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-primary/10 p-2">
|
||||
<Calendar className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{appt.title}</p>
|
||||
{appt.notes && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{appt.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{appt.from_time?.slice(0, 5)} - {appt.to_time?.slice(0, 5)}
|
||||
</div>
|
||||
<Badge variant="secondary" className={statusBadge[appt.status ?? ""] ?? ""}>
|
||||
{appt.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Calendar className="h-8 w-8 mb-2 opacity-50" />
|
||||
<p className="text-sm">No appointments</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpcomingAppointmentsCard({ data }: Props) {
|
||||
const upcoming = data.upcoming_appointments
|
||||
|
||||
const tabs = [
|
||||
{ key: "today", label: "Today", data: upcoming?.today },
|
||||
{ key: "tomorrow", label: "Tomorrow", data: upcoming?.tomorrow },
|
||||
{ key: "this_week", label: "This Week", data: upcoming?.this_week },
|
||||
{ key: "next_week", label: "Next Week", data: upcoming?.next_week },
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Upcoming Appointments</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="today">
|
||||
<TabsList className="w-full">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.key} value={tab.key} className="flex-1 text-xs">
|
||||
{tab.label}
|
||||
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[10px]">
|
||||
{tab.data?.count ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.key} value={tab.key} className="space-y-2 mt-3">
|
||||
{(tab.data?.details as AppointmentDetail[] | undefined)?.length ? (
|
||||
(tab.data?.details as AppointmentDetail[]).map((appt) => (
|
||||
<AppointmentRow key={appt.id} appt={appt} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useAuthApi } from "@/shared/useApi"
|
||||
import type { HomeDashboardResponse } from "@garage/api"
|
||||
|
||||
export type DashboardData = HomeDashboardResponse
|
||||
|
||||
export function useDashboardData() {
|
||||
const api = useAuthApi()
|
||||
|
||||
return useQuery<DashboardData>({
|
||||
queryKey: ["home", "dashboard"],
|
||||
queryFn: () => api.home.dashboard(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { Car } from "lucide-react"
|
||||
import { Bar, BarChart, XAxis, YAxis, CartesianGrid, Cell } from "recharts"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
const chartConfig = {
|
||||
vehicles_count: {
|
||||
label: "Vehicles",
|
||||
color: "var(--color-blue-500, #3b82f6)",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const COLORS = ["#3b82f6", "#8b5cf6", "#06b6d4", "#f59e0b", "#ef4444", "#10b981", "#ec4899", "#6366f1"]
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
export function VehicleStatsCards({ data }: Props) {
|
||||
const bodyTypes = data.body_types_vehicle_totals ?? []
|
||||
const makes = data.make_model_vehicle_totals?.makes ?? []
|
||||
|
||||
const bodyData = bodyTypes.map((bt) => ({
|
||||
name: bt.body_type ?? "Unknown",
|
||||
vehicles_count: bt.vehicles_count ?? 0,
|
||||
}))
|
||||
|
||||
const makeData = makes.map((m) => ({
|
||||
name: m.make ?? "Unknown",
|
||||
vehicles_count: m.vehicles_count ?? 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>Vehicles by Body Type</CardTitle>
|
||||
<CardDescription>Distribution across body types</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{bodyData.length > 0 ? (
|
||||
<ChartContainer config={chartConfig} className="h-55 w-full">
|
||||
<BarChart data={bodyData} layout="vertical" margin={{ top: 0, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
|
||||
<XAxis type="number" tickLine={false} axisLine={false} />
|
||||
<YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={80} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="vehicles_count" radius={[0, 6, 6, 0]} barSize={24}>
|
||||
{bodyData.map((_entry, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No vehicle data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>Vehicles by Make</CardTitle>
|
||||
<CardDescription>Top vehicle manufacturers</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{makeData.length > 0 ? (
|
||||
<ChartContainer config={chartConfig} className="h-55 w-full">
|
||||
<BarChart data={makeData} layout="vertical" margin={{ top: 0, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
|
||||
<XAxis type="number" tickLine={false} axisLine={false} />
|
||||
<YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={80} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="vehicles_count" radius={[0, 6, 6, 0]} barSize={24}>
|
||||
{makeData.map((_entry, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No vehicle data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import type { DashboardData } from "./use-dashboard-data"
|
||||
|
||||
type Props = { data: DashboardData }
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||
check_in: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
in_progress: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
completed: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
|
||||
cancelled: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
invoiced: "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
|
||||
}
|
||||
|
||||
export function WorkOrdersStatusCard({ data }: Props) {
|
||||
const workOrders = data.work_orders_status
|
||||
const cards = workOrders?.cards ?? []
|
||||
const totals = workOrders?.totals
|
||||
const totalOrders = totals?.orders ?? 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Work Orders</CardTitle>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">{totals?.orders_text ?? "0 Orders"}</p>
|
||||
<p className="text-xs text-muted-foreground">{totals?.amount_text ?? ""}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{cards.map((card) => {
|
||||
const percentage = totalOrders > 0 ? ((card.count ?? 0) / totalOrders) * 100 : 0
|
||||
return (
|
||||
<div key={card.status} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={statusColors[card.status ?? ""] ?? ""}
|
||||
>
|
||||
{card.label}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{card.orders_text}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{card.amount_text}</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-1.5" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{cards.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No work orders this period
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user