|
|
@@ -28,12 +28,20 @@ import {
|
|
|
Download,
|
|
|
TrendingUp,
|
|
|
AlertTriangle,
|
|
|
+ Wrench,
|
|
|
+ Cog,
|
|
|
+ CalendarDays,
|
|
|
+ UserCheck,
|
|
|
} from "lucide-react";
|
|
|
import {
|
|
|
getRevenueReport,
|
|
|
getServiceReport,
|
|
|
getCustomerReport,
|
|
|
getInventoryReport,
|
|
|
+ getTechnicianReport,
|
|
|
+ getPartsUsageReport,
|
|
|
+ getJobAnalyticsReport,
|
|
|
+ getCustomerRetentionReport,
|
|
|
} from "@/features/reports/Actions/reportActions";
|
|
|
import { formatCurrency } from "@/lib/format";
|
|
|
import type {
|
|
|
@@ -41,18 +49,30 @@ import type {
|
|
|
ServiceReport,
|
|
|
CustomerReport,
|
|
|
InventoryReport,
|
|
|
+ TechnicianReport,
|
|
|
+ PartsUsageReport,
|
|
|
+ JobAnalyticsReport,
|
|
|
+ CustomerRetentionReport,
|
|
|
} from "@/features/reports/Schema/reportTypes";
|
|
|
import { RevenueBarChart, RevenueTypeDonut } from "@/features/reports/Components/RevenueCharts";
|
|
|
import { ServiceStatusChart, ServiceTypeDonut } from "@/features/reports/Components/ServiceCharts";
|
|
|
import { TopCustomersChart } from "@/features/reports/Components/CustomerCharts";
|
|
|
+import { TechnicianBarChart } from "@/features/reports/Components/TechnicianCharts";
|
|
|
+import { PartsDonut } from "@/features/reports/Components/PartsCharts";
|
|
|
+import { DayOfWeekChart, ServiceTypeAnalyticsDonut, MonthlyTrendChart } from "@/features/reports/Components/JobAnalyticsCharts";
|
|
|
+import { RetentionBarChart } from "@/features/reports/Components/RetentionCharts";
|
|
|
import {
|
|
|
exportRevenueCsv,
|
|
|
exportServicesCsv,
|
|
|
exportCustomersCsv,
|
|
|
exportInventoryCsv,
|
|
|
+ exportTechniciansCsv,
|
|
|
+ exportPartsCsv,
|
|
|
+ exportJobAnalyticsCsv,
|
|
|
+ exportRetentionCsv,
|
|
|
} from "@/features/reports/Components/csv-export";
|
|
|
|
|
|
-type ReportTab = "revenue" | "services" | "customers" | "inventory";
|
|
|
+type ReportTab = "revenue" | "services" | "customers" | "inventory" | "technicians" | "parts" | "job-analytics" | "retention";
|
|
|
|
|
|
interface ReportsClientProps {
|
|
|
currencyCode: string;
|
|
|
@@ -72,6 +92,10 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
const [serviceData, setServiceData] = useState<ServiceReport | null>(null);
|
|
|
const [customerData, setCustomerData] = useState<CustomerReport | null>(null);
|
|
|
const [inventoryData, setInventoryData] = useState<InventoryReport | null>(null);
|
|
|
+ const [technicianData, setTechnicianData] = useState<TechnicianReport | null>(null);
|
|
|
+ const [partsData, setPartsData] = useState<PartsUsageReport | null>(null);
|
|
|
+ const [jobAnalyticsData, setJobAnalyticsData] = useState<JobAnalyticsReport | null>(null);
|
|
|
+ const [retentionData, setRetentionData] = useState<CustomerRetentionReport | null>(null);
|
|
|
|
|
|
const fmtCurrency = useCallback(
|
|
|
(value: number) => formatCurrency(value, currencyCode),
|
|
|
@@ -104,6 +128,26 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
if (result.success && result.data) setInventoryData(result.data);
|
|
|
break;
|
|
|
}
|
|
|
+ case "technicians": {
|
|
|
+ const result = await getTechnicianReport(dateParams);
|
|
|
+ if (result.success && result.data) setTechnicianData(result.data);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "parts": {
|
|
|
+ const result = await getPartsUsageReport(dateParams);
|
|
|
+ if (result.success && result.data) setPartsData(result.data);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "job-analytics": {
|
|
|
+ const result = await getJobAnalyticsReport(dateParams);
|
|
|
+ if (result.success && result.data) setJobAnalyticsData(result.data);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case "retention": {
|
|
|
+ const result = await getCustomerRetentionReport(dateParams);
|
|
|
+ if (result.success && result.data) setRetentionData(result.data);
|
|
|
+ break;
|
|
|
+ }
|
|
|
}
|
|
|
} catch (error) {
|
|
|
console.error("Failed to fetch report:", error);
|
|
|
@@ -122,13 +166,17 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
const handleTabChange = (value: string) => {
|
|
|
const tab = value as ReportTab;
|
|
|
setActiveTab(tab);
|
|
|
- // Fetch if not already loaded
|
|
|
- if (
|
|
|
- (tab === "revenue" && !revenueData) ||
|
|
|
- (tab === "services" && !serviceData) ||
|
|
|
- (tab === "customers" && !customerData) ||
|
|
|
- (tab === "inventory" && !inventoryData)
|
|
|
- ) {
|
|
|
+ const dataMap: Record<ReportTab, unknown> = {
|
|
|
+ revenue: revenueData,
|
|
|
+ services: serviceData,
|
|
|
+ customers: customerData,
|
|
|
+ inventory: inventoryData,
|
|
|
+ technicians: technicianData,
|
|
|
+ parts: partsData,
|
|
|
+ "job-analytics": jobAnalyticsData,
|
|
|
+ retention: retentionData,
|
|
|
+ };
|
|
|
+ if (!dataMap[tab]) {
|
|
|
fetchReport(tab);
|
|
|
}
|
|
|
};
|
|
|
@@ -151,20 +199,40 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
case "inventory":
|
|
|
if (inventoryData) exportInventoryCsv(inventoryData, currencyCode);
|
|
|
break;
|
|
|
+ case "technicians":
|
|
|
+ if (technicianData) exportTechniciansCsv(technicianData, currencyCode);
|
|
|
+ break;
|
|
|
+ case "parts":
|
|
|
+ if (partsData) exportPartsCsv(partsData, currencyCode);
|
|
|
+ break;
|
|
|
+ case "job-analytics":
|
|
|
+ if (jobAnalyticsData) exportJobAnalyticsCsv(jobAnalyticsData, currencyCode);
|
|
|
+ break;
|
|
|
+ case "retention":
|
|
|
+ if (retentionData) exportRetentionCsv(retentionData, currencyCode);
|
|
|
+ break;
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const hasData =
|
|
|
- (activeTab === "revenue" && revenueData) ||
|
|
|
- (activeTab === "services" && serviceData) ||
|
|
|
- (activeTab === "customers" && customerData) ||
|
|
|
- (activeTab === "inventory" && inventoryData);
|
|
|
+ const dataMap: Record<ReportTab, unknown> = {
|
|
|
+ revenue: revenueData,
|
|
|
+ services: serviceData,
|
|
|
+ customers: customerData,
|
|
|
+ inventory: inventoryData,
|
|
|
+ technicians: technicianData,
|
|
|
+ parts: partsData,
|
|
|
+ "job-analytics": jobAnalyticsData,
|
|
|
+ retention: retentionData,
|
|
|
+ };
|
|
|
+ const hasData = !!dataMap[activeTab];
|
|
|
+
|
|
|
+ const showDateRange = activeTab !== "inventory";
|
|
|
|
|
|
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">
|
|
|
- <TabsList>
|
|
|
+ <TabsList className="flex-wrap h-auto">
|
|
|
<TabsTrigger value="revenue" className="gap-1.5">
|
|
|
<DollarSign className="h-4 w-4" />
|
|
|
Revenue
|
|
|
@@ -181,10 +249,26 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
<Package className="h-4 w-4" />
|
|
|
Inventory
|
|
|
</TabsTrigger>
|
|
|
+ <TabsTrigger value="technicians" className="gap-1.5">
|
|
|
+ <Wrench className="h-4 w-4" />
|
|
|
+ Technicians
|
|
|
+ </TabsTrigger>
|
|
|
+ <TabsTrigger value="parts" className="gap-1.5">
|
|
|
+ <Cog className="h-4 w-4" />
|
|
|
+ Parts
|
|
|
+ </TabsTrigger>
|
|
|
+ <TabsTrigger value="job-analytics" className="gap-1.5">
|
|
|
+ <CalendarDays className="h-4 w-4" />
|
|
|
+ Job Analytics
|
|
|
+ </TabsTrigger>
|
|
|
+ <TabsTrigger value="retention" className="gap-1.5">
|
|
|
+ <UserCheck className="h-4 w-4" />
|
|
|
+ Retention
|
|
|
+ </TabsTrigger>
|
|
|
</TabsList>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
- {activeTab !== "inventory" && (
|
|
|
+ {showDateRange && (
|
|
|
<>
|
|
|
<Input
|
|
|
type="date"
|
|
|
@@ -343,7 +427,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
<TabsContent value="services">
|
|
|
{!loading && serviceData && (
|
|
|
<div className="space-y-4">
|
|
|
- {/* Summary card */}
|
|
|
<div className="grid gap-3 grid-cols-1 max-w-xs">
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
<CardContent className="flex items-center gap-3 p-4">
|
|
|
@@ -357,8 +440,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
</div>
|
|
|
-
|
|
|
- {/* Charts */}
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
|
{serviceData.byStatus.length > 0 && (
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
@@ -390,7 +471,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
<TabsContent value="customers">
|
|
|
{!loading && customerData && (
|
|
|
<div className="space-y-4">
|
|
|
- {/* Summary cards */}
|
|
|
<div className="grid gap-3 grid-cols-2 max-w-lg">
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
<CardContent className="flex items-center gap-3 p-4">
|
|
|
@@ -415,23 +495,16 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
</div>
|
|
|
-
|
|
|
- {/* Chart */}
|
|
|
{customerData.topCustomers.length > 0 && (
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
<CardHeader className="pb-2">
|
|
|
<CardTitle className="text-sm font-medium">Top Customers by Spend</CardTitle>
|
|
|
</CardHeader>
|
|
|
<CardContent>
|
|
|
- <TopCustomersChart
|
|
|
- data={customerData.topCustomers}
|
|
|
- formatCurrency={fmtCurrency}
|
|
|
- />
|
|
|
+ <TopCustomersChart data={customerData.topCustomers} formatCurrency={fmtCurrency} />
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
)}
|
|
|
-
|
|
|
- {/* Table */}
|
|
|
{customerData.topCustomers.length > 0 && (
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
<CardHeader className="pb-2">
|
|
|
@@ -472,7 +545,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
<TabsContent value="inventory">
|
|
|
{!loading && inventoryData && (
|
|
|
<div className="space-y-4">
|
|
|
- {/* Summary cards */}
|
|
|
<div className="grid gap-3 grid-cols-3">
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
<CardContent className="flex items-center gap-3 p-4">
|
|
|
@@ -510,8 +582,6 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
</CardContent>
|
|
|
</Card>
|
|
|
</div>
|
|
|
-
|
|
|
- {/* Low stock table */}
|
|
|
{inventoryData.lowStock.length > 0 && (
|
|
|
<Card className="border-0 shadow-sm">
|
|
|
<CardHeader className="pb-2">
|
|
|
@@ -557,6 +627,352 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
|
|
|
)}
|
|
|
{!loading && !inventoryData && <EmptyState />}
|
|
|
</TabsContent>
|
|
|
+
|
|
|
+ {/* Technicians Tab */}
|
|
|
+ <TabsContent value="technicians">
|
|
|
+ {!loading && technicianData && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="grid gap-3 grid-cols-2 max-w-lg">
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
|
|
|
+ <Wrench className="h-4 w-4 text-violet-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Total Jobs</p>
|
|
|
+ <p className="text-lg font-semibold">{technicianData.totalJobs}</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
|
|
|
+ <DollarSign className="h-4 w-4 text-emerald-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Total Revenue</p>
|
|
|
+ <p className="text-lg font-semibold truncate">
|
|
|
+ {fmtCurrency(technicianData.totalRevenue)}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ {technicianData.technicians.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Revenue by Technician</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <TechnicianBarChart data={technicianData.technicians} formatCurrency={fmtCurrency} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ {technicianData.technicians.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Technician Breakdown</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>Technician</TableHead>
|
|
|
+ <TableHead className="text-right">Jobs</TableHead>
|
|
|
+ <TableHead className="text-right">Revenue</TableHead>
|
|
|
+ <TableHead className="text-right">Avg Revenue</TableHead>
|
|
|
+ <TableHead className="text-right">Total Hours</TableHead>
|
|
|
+ <TableHead className="text-right">Avg Hours</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {technicianData.technicians.map((row) => (
|
|
|
+ <TableRow key={row.techName}>
|
|
|
+ <TableCell className="text-sm font-medium">{row.techName}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.jobCount}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(row.totalRevenue)}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(row.avgRevenue)}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.totalLaborHours.toFixed(1)}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.avgHours.toFixed(1)}</TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && !technicianData && <EmptyState />}
|
|
|
+ </TabsContent>
|
|
|
+
|
|
|
+ {/* Parts Tab */}
|
|
|
+ <TabsContent value="parts">
|
|
|
+ {!loading && partsData && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="grid gap-3 grid-cols-2 max-w-lg">
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
|
|
|
+ <Cog className="h-4 w-4 text-blue-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Parts Used</p>
|
|
|
+ <p className="text-lg font-semibold">{partsData.totalPartsUsed}</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
|
|
|
+ <DollarSign className="h-4 w-4 text-emerald-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Parts Revenue</p>
|
|
|
+ <p className="text-lg font-semibold truncate">
|
|
|
+ {fmtCurrency(partsData.totalPartsRevenue)}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ {partsData.parts.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Top Parts by Usage</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <PartsDonut data={partsData.parts} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ {partsData.parts.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Parts Usage</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>Part Name</TableHead>
|
|
|
+ <TableHead>Part #</TableHead>
|
|
|
+ <TableHead className="text-right">Usage Count</TableHead>
|
|
|
+ <TableHead className="text-right">Total Qty</TableHead>
|
|
|
+ <TableHead className="text-right">Revenue</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {partsData.parts.map((row) => (
|
|
|
+ <TableRow key={row.name}>
|
|
|
+ <TableCell className="text-sm font-medium">{row.name}</TableCell>
|
|
|
+ <TableCell className="text-sm">{row.partNumber ?? "-"}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.usageCount}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.totalQuantity}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(row.totalRevenue)}</TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && !partsData && <EmptyState />}
|
|
|
+ </TabsContent>
|
|
|
+
|
|
|
+ {/* Job Analytics Tab */}
|
|
|
+ <TabsContent value="job-analytics">
|
|
|
+ {!loading && jobAnalyticsData && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="grid gap-3 grid-cols-2 max-w-lg">
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
|
|
|
+ <BarChart3 className="h-4 w-4 text-blue-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Total Jobs</p>
|
|
|
+ <p className="text-lg font-semibold">{jobAnalyticsData.totalJobs}</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
|
|
|
+ <DollarSign className="h-4 w-4 text-emerald-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Avg Job Value</p>
|
|
|
+ <p className="text-lg font-semibold truncate">
|
|
|
+ {fmtCurrency(jobAnalyticsData.avgJobValue)}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ <div className="grid gap-4 lg:grid-cols-2">
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Jobs by Day of Week</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <DayOfWeekChart data={jobAnalyticsData.dayOfWeek} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ {jobAnalyticsData.topServiceTypes.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Service Types</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <ServiceTypeAnalyticsDonut data={jobAnalyticsData.topServiceTypes} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ {jobAnalyticsData.monthlyTrend.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Monthly Trend</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <MonthlyTrendChart data={jobAnalyticsData.monthlyTrend} formatCurrency={fmtCurrency} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ {jobAnalyticsData.topServiceTypes.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Service Type Breakdown</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>Type</TableHead>
|
|
|
+ <TableHead className="text-right">Count</TableHead>
|
|
|
+ <TableHead className="text-right">Avg Value</TableHead>
|
|
|
+ <TableHead className="text-right">Avg Hours</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {jobAnalyticsData.topServiceTypes.map((row) => (
|
|
|
+ <TableRow key={row.type}>
|
|
|
+ <TableCell className="text-sm font-medium">{row.type}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.count}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(row.avgValue)}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.avgHours.toFixed(1)}</TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && !jobAnalyticsData && <EmptyState />}
|
|
|
+ </TabsContent>
|
|
|
+
|
|
|
+ {/* Retention Tab */}
|
|
|
+ <TabsContent value="retention">
|
|
|
+ {!loading && retentionData && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/10">
|
|
|
+ <UserCheck className="h-4 w-4 text-emerald-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Returning</p>
|
|
|
+ <p className="text-lg font-semibold">{retentionData.returningCustomers}</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-500/10">
|
|
|
+ <Users className="h-4 w-4 text-blue-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">New</p>
|
|
|
+ <p className="text-lg font-semibold">{retentionData.newCustomers}</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-violet-500/10">
|
|
|
+ <Users className="h-4 w-4 text-violet-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Total Active</p>
|
|
|
+ <p className="text-lg font-semibold">{retentionData.totalActive}</p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardContent className="flex items-center gap-3 p-4">
|
|
|
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-amber-500/10">
|
|
|
+ <CalendarDays className="h-4 w-4 text-amber-500" />
|
|
|
+ </div>
|
|
|
+ <div className="min-w-0">
|
|
|
+ <p className="text-xs text-muted-foreground">Avg Days Between</p>
|
|
|
+ <p className="text-lg font-semibold">
|
|
|
+ {retentionData.avgTimeBetweenVisits ?? "-"}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ {retentionData.topReturning.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Top Returning Customers</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <RetentionBarChart data={retentionData.topReturning} formatCurrency={fmtCurrency} />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ {retentionData.topReturning.length > 0 && (
|
|
|
+ <Card className="border-0 shadow-sm">
|
|
|
+ <CardHeader className="pb-2">
|
|
|
+ <CardTitle className="text-sm font-medium">Returning Customers</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>Customer</TableHead>
|
|
|
+ <TableHead>Company</TableHead>
|
|
|
+ <TableHead className="text-right">Visits</TableHead>
|
|
|
+ <TableHead className="text-right">Total Spent</TableHead>
|
|
|
+ <TableHead className="text-right">Avg Days Between</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {retentionData.topReturning.map((row) => (
|
|
|
+ <TableRow key={row.id}>
|
|
|
+ <TableCell className="text-sm font-medium">{row.name}</TableCell>
|
|
|
+ <TableCell className="text-sm">{row.company ?? "-"}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.visitCount}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{fmtCurrency(row.totalSpent)}</TableCell>
|
|
|
+ <TableCell className="text-right text-sm">{row.avgTimeBetweenVisits ?? "-"}</TableCell>
|
|
|
+ </TableRow>
|
|
|
+ ))}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ {!loading && !retentionData && <EmptyState />}
|
|
|
+ </TabsContent>
|
|
|
</Tabs>
|
|
|
);
|
|
|
}
|