|
|
@@ -29,6 +29,7 @@ import {
|
|
|
} from "@/components/ui/table";
|
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
+import { Skeleton } from "@/components/ui/skeleton";
|
|
|
import {
|
|
|
Select,
|
|
|
SelectContent,
|
|
|
@@ -55,6 +56,9 @@ import {
|
|
|
Clock,
|
|
|
ArrowUpDown,
|
|
|
Landmark,
|
|
|
+ Car,
|
|
|
+ ChevronLeft,
|
|
|
+ ChevronRight,
|
|
|
} from "lucide-react";
|
|
|
import {
|
|
|
getRevenueReport,
|
|
|
@@ -67,6 +71,7 @@ import {
|
|
|
getCustomerRetentionReport,
|
|
|
getTaxReport,
|
|
|
getPastDueInvoicesReport,
|
|
|
+ getVehicleReport,
|
|
|
} from "@/features/reports/Actions/reportActions";
|
|
|
import { formatCurrency } from "@/lib/format";
|
|
|
import type {
|
|
|
@@ -80,6 +85,7 @@ import type {
|
|
|
CustomerRetentionReport,
|
|
|
TaxReport,
|
|
|
PastDueInvoicesReport,
|
|
|
+ VehicleReportData,
|
|
|
} from "@/features/reports/Schema/reportTypes";
|
|
|
import { RevenueBarChart, RevenueTypeDonut } from "@/features/reports/Components/RevenueCharts";
|
|
|
import { ServiceStatusChart, ServiceTypeDonut } from "@/features/reports/Components/ServiceCharts";
|
|
|
@@ -89,6 +95,8 @@ import { PartsDonut } from "@/features/reports/Components/PartsCharts";
|
|
|
import { DayOfWeekChart, ServiceTypeAnalyticsDonut, MonthlyTrendChart } from "@/features/reports/Components/JobAnalyticsCharts";
|
|
|
import { RetentionBarChart } from "@/features/reports/Components/RetentionCharts";
|
|
|
import { TaxBarChart } from "@/features/reports/Components/TaxCharts";
|
|
|
+import { VehicleCostBarChart } from "@/features/reports/Components/VehicleCharts";
|
|
|
+import { VehicleCombobox } from "@/features/quotes/Components/VehicleCombobox";
|
|
|
import {
|
|
|
exportRevenueCsv,
|
|
|
exportServicesCsv,
|
|
|
@@ -100,11 +108,12 @@ import {
|
|
|
exportRetentionCsv,
|
|
|
exportTaxCsv,
|
|
|
exportPastDueInvoicesCsv,
|
|
|
+ exportVehicleReportCsv,
|
|
|
} from "@/features/reports/Components/csv-export";
|
|
|
import { ReportPDF } from "@/features/reports/Components/ReportPDF";
|
|
|
import { pdf } from "@react-pdf/renderer";
|
|
|
|
|
|
-type ReportTab = "financial" | "services" | "customers" | "inventory" | "technicians" | "parts" | "job-analytics" | "retention";
|
|
|
+type ReportTab = "financial" | "services" | "customers" | "inventory" | "technicians" | "parts" | "job-analytics" | "retention" | "vehicles";
|
|
|
type FinancialSubTab = "revenue" | "past-due-invoices" | "tax";
|
|
|
type PastDueSortKey = "customerName" | "amountDue" | "daysPastDue";
|
|
|
type PastDueSortDir = "asc" | "desc";
|
|
|
@@ -112,12 +121,13 @@ type PastDueSortDir = "asc" | "desc";
|
|
|
interface ReportsClientProps {
|
|
|
currencyCode: string;
|
|
|
primaryColor: string;
|
|
|
+ organizationName: string;
|
|
|
}
|
|
|
|
|
|
-const VALID_TABS: ReportTab[] = ["financial", "services", "customers", "inventory", "technicians", "parts", "job-analytics", "retention"];
|
|
|
+const VALID_TABS: ReportTab[] = ["financial", "services", "customers", "inventory", "technicians", "parts", "job-analytics", "retention", "vehicles"];
|
|
|
const VALID_SUB_TABS: FinancialSubTab[] = ["revenue", "past-due-invoices", "tax"];
|
|
|
|
|
|
-export default function ReportsClient({ currencyCode, primaryColor }: ReportsClientProps) {
|
|
|
+export default function ReportsClient({ currencyCode, primaryColor, organizationName }: ReportsClientProps) {
|
|
|
const t = useTranslations("reports");
|
|
|
const searchParams = useSearchParams();
|
|
|
const router = useRouter();
|
|
|
@@ -163,6 +173,9 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
const [retentionData, setRetentionData] = useState<CustomerRetentionReport | null>(null);
|
|
|
const [taxData, setTaxData] = useState<TaxReport | null>(null);
|
|
|
const [pastDueData, setPastDueData] = useState<PastDueInvoicesReport | null>(null);
|
|
|
+ const [vehicleData, setVehicleData] = useState<VehicleReportData | null>(null);
|
|
|
+ const [selectedVehicleId, setSelectedVehicleId] = useState<string>("");
|
|
|
+ const [historyPage, setHistoryPage] = useState(0);
|
|
|
|
|
|
const fmtCurrency = useCallback(
|
|
|
(value: number) => formatCurrency(value, currencyCode),
|
|
|
@@ -172,7 +185,7 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
type FetchableReport = ReportTab | FinancialSubTab;
|
|
|
|
|
|
const fetchReport = useCallback(
|
|
|
- async (type: FetchableReport, overrideDateRange?: DateRange) => {
|
|
|
+ async (type: FetchableReport, overrideDateRange?: DateRange, overrideVehicleId?: string) => {
|
|
|
const range = overrideDateRange ?? dateRange;
|
|
|
setLoading(true);
|
|
|
try {
|
|
|
@@ -231,6 +244,13 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
if (result.success && result.data) setRetentionData(result.data);
|
|
|
break;
|
|
|
}
|
|
|
+ case "vehicles": {
|
|
|
+ const vid = overrideVehicleId ?? selectedVehicleId;
|
|
|
+ if (!vid) break;
|
|
|
+ const result = await getVehicleReport({ ...dateParams, vehicleId: vid });
|
|
|
+ if (result.success && result.data) setVehicleData(result.data);
|
|
|
+ break;
|
|
|
+ }
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error("Failed to fetch report:", error);
|
|
|
@@ -238,7 +258,7 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
setLoading(false);
|
|
|
}
|
|
|
},
|
|
|
- [dateRange],
|
|
|
+ [dateRange, selectedVehicleId],
|
|
|
);
|
|
|
|
|
|
// Auto-fetch current tab on mount
|
|
|
@@ -272,9 +292,14 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
parts: partsData,
|
|
|
"job-analytics": jobAnalyticsData,
|
|
|
retention: retentionData,
|
|
|
+ vehicles: vehicleData,
|
|
|
};
|
|
|
if (!dataMap[tab]) {
|
|
|
- fetchReport(tab);
|
|
|
+ if (tab === "vehicles") {
|
|
|
+ if (selectedVehicleId) fetchReport(tab);
|
|
|
+ } else {
|
|
|
+ fetchReport(tab);
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
@@ -296,6 +321,8 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
const handleRefresh = () => {
|
|
|
if (activeTab === "financial") {
|
|
|
fetchReport(financialSubTab);
|
|
|
+ } else if (activeTab === "vehicles") {
|
|
|
+ if (selectedVehicleId) fetchReport("vehicles");
|
|
|
} else {
|
|
|
fetchReport(activeTab);
|
|
|
}
|
|
|
@@ -338,6 +365,9 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
case "retention":
|
|
|
if (retentionData) exportRetentionCsv(retentionData, currencyCode, [h("customer"), h("company"), h("visits"), h("totalSpent"), h("avgDaysBetweenVisits")]);
|
|
|
break;
|
|
|
+ case "vehicles":
|
|
|
+ if (vehicleData) exportVehicleReportCsv(vehicleData, currencyCode, [h("date"), h("title"), h("type"), h("status"), h("totalAmount"), h("partsUsed"), h("laborHours")]);
|
|
|
+ break;
|
|
|
}
|
|
|
}
|
|
|
};
|
|
|
@@ -441,6 +471,19 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
quantity: t("pdf.quantity"),
|
|
|
minQuantity: t("pdf.minQuantity"),
|
|
|
unitCost: t("pdf.unitCost"),
|
|
|
+ vehiclesSection: t("pdf.vehiclesSection"),
|
|
|
+ vehicleLabel: t("pdf.vehicleLabel"),
|
|
|
+ totalCost: t("pdf.totalCost"),
|
|
|
+ partsCostLabel: t("pdf.partsCostLabel"),
|
|
|
+ laborCostLabel: t("pdf.laborCostLabel"),
|
|
|
+ date: t("pdf.date"),
|
|
|
+ repairs: t("vehicles.repairs"),
|
|
|
+ maintenance: t("vehicles.maintenance"),
|
|
|
+ upgrades: t("vehicles.upgrades"),
|
|
|
+ inspections: t("vehicles.inspections"),
|
|
|
+ totalLaborHours: t("vehicles.totalLaborHours"),
|
|
|
+ serviceHistory: t("vehicles.serviceHistory"),
|
|
|
+ serviceTypeBreakdown: t("vehicles.serviceTypeBreakdown"),
|
|
|
};
|
|
|
|
|
|
// Only include data for the current tab
|
|
|
@@ -460,6 +503,7 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
parts: ["partsData", partsData],
|
|
|
"job-analytics": ["jobAnalyticsData", jobAnalyticsData],
|
|
|
retention: ["retentionData", retentionData],
|
|
|
+ vehicles: ["vehicleData", vehicleData],
|
|
|
};
|
|
|
const entry = dataMap[activeTab];
|
|
|
if (entry) pdfProps[entry[0] as string] = entry[1];
|
|
|
@@ -470,6 +514,7 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
dateRange={dateRangeStr}
|
|
|
currencyCode={currencyCode}
|
|
|
primaryColor={primaryColor}
|
|
|
+ organizationName={organizationName}
|
|
|
labels={labels}
|
|
|
{...pdfProps}
|
|
|
/>,
|
|
|
@@ -506,6 +551,7 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
parts: partsData,
|
|
|
"job-analytics": jobAnalyticsData,
|
|
|
retention: retentionData,
|
|
|
+ vehicles: vehicleData,
|
|
|
};
|
|
|
return !!dataMap[activeTab];
|
|
|
})();
|
|
|
@@ -539,8 +585,8 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
|
|
|
return (
|
|
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-4">
|
|
|
- {/* Tab bar with date range and actions */}
|
|
|
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
+ {/* Tab bar */}
|
|
|
+ <div className="space-y-3">
|
|
|
<TabsList className="flex-wrap h-auto">
|
|
|
<TabsTrigger value="financial" className="gap-1.5">
|
|
|
<Landmark className="h-4 w-4" />
|
|
|
@@ -550,6 +596,10 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
<BarChart3 className="h-4 w-4" />
|
|
|
{t("tabs.services")}
|
|
|
</TabsTrigger>
|
|
|
+ <TabsTrigger value="vehicles" className="gap-1.5">
|
|
|
+ <Car className="h-4 w-4" />
|
|
|
+ {t("tabs.vehicles")}
|
|
|
+ </TabsTrigger>
|
|
|
<TabsTrigger value="customers" className="gap-1.5">
|
|
|
<Users className="h-4 w-4" />
|
|
|
{t("tabs.customers")}
|
|
|
@@ -576,7 +626,8 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
</TabsTrigger>
|
|
|
</TabsList>
|
|
|
|
|
|
- <div className="flex items-center gap-2">
|
|
|
+ {/* Date range and actions */}
|
|
|
+ <div className="flex items-center gap-2 flex-wrap justify-end">
|
|
|
{showDateRange && (
|
|
|
<Popover open={datePickerOpen} onOpenChange={(open) => {
|
|
|
setDatePickerOpen(open);
|
|
|
@@ -607,13 +658,54 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
</Button>
|
|
|
</PopoverTrigger>
|
|
|
<PopoverContent className="w-auto p-0" align="end">
|
|
|
- <Calendar
|
|
|
- mode="range"
|
|
|
- defaultMonth={dateRange.to ? new Date(dateRange.to.getFullYear(), dateRange.to.getMonth() - 1) : dateRange.from}
|
|
|
- selected={pendingDateRange}
|
|
|
- onSelect={(range) => range && setPendingDateRange(range)}
|
|
|
- numberOfMonths={2}
|
|
|
- />
|
|
|
+ <div className="flex">
|
|
|
+ <Calendar
|
|
|
+ mode="range"
|
|
|
+ defaultMonth={dateRange.to ? new Date(dateRange.to.getFullYear(), dateRange.to.getMonth() - 1) : dateRange.from}
|
|
|
+ selected={pendingDateRange}
|
|
|
+ onSelect={(range) => range && setPendingDateRange(range)}
|
|
|
+ numberOfMonths={2}
|
|
|
+ />
|
|
|
+ <div className="border-l p-2 flex flex-col gap-0.5 min-w-[130px]">
|
|
|
+ {(() => {
|
|
|
+ const now = new Date();
|
|
|
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
+ const presets: { label: string; from: Date; to: Date }[] = [
|
|
|
+ { label: t("dateRange.presets.today"), from: today, to: today },
|
|
|
+ { label: t("dateRange.presets.yesterday"), from: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1), to: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1) },
|
|
|
+ { label: t("dateRange.presets.last7"), from: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 6), to: today },
|
|
|
+ { label: t("dateRange.presets.last30"), from: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29), to: today },
|
|
|
+ { label: t("dateRange.presets.last90"), from: new Date(today.getFullYear(), today.getMonth(), today.getDate() - 89), to: today },
|
|
|
+ { label: t("dateRange.presets.thisMonth"), from: new Date(now.getFullYear(), now.getMonth(), 1), to: today },
|
|
|
+ { label: t("dateRange.presets.lastMonth"), from: new Date(now.getFullYear(), now.getMonth() - 1, 1), to: new Date(now.getFullYear(), now.getMonth(), 0) },
|
|
|
+ { label: t("dateRange.presets.thisQuarter"), from: new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1), to: today },
|
|
|
+ { label: t("dateRange.presets.thisYear"), from: new Date(now.getFullYear(), 0, 1), to: today },
|
|
|
+ { label: t("dateRange.presets.lastYear"), from: new Date(now.getFullYear() - 1, 0, 1), to: new Date(now.getFullYear() - 1, 11, 31) },
|
|
|
+ { label: t("dateRange.presets.allTime"), from: new Date(2000, 0, 1), to: today },
|
|
|
+ ];
|
|
|
+ return presets.map((p) => (
|
|
|
+ <Button
|
|
|
+ key={p.label}
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ className="justify-start text-xs h-7 px-2"
|
|
|
+ onClick={() => {
|
|
|
+ const range = { from: p.from, to: p.to };
|
|
|
+ setDateRange(range);
|
|
|
+ setDatePickerOpen(false);
|
|
|
+ if (activeTab === "financial") {
|
|
|
+ fetchReport(financialSubTab, range);
|
|
|
+ } else {
|
|
|
+ fetchReport(activeTab, range);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {p.label}
|
|
|
+ </Button>
|
|
|
+ ));
|
|
|
+ })()}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
<div className="border-t p-3 flex justify-end">
|
|
|
<Button
|
|
|
size="sm"
|
|
|
@@ -656,10 +748,19 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- {/* Loading overlay */}
|
|
|
- {loading && (
|
|
|
- <div className="flex items-center justify-center py-12">
|
|
|
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
+ {/* Loading skeleton (hidden on vehicles tab — it handles its own) */}
|
|
|
+ {loading && activeTab !== "vehicles" && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="grid gap-2 grid-cols-3">
|
|
|
+ {Array.from({ length: 3 }).map((_, i) => (
|
|
|
+ <Skeleton key={i} className="h-16 rounded-lg" />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ <div className="grid gap-4 lg:grid-cols-5">
|
|
|
+ <Skeleton className="h-[300px] rounded-lg lg:col-span-3" />
|
|
|
+ <Skeleton className="h-[300px] rounded-lg lg:col-span-2" />
|
|
|
+ </div>
|
|
|
+ <Skeleton className="h-48 rounded-lg" />
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
@@ -1727,6 +1828,223 @@ export default function ReportsClient({ currencyCode, primaryColor }: ReportsCli
|
|
|
)}
|
|
|
{!loading && !retentionData && <EmptyState message={t("empty")} />}
|
|
|
</TabsContent>
|
|
|
+
|
|
|
+ {/* Vehicle Reports Tab */}
|
|
|
+ <TabsContent value="vehicles">
|
|
|
+ <div className="mb-4 max-w-md">
|
|
|
+ <VehicleCombobox
|
|
|
+ value={selectedVehicleId}
|
|
|
+ onChange={(id) => {
|
|
|
+ setSelectedVehicleId(id);
|
|
|
+ setVehicleData(null);
|
|
|
+ setHistoryPage(0);
|
|
|
+ if (id) {
|
|
|
+ fetchReport("vehicles", undefined, id);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ placeholder={t("vehicles.selectVehicle")}
|
|
|
+ noneLabel="—"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {loading && activeTab === "vehicles" && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <Skeleton className="h-[72px] rounded-lg" />
|
|
|
+ <div className="grid gap-4 lg:grid-cols-5">
|
|
|
+ <Skeleton className="h-[300px] rounded-lg lg:col-span-3" />
|
|
|
+ <Skeleton className="h-[300px] rounded-lg lg:col-span-2" />
|
|
|
+ </div>
|
|
|
+ <div className="grid gap-4 lg:grid-cols-5">
|
|
|
+ <Skeleton className="h-48 rounded-lg lg:col-span-2" />
|
|
|
+ <Skeleton className="h-48 rounded-lg lg:col-span-3" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && vehicleData && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {/* Vehicle header with inline stats */}
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="p-4">
|
|
|
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
|
+ <div className="flex items-center gap-3 min-w-0">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
|
|
|
+ <Car className="h-4 w-4 text-blue-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-base font-semibold">
|
|
|
+ {vehicleData.vehicleInfo.year} {vehicleData.vehicleInfo.make} {vehicleData.vehicleInfo.model}
|
|
|
+ </p>
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
+ {[vehicleData.vehicleInfo.licensePlate, vehicleData.vehicleInfo.vin, vehicleData.vehicleInfo.customerName].filter(Boolean).join(" · ")}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-wrap gap-x-5 gap-y-1 text-sm tabular-nums">
|
|
|
+ <span><span className="text-muted-foreground">{t("vehicles.totalServices")}:</span> <span className="font-medium">{vehicleData.summary.totalServices}</span></span>
|
|
|
+ <span><span className="text-muted-foreground">{t("vehicles.totalCost")}:</span> <span className="font-medium">{fmtCurrency(vehicleData.summary.totalCost)}</span></span>
|
|
|
+ <span><span className="text-muted-foreground">{t("vehicles.totalPartsUsed")}:</span> <span className="font-medium">{vehicleData.summary.totalPartsUsed}</span></span>
|
|
|
+ <span><span className="text-muted-foreground">{t("vehicles.totalLaborHours")}:</span> <span className="font-medium">{vehicleData.summary.totalLaborHours.toFixed(1)}</span></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* Charts row */}
|
|
|
+ <div className="grid gap-4 lg:grid-cols-5">
|
|
|
+ <Card className="border-0 shadow-sm lg:col-span-3">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">{t("vehicles.monthlyCosts")}</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <VehicleCostBarChart
|
|
|
+ data={vehicleData.monthlyCosts}
|
|
|
+ formatCurrency={fmtCurrency}
|
|
|
+ labels={{ partsCost: t("charts.partsCost"), laborCost: t("charts.laborCost") }}
|
|
|
+ />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm lg:col-span-2">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">{t("vehicles.serviceTypeBreakdown")}</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="space-y-3">
|
|
|
+ {vehicleData.serviceTypeBreakdown.map((item) => {
|
|
|
+ const pct = vehicleData.summary.totalCost > 0
|
|
|
+ ? (item.totalCost / vehicleData.summary.totalCost) * 100
|
|
|
+ : 0;
|
|
|
+ return (
|
|
|
+ <div key={item.type}>
|
|
|
+ <div className="flex items-center justify-between mb-1">
|
|
|
+ <span className="text-sm capitalize">{item.type}</span>
|
|
|
+ <span className="text-sm font-medium tabular-nums">{fmtCurrency(item.totalCost)}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <div className="h-2 flex-1 rounded-full bg-muted overflow-hidden">
|
|
|
+ <div
|
|
|
+ className="h-full rounded-full bg-blue-500"
|
|
|
+ style={{ width: `${pct}%` }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <span className="text-xs text-muted-foreground tabular-nums w-16 text-right">
|
|
|
+ {item.count} · {pct.toFixed(0)}%
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ {vehicleData.serviceTypeBreakdown.length > 0 && (
|
|
|
+ <div className="border-t pt-3 flex items-center justify-between">
|
|
|
+ <span className="text-sm font-medium">{t("vehicles.totalCost")}</span>
|
|
|
+ <span className="text-sm font-semibold tabular-nums">{fmtCurrency(vehicleData.summary.totalCost)}</span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Top Parts + Service History side by side on large screens */}
|
|
|
+ <div className="grid gap-4 lg:grid-cols-5">
|
|
|
+ {vehicleData.topParts.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm lg:col-span-2">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">{t("vehicles.topParts")}</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>{t("vehicles.tableHeaders.partName")}</TableHead>
|
|
|
+ <TableHead className="text-right">{t("vehicles.tableHeaders.quantity")}</TableHead>
|
|
|
+ <TableHead className="text-right">{t("vehicles.tableHeaders.cost")}</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {vehicleData.topParts.map((part, i) => (
|
|
|
+ <TableRow key={i}>
|
|
|
+ <TableCell className="text-sm">
|
|
|
+ <span className="font-medium">{part.name}</span>
|
|
|
+ {part.partNumber && <span className="text-muted-foreground ml-1.5 text-xs">#{part.partNumber}</span>}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{part.quantity}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(part.totalCost)}</TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {vehicleData.serviceHistory.length > 0 && (() => {
|
|
|
+ const pageSize = 15;
|
|
|
+ const totalPages = Math.ceil(vehicleData.serviceHistory.length / pageSize);
|
|
|
+ const page = Math.min(historyPage, totalPages - 1);
|
|
|
+ const paged = vehicleData.serviceHistory.slice(page * pageSize, (page + 1) * pageSize);
|
|
|
+ return (
|
|
|
+ <Card className={cn("border-0 shadow-sm", vehicleData.topParts.length > 0 ? "lg:col-span-3" : "lg:col-span-5")}>
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <CardTitle className="text-sm font-medium">{t("vehicles.serviceHistory")}</CardTitle>
|
|
|
+ {totalPages > 1 && (
|
|
|
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
+ <span>{page * pageSize + 1}–{Math.min((page + 1) * pageSize, vehicleData.serviceHistory.length)} / {vehicleData.serviceHistory.length}</span>
|
|
|
+ <Button variant="ghost" size="icon" className="h-6 w-6" disabled={page === 0} onClick={() => setHistoryPage(page - 1)}>
|
|
|
+ <ChevronLeft className="h-3.5 w-3.5" />
|
|
|
+ </Button>
|
|
|
+ <Button variant="ghost" size="icon" className="h-6 w-6" disabled={page >= totalPages - 1} onClick={() => setHistoryPage(page + 1)}>
|
|
|
+ <ChevronRight className="h-3.5 w-3.5" />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>{t("vehicles.tableHeaders.date")}</TableHead>
|
|
|
+ <TableHead>{t("vehicles.tableHeaders.title")}</TableHead>
|
|
|
+ <TableHead>{t("vehicles.tableHeaders.type")}</TableHead>
|
|
|
+ <TableHead className="text-right">{t("vehicles.tableHeaders.totalAmount")}</TableHead>
|
|
|
+ <TableHead className="text-right hidden sm:table-cell">{t("vehicles.tableHeaders.laborHours")}</TableHead>
|
|
|
+ <TableHead className="hidden md:table-cell">{t("vehicles.tableHeaders.techName")}</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {paged.map((row) => (
|
|
|
+ <TableRow
|
|
|
+ key={row.id}
|
|
|
+ className="cursor-pointer hover:bg-muted/50"
|
|
|
+ onClick={() => router.push(`/vehicles/${vehicleData.vehicleInfo.id}/service/${row.id}`)}
|
|
|
+ >
|
|
|
+ <TableCell className="text-sm text-muted-foreground">{row.date}</TableCell>
|
|
|
+ <TableCell className="text-sm font-medium">{row.title}</TableCell>
|
|
|
+ <TableCell className="text-sm">
|
|
|
+ <Badge variant="secondary">{row.type}</Badge>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(row.totalAmount)}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm hidden sm:table-cell">{row.laborHours.toFixed(1)}</TableCell>
|
|
|
+ <TableCell className="text-sm hidden md:table-cell">{row.techName ?? "-"}</TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ );
|
|
|
+ })()}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && !vehicleData && !selectedVehicleId && (
|
|
|
+ <EmptyState message={t("vehicles.selectVehiclePrompt")} />
|
|
|
+ )}
|
|
|
+ {!loading && !vehicleData && selectedVehicleId && (
|
|
|
+ <EmptyState message={t("empty")} />
|
|
|
+ )}
|
|
|
+ </TabsContent>
|
|
|
</Tabs>
|
|
|
);
|
|
|
}
|