Parcourir la source

Add reports date range picker, PDF export, and scheduled report delivery (#115)

* new datepicker

* pdf report

* report scheduler

* report scheduler

* lint

* remove ununsed func
Bernt Christian Egeland il y a 6 mois
Parent
commit
30b51e9fe8
43 fichiers modifiés avec 4497 ajouts et 101 suppressions
  1. 91 2
      messages/de/reports.json
  2. 80 0
      messages/de/settings.json
  3. 91 2
      messages/en/reports.json
  4. 80 0
      messages/en/settings.json
  5. 91 2
      messages/es/reports.json
  6. 80 0
      messages/es/settings.json
  7. 91 2
      messages/fr/reports.json
  8. 80 0
      messages/fr/settings.json
  9. 91 2
      messages/it/reports.json
  10. 80 0
      messages/it/settings.json
  11. 91 2
      messages/nb/reports.json
  12. 80 0
      messages/nb/settings.json
  13. 91 2
      messages/nl/reports.json
  14. 80 0
      messages/nl/settings.json
  15. 91 2
      messages/pl/reports.json
  16. 80 0
      messages/pl/settings.json
  17. 91 2
      messages/pt-BR/reports.json
  18. 80 0
      messages/pt-BR/settings.json
  19. 91 2
      messages/ru/reports.json
  20. 80 0
      messages/ru/settings.json
  21. 91 2
      messages/tr/reports.json
  22. 80 0
      messages/tr/settings.json
  23. 29 0
      prisma/migrations/20260329102940_report_scheduler/migration.sql
  24. 26 0
      prisma/schema.prisma
  25. 3 2
      src/app/(authenticated)/reports/page.tsx
  26. 270 33
      src/app/(authenticated)/reports/reports-client.tsx
  27. 47 0
      src/app/(authenticated)/settings/report-schedule/page.tsx
  28. 642 0
      src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx
  29. 2 0
      src/app/(authenticated)/settings/settings-nav.tsx
  30. 1 0
      src/cronTasks.ts
  31. 243 0
      src/features/report-schedule/Actions/reportScheduleActions.ts
  32. 47 0
      src/features/report-schedule/Schema/reportScheduleSchema.ts
  33. 4 3
      src/features/reports/Components/CustomerCharts.tsx
  34. 12 9
      src/features/reports/Components/JobAnalyticsCharts.tsx
  35. 4 3
      src/features/reports/Components/PartsCharts.tsx
  36. 782 0
      src/features/reports/Components/ReportPDF.tsx
  37. 4 3
      src/features/reports/Components/RetentionCharts.tsx
  38. 33 13
      src/features/reports/Components/RevenueCharts.tsx
  39. 8 6
      src/features/reports/Components/ServiceCharts.tsx
  40. 4 3
      src/features/reports/Components/TaxCharts.tsx
  41. 4 3
      src/features/reports/Components/TechnicianCharts.tsx
  42. 2 1
      src/instrumentation.ts
  43. 449 0
      src/lib/cron/report-schedules.ts

+ 91 - 2
messages/de/reports.json

@@ -14,11 +14,14 @@
     "retention": "Kundenbindung"
   },
   "dateRange": {
-    "to": "bis"
+    "to": "bis",
+    "pickDate": "Datumsbereich wählen",
+    "apply": "Anwenden"
   },
   "actions": {
     "refresh": "Aktualisieren",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Umsatz",
@@ -242,5 +245,91 @@
     "partsCost": "Teilekosten",
     "netProfit": "Nettogewinn"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Noch keine Daten geladen. Klicken Sie auf Aktualisieren, um den Bericht zu laden."
 }

+ 80 - 0
messages/de/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "KI-Anbieter & Funktionen"
       },
+      "reportSchedule": {
+        "title": "Berichtsplanung",
+        "description": "Automatischer Berichtsversand"
+      },
       "appearance": {
         "title": "Darstellung",
         "description": "Design & Anzeige"
@@ -1027,5 +1031,81 @@
       "medium": "Mittel",
       "low": "Niedrige Kosten"
     }
+  },
+  "reportSchedule": {
+    "title": "Berichtsplanung",
+    "description": "Erstellen und versenden Sie automatisch Berichte als PDF per E-Mail nach einem wiederkehrenden Zeitplan.",
+    "schedule": "Zeitplan",
+    "name": "Name des Zeitplans",
+    "namePlaceholder": "z.B. Wöchentlicher Geschäftsbericht",
+    "frequency": "Häufigkeit",
+    "daily": "Täglich",
+    "weekly": "Wöchentlich",
+    "biweekly": "Alle 2 Wochen",
+    "monthly": "Monatlich",
+    "bimonthly": "Alle 2 Monate",
+    "quarterly": "Alle 4 Monate",
+    "semiannually": "Alle 6 Monate",
+    "yearly": "Jährlich",
+    "dateRangeLabel": "Zeitraum",
+    "dateRanges": {
+      "last1d": "Letzter Tag",
+      "last7d": "Letzte 7 Tage",
+      "last14d": "Letzte 14 Tage",
+      "last30d": "Letzte 30 Tage",
+      "last60d": "Letzte 60 Tage",
+      "last90d": "Letzte 90 Tage",
+      "last6m": "Letzte 6 Monate",
+      "last12m": "Letzte 12 Monate",
+      "ytd": "Laufendes Jahr",
+      "allTime": "Gesamter Zeitraum"
+    },
+    "sections": "Berichtsabschnitte",
+    "sectionsDescription": "Wählen Sie, welche Abschnitte im geplanten Bericht enthalten sein sollen.",
+    "sectionsIncluded": "Enthaltene Abschnitte",
+    "selectAll": "Alle auswählen",
+    "clearAll": "Alle abwählen",
+    "recipients": "Empfänger",
+    "recipientsDescription": "Wählen Sie Teammitglieder, die den Bericht per E-Mail erhalten sollen.",
+    "recipientsCount": "Empfänger",
+    "endDate": "Enddatum",
+    "endDateDescription": "Optional den Zeitplan nach diesem Datum beenden.",
+    "enabled": "Aktiviert",
+    "disabled": "Deaktiviert",
+    "nextRun": "Nächste Ausführung",
+    "lastRun": "Letzte Ausführung",
+    "runCount": "Gesendete Berichte",
+    "save": "Zeitplan speichern",
+    "create": "Zeitplan erstellen",
+    "delete": "Zeitplan löschen",
+    "created": "Berichtszeitplan erstellt",
+    "updated": "Berichtszeitplan aktualisiert",
+    "deleted": "Berichtszeitplan gelöscht",
+    "toggledOn": "Berichtszeitplan aktiviert",
+    "toggledOff": "Berichtszeitplan deaktiviert",
+    "noMembers": "Keine Teammitglieder gefunden",
+    "addRecipient": "Empfänger hinzufügen...",
+    "searchMembers": "Mitglieder suchen...",
+    "sendNow": "Jetzt senden",
+    "sentNow": "Bericht erfolgreich gesendet",
+    "newSchedule": "Neuer Zeitplan",
+    "editSchedule": "Zeitplan bearbeiten",
+    "edit": "Bearbeiten",
+    "cancel": "Abbrechen",
+    "sent": "gesendet",
+    "emptyTitle": "Keine geplanten Berichte",
+    "emptyDescription": "Erstellen Sie einen Zeitplan, um Berichte automatisch an Ihr Team zu senden.",
+    "sectionLabels": {
+      "revenue": "Umsatz",
+      "tax": "Steuern",
+      "pastDue": "Überfällige Rechnungen",
+      "services": "Dienstleistungen",
+      "customers": "Kunden",
+      "technicians": "Techniker",
+      "parts": "Teileauswertung",
+      "jobAnalytics": "Auftragsanalysen",
+      "retention": "Kundenbindung",
+      "inventory": "Inventar"
+    }
   }
 }

+ 91 - 2
messages/en/reports.json

@@ -14,11 +14,14 @@
     "retention": "Retention"
   },
   "dateRange": {
-    "to": "to"
+    "to": "to",
+    "pickDate": "Pick a date range",
+    "apply": "Apply"
   },
   "actions": {
     "refresh": "Refresh",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Revenue",
@@ -242,5 +245,91 @@
     "partsCost": "Parts Cost",
     "netProfit": "Net Profit"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "No data loaded yet. Click Refresh to load the report."
 }

+ 80 - 0
messages/en/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "AI provider & features"
       },
+      "reportSchedule": {
+        "title": "Report Schedule",
+        "description": "Automated report delivery"
+      },
       "appearance": {
         "title": "Appearance",
         "description": "Theme & display"
@@ -1027,5 +1031,81 @@
       "medium": "Medium",
       "low": "Low Cost"
     }
+  },
+  "reportSchedule": {
+    "title": "Report Schedule",
+    "description": "Automatically generate and email report PDFs on a recurring schedule.",
+    "schedule": "Schedule",
+    "name": "Schedule Name",
+    "namePlaceholder": "e.g. Weekly Business Report",
+    "frequency": "Frequency",
+    "daily": "Daily",
+    "weekly": "Weekly",
+    "biweekly": "Every 2 Weeks",
+    "monthly": "Monthly",
+    "bimonthly": "Every 2 Months",
+    "quarterly": "Every 4 Months",
+    "semiannually": "Every 6 Months",
+    "yearly": "Yearly",
+    "dateRangeLabel": "Date Range",
+    "dateRanges": {
+      "last1d": "Last Day",
+      "last7d": "Last 7 Days",
+      "last14d": "Last 14 Days",
+      "last30d": "Last 30 Days",
+      "last60d": "Last 60 Days",
+      "last90d": "Last 90 Days",
+      "last6m": "Last 6 Months",
+      "last12m": "Last 12 Months",
+      "ytd": "Year to Date",
+      "allTime": "All Time"
+    },
+    "sections": "Report Sections",
+    "sectionsDescription": "Select which sections to include in the scheduled report.",
+    "sectionsIncluded": "Sections included",
+    "selectAll": "Select all",
+    "clearAll": "Clear all",
+    "recipients": "Recipients",
+    "recipientsDescription": "Select team members who will receive the report via email.",
+    "recipientsCount": "Recipients",
+    "endDate": "End Date",
+    "endDateDescription": "Optionally stop the schedule after this date.",
+    "enabled": "Enabled",
+    "disabled": "Disabled",
+    "nextRun": "Next run",
+    "lastRun": "Last run",
+    "runCount": "Reports sent",
+    "save": "Save Schedule",
+    "create": "Create Schedule",
+    "delete": "Delete Schedule",
+    "created": "Report schedule created",
+    "updated": "Report schedule updated",
+    "deleted": "Report schedule deleted",
+    "toggledOn": "Report schedule enabled",
+    "toggledOff": "Report schedule disabled",
+    "noMembers": "No team members found",
+    "addRecipient": "Add recipient...",
+    "searchMembers": "Search members...",
+    "newSchedule": "New Schedule",
+    "editSchedule": "Edit Schedule",
+    "edit": "Edit",
+    "cancel": "Cancel",
+    "sent": "sent",
+    "sendNow": "Send Now",
+    "sentNow": "Report sent successfully",
+    "emptyTitle": "No scheduled reports",
+    "emptyDescription": "Create a schedule to automatically generate and email reports to your team.",
+    "sectionLabels": {
+      "revenue": "Revenue",
+      "tax": "Tax",
+      "pastDue": "Past Due Invoices",
+      "services": "Services",
+      "customers": "Customers",
+      "technicians": "Technicians",
+      "parts": "Parts Usage",
+      "jobAnalytics": "Job Analytics",
+      "retention": "Customer Retention",
+      "inventory": "Inventory"
+    }
   }
 }

+ 91 - 2
messages/es/reports.json

@@ -14,11 +14,14 @@
     "retention": "Retención"
   },
   "dateRange": {
-    "to": "a"
+    "to": "a",
+    "pickDate": "Seleccionar rango de fechas",
+    "apply": "Aplicar"
   },
   "actions": {
     "refresh": "Actualizar",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Ingresos",
@@ -242,5 +245,91 @@
     "partsCost": "Coste de piezas",
     "netProfit": "Beneficio neto"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Aún no se han cargado datos. Haga clic en Actualizar para cargar el informe."
 }

+ 80 - 0
messages/es/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "Proveedor de IA y funciones"
       },
+      "reportSchedule": {
+        "title": "Informes programados",
+        "description": "Envío automático de informes"
+      },
       "appearance": {
         "title": "Apariencia",
         "description": "Tema y visualizacion"
@@ -1027,5 +1031,81 @@
       "medium": "Medio",
       "low": "Bajo costo"
     }
+  },
+  "reportSchedule": {
+    "title": "Informes programados",
+    "description": "Genere y envíe automáticamente informes en PDF por correo electrónico de forma recurrente.",
+    "schedule": "Programación",
+    "name": "Nombre del programa",
+    "namePlaceholder": "Ej. Informe semanal de negocio",
+    "frequency": "Frecuencia",
+    "daily": "Diario",
+    "weekly": "Semanal",
+    "biweekly": "Cada 2 semanas",
+    "monthly": "Mensual",
+    "bimonthly": "Cada 2 meses",
+    "quarterly": "Cada 4 meses",
+    "semiannually": "Cada 6 meses",
+    "yearly": "Anual",
+    "dateRangeLabel": "Período",
+    "dateRanges": {
+      "last1d": "Último día",
+      "last7d": "Últimos 7 días",
+      "last14d": "Últimos 14 días",
+      "last30d": "Últimos 30 días",
+      "last60d": "Últimos 60 días",
+      "last90d": "Últimos 90 días",
+      "last6m": "Últimos 6 meses",
+      "last12m": "Últimos 12 meses",
+      "ytd": "Año en curso",
+      "allTime": "Todo el tiempo"
+    },
+    "sections": "Secciones del informe",
+    "sectionsDescription": "Seleccione qué secciones incluir en el informe programado.",
+    "sectionsIncluded": "Secciones incluidas",
+    "selectAll": "Seleccionar todo",
+    "clearAll": "Limpiar todo",
+    "recipients": "Destinatarios",
+    "recipientsDescription": "Seleccione los miembros del equipo que recibirán el informe por correo.",
+    "recipientsCount": "Destinatarios",
+    "endDate": "Fecha de fin",
+    "endDateDescription": "Opcionalmente detener el programa después de esta fecha.",
+    "enabled": "Activado",
+    "disabled": "Desactivado",
+    "nextRun": "Próxima ejecución",
+    "lastRun": "Última ejecución",
+    "runCount": "Informes enviados",
+    "save": "Guardar programa",
+    "create": "Crear programa",
+    "delete": "Eliminar programa",
+    "created": "Programa de informes creado",
+    "updated": "Programa de informes actualizado",
+    "deleted": "Programa de informes eliminado",
+    "toggledOn": "Programa de informes activado",
+    "toggledOff": "Programa de informes desactivado",
+    "noMembers": "No se encontraron miembros del equipo",
+    "addRecipient": "Agregar destinatario...",
+    "searchMembers": "Buscar miembros...",
+    "sendNow": "Enviar ahora",
+    "sentNow": "Informe enviado correctamente",
+    "newSchedule": "Nuevo programa",
+    "editSchedule": "Editar programa",
+    "edit": "Editar",
+    "cancel": "Cancelar",
+    "sent": "enviados",
+    "emptyTitle": "Sin informes programados",
+    "emptyDescription": "Cree un programa para generar y enviar informes automáticamente a su equipo.",
+    "sectionLabels": {
+      "revenue": "Ingresos",
+      "tax": "Impuestos",
+      "pastDue": "Facturas vencidas",
+      "services": "Servicios",
+      "customers": "Clientes",
+      "technicians": "Técnicos",
+      "parts": "Uso de piezas",
+      "jobAnalytics": "Análisis de trabajos",
+      "retention": "Retención de clientes",
+      "inventory": "Inventario"
+    }
   }
 }

+ 91 - 2
messages/fr/reports.json

@@ -14,11 +14,14 @@
     "retention": "Fidélisation"
   },
   "dateRange": {
-    "to": "au"
+    "to": "au",
+    "pickDate": "Choisir une plage de dates",
+    "apply": "Appliquer"
   },
   "actions": {
     "refresh": "Actualiser",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Revenus",
@@ -242,5 +245,91 @@
     "partsCost": "Coût des pièces",
     "netProfit": "Bénéfice net"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Aucune donnée chargée. Cliquez sur Actualiser pour charger le rapport."
 }

+ 80 - 0
messages/fr/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "Fournisseur IA et fonctionnalites"
       },
+      "reportSchedule": {
+        "title": "Rapports planifiés",
+        "description": "Envoi automatique de rapports"
+      },
       "appearance": {
         "title": "Apparence",
         "description": "Theme et affichage"
@@ -1027,5 +1031,81 @@
       "medium": "Moyen",
       "low": "Faible coût"
     }
+  },
+  "reportSchedule": {
+    "title": "Rapports planifiés",
+    "description": "Générez et envoyez automatiquement des rapports PDF par e-mail selon un calendrier récurrent.",
+    "schedule": "Planification",
+    "name": "Nom du planning",
+    "namePlaceholder": "Ex. Rapport hebdomadaire",
+    "frequency": "Fréquence",
+    "daily": "Quotidien",
+    "weekly": "Hebdomadaire",
+    "biweekly": "Toutes les 2 semaines",
+    "monthly": "Mensuel",
+    "bimonthly": "Tous les 2 mois",
+    "quarterly": "Tous les 4 mois",
+    "semiannually": "Tous les 6 mois",
+    "yearly": "Annuel",
+    "dateRangeLabel": "Période",
+    "dateRanges": {
+      "last1d": "Dernier jour",
+      "last7d": "7 derniers jours",
+      "last14d": "14 derniers jours",
+      "last30d": "30 derniers jours",
+      "last60d": "60 derniers jours",
+      "last90d": "90 derniers jours",
+      "last6m": "6 derniers mois",
+      "last12m": "12 derniers mois",
+      "ytd": "Depuis début d'année",
+      "allTime": "Tout le temps"
+    },
+    "sections": "Sections du rapport",
+    "sectionsDescription": "Sélectionnez les sections à inclure dans le rapport planifié.",
+    "sectionsIncluded": "Sections incluses",
+    "selectAll": "Tout sélectionner",
+    "clearAll": "Tout effacer",
+    "recipients": "Destinataires",
+    "recipientsDescription": "Sélectionnez les membres de l'équipe qui recevront le rapport par e-mail.",
+    "recipientsCount": "Destinataires",
+    "endDate": "Date de fin",
+    "endDateDescription": "Arrêter optionnellement le planning après cette date.",
+    "enabled": "Activé",
+    "disabled": "Désactivé",
+    "nextRun": "Prochaine exécution",
+    "lastRun": "Dernière exécution",
+    "runCount": "Rapports envoyés",
+    "save": "Enregistrer le planning",
+    "create": "Créer un planning",
+    "delete": "Supprimer le planning",
+    "created": "Planning de rapports créé",
+    "updated": "Planning de rapports mis à jour",
+    "deleted": "Planning de rapports supprimé",
+    "toggledOn": "Planning de rapports activé",
+    "toggledOff": "Planning de rapports désactivé",
+    "noMembers": "Aucun membre trouvé",
+    "addRecipient": "Ajouter un destinataire...",
+    "searchMembers": "Rechercher des membres...",
+    "sendNow": "Envoyer maintenant",
+    "sentNow": "Rapport envoyé avec succès",
+    "newSchedule": "Nouveau planning",
+    "editSchedule": "Modifier le planning",
+    "edit": "Modifier",
+    "cancel": "Annuler",
+    "sent": "envoyés",
+    "emptyTitle": "Aucun rapport planifié",
+    "emptyDescription": "Créez un planning pour générer et envoyer automatiquement des rapports à votre équipe.",
+    "sectionLabels": {
+      "revenue": "Revenus",
+      "tax": "Taxes",
+      "pastDue": "Factures en retard",
+      "services": "Services",
+      "customers": "Clients",
+      "technicians": "Techniciens",
+      "parts": "Utilisation des pièces",
+      "jobAnalytics": "Analyses des travaux",
+      "retention": "Fidélisation clients",
+      "inventory": "Inventaire"
+    }
   }
 }

+ 91 - 2
messages/it/reports.json

@@ -14,11 +14,14 @@
     "retention": "Fidelizzazione"
   },
   "dateRange": {
-    "to": "a"
+    "to": "a",
+    "pickDate": "Seleziona intervallo date",
+    "apply": "Applica"
   },
   "actions": {
     "refresh": "Aggiorna",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Ricavi",
@@ -242,5 +245,91 @@
     "partsCost": "Costo ricambi",
     "netProfit": "Utile netto"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Nessun dato caricato. Clicca su Aggiorna per caricare il report."
 }

+ 80 - 0
messages/it/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "Provider IA e funzionalita"
       },
+      "reportSchedule": {
+        "title": "Report programmati",
+        "description": "Invio automatico dei report"
+      },
       "appearance": {
         "title": "Aspetto",
         "description": "Tema e visualizzazione"
@@ -1027,5 +1031,81 @@
       "medium": "Medio",
       "low": "Costo basso"
     }
+  },
+  "reportSchedule": {
+    "title": "Report programmati",
+    "description": "Genera e invia automaticamente report PDF via email con cadenza ricorrente.",
+    "schedule": "Pianificazione",
+    "name": "Nome pianificazione",
+    "namePlaceholder": "Es. Report settimanale",
+    "frequency": "Frequenza",
+    "daily": "Giornaliero",
+    "weekly": "Settimanale",
+    "biweekly": "Ogni 2 settimane",
+    "monthly": "Mensile",
+    "bimonthly": "Ogni 2 mesi",
+    "quarterly": "Ogni 4 mesi",
+    "semiannually": "Ogni 6 mesi",
+    "yearly": "Annuale",
+    "dateRangeLabel": "Periodo",
+    "dateRanges": {
+      "last1d": "Ultimo giorno",
+      "last7d": "Ultimi 7 giorni",
+      "last14d": "Ultimi 14 giorni",
+      "last30d": "Ultimi 30 giorni",
+      "last60d": "Ultimi 60 giorni",
+      "last90d": "Ultimi 90 giorni",
+      "last6m": "Ultimi 6 mesi",
+      "last12m": "Ultimi 12 mesi",
+      "ytd": "Da inizio anno",
+      "allTime": "Tutto il periodo"
+    },
+    "sections": "Sezioni del report",
+    "sectionsDescription": "Seleziona le sezioni da includere nel report programmato.",
+    "sectionsIncluded": "Sezioni incluse",
+    "selectAll": "Seleziona tutto",
+    "clearAll": "Deseleziona tutto",
+    "recipients": "Destinatari",
+    "recipientsDescription": "Seleziona i membri del team che riceveranno il report via email.",
+    "recipientsCount": "Destinatari",
+    "endDate": "Data di fine",
+    "endDateDescription": "Interrompi opzionalmente la pianificazione dopo questa data.",
+    "enabled": "Attivo",
+    "disabled": "Disattivo",
+    "nextRun": "Prossimo invio",
+    "lastRun": "Ultimo invio",
+    "runCount": "Report inviati",
+    "save": "Salva pianificazione",
+    "create": "Crea pianificazione",
+    "delete": "Elimina pianificazione",
+    "created": "Pianificazione report creata",
+    "updated": "Pianificazione report aggiornata",
+    "deleted": "Pianificazione report eliminata",
+    "toggledOn": "Pianificazione report attivata",
+    "toggledOff": "Pianificazione report disattivata",
+    "noMembers": "Nessun membro trovato",
+    "addRecipient": "Aggiungi destinatario...",
+    "searchMembers": "Cerca membri...",
+    "sendNow": "Invia ora",
+    "sentNow": "Report inviato con successo",
+    "newSchedule": "Nuova pianificazione",
+    "editSchedule": "Modifica pianificazione",
+    "edit": "Modifica",
+    "cancel": "Annulla",
+    "sent": "inviati",
+    "emptyTitle": "Nessun report programmato",
+    "emptyDescription": "Crea una pianificazione per generare e inviare automaticamente report al tuo team.",
+    "sectionLabels": {
+      "revenue": "Ricavi",
+      "tax": "Tasse",
+      "pastDue": "Fatture scadute",
+      "services": "Servizi",
+      "customers": "Clienti",
+      "technicians": "Tecnici",
+      "parts": "Utilizzo ricambi",
+      "jobAnalytics": "Analisi lavori",
+      "retention": "Fidelizzazione clienti",
+      "inventory": "Inventario"
+    }
   }
 }

+ 91 - 2
messages/nb/reports.json

@@ -14,11 +14,14 @@
     "retention": "Kundelojalitet"
   },
   "dateRange": {
-    "to": "til"
+    "to": "til",
+    "pickDate": "Velg datoperiode",
+    "apply": "Bruk"
   },
   "actions": {
     "refresh": "Oppdater",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Inntekter",
@@ -242,5 +245,91 @@
     "partsCost": "Delekostnad",
     "netProfit": "Nettoresultat"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Ingen data lastet ennå. Klikk Oppdater for å laste rapporten."
 }

+ 80 - 0
messages/nb/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "AI-leverandor og funksjoner"
       },
+      "reportSchedule": {
+        "title": "Planlagte rapporter",
+        "description": "Automatisk rapportlevering"
+      },
       "appearance": {
         "title": "Utseende",
         "description": "Tema og visning"
@@ -1027,5 +1031,81 @@
       "medium": "Middels",
       "low": "Lav kostnad"
     }
+  },
+  "reportSchedule": {
+    "title": "Planlagte rapporter",
+    "description": "Generer og send rapporter som PDF på e-post automatisk etter en gjentakende plan.",
+    "schedule": "Tidsplan",
+    "name": "Navn på tidsplan",
+    "namePlaceholder": "F.eks. Ukentlig forretningsrapport",
+    "frequency": "Frekvens",
+    "daily": "Daglig",
+    "weekly": "Ukentlig",
+    "biweekly": "Hver 2. uke",
+    "monthly": "Månedlig",
+    "bimonthly": "Hver 2. måned",
+    "quarterly": "Hver 4. måned",
+    "semiannually": "Hver 6. måned",
+    "yearly": "Årlig",
+    "dateRangeLabel": "Periode",
+    "dateRanges": {
+      "last1d": "Siste dag",
+      "last7d": "Siste 7 dager",
+      "last14d": "Siste 14 dager",
+      "last30d": "Siste 30 dager",
+      "last60d": "Siste 60 dager",
+      "last90d": "Siste 90 dager",
+      "last6m": "Siste 6 måneder",
+      "last12m": "Siste 12 måneder",
+      "ytd": "Hittil i år",
+      "allTime": "All tid"
+    },
+    "sections": "Rapportseksjoner",
+    "sectionsDescription": "Velg hvilke seksjoner som skal inkluderes i den planlagte rapporten.",
+    "sectionsIncluded": "Inkluderte seksjoner",
+    "selectAll": "Velg alle",
+    "clearAll": "Fjern alle",
+    "recipients": "Mottakere",
+    "recipientsDescription": "Velg teammedlemmer som skal motta rapporten via e-post.",
+    "recipientsCount": "Mottakere",
+    "endDate": "Sluttdato",
+    "endDateDescription": "Valgfritt stopp tidsplanen etter denne datoen.",
+    "enabled": "Aktivert",
+    "disabled": "Deaktivert",
+    "nextRun": "Neste kjøring",
+    "lastRun": "Siste kjøring",
+    "runCount": "Rapporter sendt",
+    "save": "Lagre tidsplan",
+    "create": "Opprett tidsplan",
+    "delete": "Slett tidsplan",
+    "created": "Rapporttidsplan opprettet",
+    "updated": "Rapporttidsplan oppdatert",
+    "deleted": "Rapporttidsplan slettet",
+    "toggledOn": "Rapporttidsplan aktivert",
+    "toggledOff": "Rapporttidsplan deaktivert",
+    "noMembers": "Ingen teammedlemmer funnet",
+    "addRecipient": "Legg til mottaker...",
+    "searchMembers": "Søk etter medlemmer...",
+    "sendNow": "Send nå",
+    "sentNow": "Rapport sendt",
+    "newSchedule": "Ny tidsplan",
+    "editSchedule": "Rediger tidsplan",
+    "edit": "Rediger",
+    "cancel": "Avbryt",
+    "sent": "sendt",
+    "emptyTitle": "Ingen planlagte rapporter",
+    "emptyDescription": "Opprett en tidsplan for å automatisk generere og sende rapporter til teamet ditt.",
+    "sectionLabels": {
+      "revenue": "Inntekter",
+      "tax": "Skatt",
+      "pastDue": "Forfalte fakturaer",
+      "services": "Tjenester",
+      "customers": "Kunder",
+      "technicians": "Teknikere",
+      "parts": "Delebruk",
+      "jobAnalytics": "Jobbanalyser",
+      "retention": "Kundelojalitet",
+      "inventory": "Inventar"
+    }
   }
 }

+ 91 - 2
messages/nl/reports.json

@@ -14,11 +14,14 @@
     "retention": "Klantbehoud"
   },
   "dateRange": {
-    "to": "tot"
+    "to": "tot",
+    "pickDate": "Kies een datumbereik",
+    "apply": "Toepassen"
   },
   "actions": {
     "refresh": "Vernieuwen",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Omzet",
@@ -242,5 +245,91 @@
     "partsCost": "Onderdeelkosten",
     "netProfit": "Nettowinst"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Nog geen gegevens geladen. Klik op Vernieuwen om het rapport te laden."
 }

+ 80 - 0
messages/nl/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "AI-provider & functies"
       },
+      "reportSchedule": {
+        "title": "Rapportplanning",
+        "description": "Automatische rapportlevering"
+      },
       "appearance": {
         "title": "Weergave",
         "description": "Thema en weergave"
@@ -1027,5 +1031,81 @@
       "medium": "Gemiddeld",
       "low": "Lage kosten"
     }
+  },
+  "reportSchedule": {
+    "title": "Rapportplanning",
+    "description": "Genereer en verzend automatisch rapporten als PDF per e-mail volgens een terugkerend schema.",
+    "schedule": "Planning",
+    "name": "Naam van planning",
+    "namePlaceholder": "Bijv. Wekelijks bedrijfsrapport",
+    "frequency": "Frequentie",
+    "daily": "Dagelijks",
+    "weekly": "Wekelijks",
+    "biweekly": "Om de 2 weken",
+    "monthly": "Maandelijks",
+    "bimonthly": "Om de 2 maanden",
+    "quarterly": "Om de 4 maanden",
+    "semiannually": "Om de 6 maanden",
+    "yearly": "Jaarlijks",
+    "dateRangeLabel": "Periode",
+    "dateRanges": {
+      "last1d": "Laatste dag",
+      "last7d": "Laatste 7 dagen",
+      "last14d": "Laatste 14 dagen",
+      "last30d": "Laatste 30 dagen",
+      "last60d": "Laatste 60 dagen",
+      "last90d": "Laatste 90 dagen",
+      "last6m": "Laatste 6 maanden",
+      "last12m": "Laatste 12 maanden",
+      "ytd": "Dit jaar tot nu",
+      "allTime": "Alle tijd"
+    },
+    "sections": "Rapportsecties",
+    "sectionsDescription": "Selecteer welke secties in het geplande rapport moeten worden opgenomen.",
+    "sectionsIncluded": "Opgenomen secties",
+    "selectAll": "Alles selecteren",
+    "clearAll": "Alles wissen",
+    "recipients": "Ontvangers",
+    "recipientsDescription": "Selecteer teamleden die het rapport per e-mail ontvangen.",
+    "recipientsCount": "Ontvangers",
+    "endDate": "Einddatum",
+    "endDateDescription": "Optioneel de planning stoppen na deze datum.",
+    "enabled": "Ingeschakeld",
+    "disabled": "Uitgeschakeld",
+    "nextRun": "Volgende uitvoering",
+    "lastRun": "Laatste uitvoering",
+    "runCount": "Verzonden rapporten",
+    "save": "Planning opslaan",
+    "create": "Planning aanmaken",
+    "delete": "Planning verwijderen",
+    "created": "Rapportplanning aangemaakt",
+    "updated": "Rapportplanning bijgewerkt",
+    "deleted": "Rapportplanning verwijderd",
+    "toggledOn": "Rapportplanning ingeschakeld",
+    "toggledOff": "Rapportplanning uitgeschakeld",
+    "noMembers": "Geen teamleden gevonden",
+    "addRecipient": "Ontvanger toevoegen...",
+    "searchMembers": "Leden zoeken...",
+    "sendNow": "Nu verzenden",
+    "sentNow": "Rapport succesvol verzonden",
+    "newSchedule": "Nieuwe planning",
+    "editSchedule": "Planning bewerken",
+    "edit": "Bewerken",
+    "cancel": "Annuleren",
+    "sent": "verzonden",
+    "emptyTitle": "Geen geplande rapporten",
+    "emptyDescription": "Maak een planning aan om automatisch rapporten te genereren en naar uw team te sturen.",
+    "sectionLabels": {
+      "revenue": "Omzet",
+      "tax": "Belasting",
+      "pastDue": "Achterstallige facturen",
+      "services": "Diensten",
+      "customers": "Klanten",
+      "technicians": "Technici",
+      "parts": "Onderdeelgebruik",
+      "jobAnalytics": "Werkanalyses",
+      "retention": "Klantbehoud",
+      "inventory": "Inventaris"
+    }
   }
 }

+ 91 - 2
messages/pl/reports.json

@@ -14,11 +14,14 @@
     "retention": "Utrzymanie Klientów"
   },
   "dateRange": {
-    "to": "do"
+    "to": "do",
+    "pickDate": "Wybierz zakres dat",
+    "apply": "Zastosuj"
   },
   "actions": {
     "refresh": "Odśwież",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Przychody",
@@ -242,5 +245,91 @@
     "partsCost": "Koszt części",
     "netProfit": "Zysk netto"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Nie załadowano jeszcze danych. Kliknij Odśwież, aby załadować raport."
 }

+ 80 - 0
messages/pl/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "Dostawca AI i funkcje"
       },
+      "reportSchedule": {
+        "title": "Harmonogram raportów",
+        "description": "Automatyczne wysyłanie raportów"
+      },
       "appearance": {
         "title": "Wyglad",
         "description": "Motyw i wyswietlanie"
@@ -1027,5 +1031,81 @@
       "medium": "Średni",
       "low": "Niski koszt"
     }
+  },
+  "reportSchedule": {
+    "title": "Harmonogram raportów",
+    "description": "Automatycznie generuj i wysyłaj raporty PDF e-mailem według harmonogramu.",
+    "schedule": "Harmonogram",
+    "name": "Nazwa harmonogramu",
+    "namePlaceholder": "Np. Tygodniowy raport biznesowy",
+    "frequency": "Częstotliwość",
+    "daily": "Codziennie",
+    "weekly": "Co tydzień",
+    "biweekly": "Co 2 tygodnie",
+    "monthly": "Co miesiąc",
+    "bimonthly": "Co 2 miesiące",
+    "quarterly": "Co 4 miesiące",
+    "semiannually": "Co 6 miesięcy",
+    "yearly": "Rocznie",
+    "dateRangeLabel": "Okres",
+    "dateRanges": {
+      "last1d": "Ostatni dzień",
+      "last7d": "Ostatnie 7 dni",
+      "last14d": "Ostatnie 14 dni",
+      "last30d": "Ostatnie 30 dni",
+      "last60d": "Ostatnie 60 dni",
+      "last90d": "Ostatnie 90 dni",
+      "last6m": "Ostatnie 6 miesięcy",
+      "last12m": "Ostatnie 12 miesięcy",
+      "ytd": "Od początku roku",
+      "allTime": "Cały okres"
+    },
+    "sections": "Sekcje raportu",
+    "sectionsDescription": "Wybierz, które sekcje uwzględnić w zaplanowanym raporcie.",
+    "sectionsIncluded": "Uwzględnione sekcje",
+    "selectAll": "Zaznacz wszystko",
+    "clearAll": "Odznacz wszystko",
+    "recipients": "Odbiorcy",
+    "recipientsDescription": "Wybierz członków zespołu, którzy otrzymają raport e-mailem.",
+    "recipientsCount": "Odbiorcy",
+    "endDate": "Data zakończenia",
+    "endDateDescription": "Opcjonalnie zatrzymaj harmonogram po tej dacie.",
+    "enabled": "Włączony",
+    "disabled": "Wyłączony",
+    "nextRun": "Następne wykonanie",
+    "lastRun": "Ostatnie wykonanie",
+    "runCount": "Wysłane raporty",
+    "save": "Zapisz harmonogram",
+    "create": "Utwórz harmonogram",
+    "delete": "Usuń harmonogram",
+    "created": "Harmonogram raportów utworzony",
+    "updated": "Harmonogram raportów zaktualizowany",
+    "deleted": "Harmonogram raportów usunięty",
+    "toggledOn": "Harmonogram raportów włączony",
+    "toggledOff": "Harmonogram raportów wyłączony",
+    "noMembers": "Nie znaleziono członków zespołu",
+    "addRecipient": "Dodaj odbiorcę...",
+    "searchMembers": "Szukaj członków...",
+    "sendNow": "Wyślij teraz",
+    "sentNow": "Raport wysłany pomyślnie",
+    "newSchedule": "Nowy harmonogram",
+    "editSchedule": "Edytuj harmonogram",
+    "edit": "Edytuj",
+    "cancel": "Anuluj",
+    "sent": "wysłano",
+    "emptyTitle": "Brak zaplanowanych raportów",
+    "emptyDescription": "Utwórz harmonogram, aby automatycznie generować i wysyłać raporty do zespołu.",
+    "sectionLabels": {
+      "revenue": "Przychody",
+      "tax": "Podatki",
+      "pastDue": "Zaległe faktury",
+      "services": "Usługi",
+      "customers": "Klienci",
+      "technicians": "Technicy",
+      "parts": "Zużycie części",
+      "jobAnalytics": "Analizy zleceń",
+      "retention": "Lojalność klientów",
+      "inventory": "Magazyn"
+    }
   }
 }

+ 91 - 2
messages/pt-BR/reports.json

@@ -14,11 +14,14 @@
     "retention": "Retenção"
   },
   "dateRange": {
-    "to": "até"
+    "to": "até",
+    "pickDate": "Selecionar período",
+    "apply": "Aplicar"
   },
   "actions": {
     "refresh": "Atualizar",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Receita",
@@ -242,5 +245,91 @@
     "partsCost": "Custo de peças",
     "netProfit": "Lucro líquido"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Nenhum dado carregado ainda. Clique em Atualizar para carregar o relatório."
 }

+ 80 - 0
messages/pt-BR/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "Provedor de IA e recursos"
       },
+      "reportSchedule": {
+        "title": "Agendamento de relatórios",
+        "description": "Envio automático de relatórios"
+      },
       "appearance": {
         "title": "Aparencia",
         "description": "Tema e exibicao"
@@ -1027,5 +1031,81 @@
       "medium": "Médio",
       "low": "Baixo custo"
     }
+  },
+  "reportSchedule": {
+    "title": "Agendamento de relatórios",
+    "description": "Gere e envie automaticamente relatórios em PDF por e-mail em um cronograma recorrente.",
+    "schedule": "Agendamento",
+    "name": "Nome do agendamento",
+    "namePlaceholder": "Ex. Relatório semanal de negócios",
+    "frequency": "Frequência",
+    "daily": "Diário",
+    "weekly": "Semanal",
+    "biweekly": "A cada 2 semanas",
+    "monthly": "Mensal",
+    "bimonthly": "A cada 2 meses",
+    "quarterly": "A cada 4 meses",
+    "semiannually": "A cada 6 meses",
+    "yearly": "Anual",
+    "dateRangeLabel": "Período",
+    "dateRanges": {
+      "last1d": "Último dia",
+      "last7d": "Últimos 7 dias",
+      "last14d": "Últimos 14 dias",
+      "last30d": "Últimos 30 dias",
+      "last60d": "Últimos 60 dias",
+      "last90d": "Últimos 90 dias",
+      "last6m": "Últimos 6 meses",
+      "last12m": "Últimos 12 meses",
+      "ytd": "Acumulado do ano",
+      "allTime": "Todo o período"
+    },
+    "sections": "Seções do relatório",
+    "sectionsDescription": "Selecione quais seções incluir no relatório agendado.",
+    "sectionsIncluded": "Seções incluídas",
+    "selectAll": "Selecionar tudo",
+    "clearAll": "Limpar tudo",
+    "recipients": "Destinatários",
+    "recipientsDescription": "Selecione os membros da equipe que receberão o relatório por e-mail.",
+    "recipientsCount": "Destinatários",
+    "endDate": "Data de término",
+    "endDateDescription": "Opcionalmente interrompa o agendamento após esta data.",
+    "enabled": "Ativado",
+    "disabled": "Desativado",
+    "nextRun": "Próxima execução",
+    "lastRun": "Última execução",
+    "runCount": "Relatórios enviados",
+    "save": "Salvar agendamento",
+    "create": "Criar agendamento",
+    "delete": "Excluir agendamento",
+    "created": "Agendamento de relatórios criado",
+    "updated": "Agendamento de relatórios atualizado",
+    "deleted": "Agendamento de relatórios excluído",
+    "toggledOn": "Agendamento de relatórios ativado",
+    "toggledOff": "Agendamento de relatórios desativado",
+    "noMembers": "Nenhum membro encontrado",
+    "addRecipient": "Adicionar destinatário...",
+    "searchMembers": "Buscar membros...",
+    "sendNow": "Enviar agora",
+    "sentNow": "Relatório enviado com sucesso",
+    "newSchedule": "Novo agendamento",
+    "editSchedule": "Editar agendamento",
+    "edit": "Editar",
+    "cancel": "Cancelar",
+    "sent": "enviados",
+    "emptyTitle": "Nenhum relatório agendado",
+    "emptyDescription": "Crie um agendamento para gerar e enviar relatórios automaticamente à sua equipe.",
+    "sectionLabels": {
+      "revenue": "Receita",
+      "tax": "Impostos",
+      "pastDue": "Faturas vencidas",
+      "services": "Serviços",
+      "customers": "Clientes",
+      "technicians": "Técnicos",
+      "parts": "Uso de peças",
+      "jobAnalytics": "Análises de serviços",
+      "retention": "Retenção de clientes",
+      "inventory": "Estoque"
+    }
   }
 }

+ 91 - 2
messages/ru/reports.json

@@ -14,11 +14,14 @@
     "retention": "Удержание"
   },
   "dateRange": {
-    "to": "до"
+    "to": "до",
+    "pickDate": "Выбрать диапазон дат",
+    "apply": "Применить"
   },
   "actions": {
     "refresh": "Обновить",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Доход",
@@ -242,5 +245,91 @@
     "partsCost": "Стоимость запчастей",
     "netProfit": "Чистая прибыль"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Данные ещё не загружены. Нажмите «Обновить» для загрузки отчёта."
 }

+ 80 - 0
messages/ru/settings.json

@@ -82,6 +82,10 @@
         "title": "ИИ",
         "description": "Провайдер ИИ и функции"
       },
+      "reportSchedule": {
+        "title": "Расписание отчётов",
+        "description": "Автоматическая отправка отчётов"
+      },
       "appearance": {
         "title": "Внешний вид",
         "description": "Тема и отображение"
@@ -1027,5 +1031,81 @@
       "medium": "Средняя",
       "low": "Низкая стоимость"
     }
+  },
+  "reportSchedule": {
+    "title": "Расписание отчётов",
+    "description": "Автоматически создавайте и отправляйте отчёты в формате PDF по электронной почте по расписанию.",
+    "schedule": "Расписание",
+    "name": "Название расписания",
+    "namePlaceholder": "Напр. Еженедельный бизнес-отчёт",
+    "frequency": "Частота",
+    "daily": "Ежедневно",
+    "weekly": "Еженедельно",
+    "biweekly": "Каждые 2 недели",
+    "monthly": "Ежемесячно",
+    "bimonthly": "Каждые 2 месяца",
+    "quarterly": "Каждые 4 месяца",
+    "semiannually": "Каждые 6 месяцев",
+    "yearly": "Ежегодно",
+    "dateRangeLabel": "Период",
+    "dateRanges": {
+      "last1d": "Последний день",
+      "last7d": "Последние 7 дней",
+      "last14d": "Последние 14 дней",
+      "last30d": "Последние 30 дней",
+      "last60d": "Последние 60 дней",
+      "last90d": "Последние 90 дней",
+      "last6m": "Последние 6 месяцев",
+      "last12m": "Последние 12 месяцев",
+      "ytd": "С начала года",
+      "allTime": "За всё время"
+    },
+    "sections": "Разделы отчёта",
+    "sectionsDescription": "Выберите разделы для включения в запланированный отчёт.",
+    "sectionsIncluded": "Включённые разделы",
+    "selectAll": "Выбрать все",
+    "clearAll": "Снять все",
+    "recipients": "Получатели",
+    "recipientsDescription": "Выберите участников команды, которые будут получать отчёт по электронной почте.",
+    "recipientsCount": "Получатели",
+    "endDate": "Дата окончания",
+    "endDateDescription": "Необязательно остановить расписание после этой даты.",
+    "enabled": "Включено",
+    "disabled": "Отключено",
+    "nextRun": "Следующий запуск",
+    "lastRun": "Последний запуск",
+    "runCount": "Отправлено отчётов",
+    "save": "Сохранить расписание",
+    "create": "Создать расписание",
+    "delete": "Удалить расписание",
+    "created": "Расписание отчётов создано",
+    "updated": "Расписание отчётов обновлено",
+    "deleted": "Расписание отчётов удалено",
+    "toggledOn": "Расписание отчётов включено",
+    "toggledOff": "Расписание отчётов отключено",
+    "noMembers": "Участники команды не найдены",
+    "addRecipient": "Добавить получателя...",
+    "searchMembers": "Поиск участников...",
+    "sendNow": "Отправить сейчас",
+    "sentNow": "Отчёт успешно отправлен",
+    "newSchedule": "Новое расписание",
+    "editSchedule": "Редактировать расписание",
+    "edit": "Редактировать",
+    "cancel": "Отмена",
+    "sent": "отправлено",
+    "emptyTitle": "Нет запланированных отчётов",
+    "emptyDescription": "Создайте расписание для автоматической генерации и отправки отчётов вашей команде.",
+    "sectionLabels": {
+      "revenue": "Доходы",
+      "tax": "Налоги",
+      "pastDue": "Просроченные счета",
+      "services": "Услуги",
+      "customers": "Клиенты",
+      "technicians": "Техники",
+      "parts": "Использование запчастей",
+      "jobAnalytics": "Аналитика заказов",
+      "retention": "Удержание клиентов",
+      "inventory": "Склад"
+    }
   }
 }

+ 91 - 2
messages/tr/reports.json

@@ -14,11 +14,14 @@
     "retention": "Müşteri Sadakati"
   },
   "dateRange": {
-    "to": "-"
+    "to": "-",
+    "pickDate": "Tarih aralığı seçin",
+    "apply": "Uygula"
   },
   "actions": {
     "refresh": "Yenile",
-    "csv": "CSV"
+    "csv": "CSV",
+    "pdf": "PDF"
   },
   "revenue": {
     "revenue": "Gelir",
@@ -242,5 +245,91 @@
     "partsCost": "Parça maliyeti",
     "netProfit": "Net kâr"
   },
+  "pdf": {
+    "reportTitle": "Business Report",
+    "revenueSection": "Revenue Report",
+    "taxSection": "Tax Report",
+    "pastDueSection": "Past Due Invoices",
+    "servicesSection": "Services Report",
+    "customersSection": "Customers Report",
+    "techniciansSection": "Technicians Report",
+    "partsSection": "Parts Usage Report",
+    "jobAnalyticsSection": "Job Analytics",
+    "retentionSection": "Customer Retention",
+    "inventorySection": "Inventory Report",
+    "monthlyBreakdown": "Monthly Breakdown",
+    "revenueByType": "Revenue by Type",
+    "taxByRate": "Tax by Rate",
+    "servicesByStatus": "By Status",
+    "servicesByType": "By Type",
+    "topCustomers": "Top Customers",
+    "topServiceTypes": "Top Service Types",
+    "topReturning": "Top Returning Customers",
+    "dayOfWeek": "Jobs by Day of Week",
+    "monthlyTrend": "Monthly Trend",
+    "lowStock": "Low Stock Items",
+    "revenue": "Revenue",
+    "collected": "Collected",
+    "outstanding": "Outstanding",
+    "services": "Services",
+    "partsCost": "Parts Cost",
+    "netProfit": "Net Profit",
+    "month": "Month",
+    "count": "Count",
+    "type": "Type",
+    "status": "Status",
+    "name": "Name",
+    "company": "Company",
+    "totalSpent": "Total Spent",
+    "customer": "Customer",
+    "invoiceNumber": "Invoice #",
+    "totalAmount": "Total Amount",
+    "amountDue": "Amount Due",
+    "dueDate": "Due Date",
+    "daysPastDue": "Days Past Due",
+    "totalPastDue": "Total Past Due",
+    "totalAmountDue": "Total Amount Due",
+    "over30": "Over 30 Days",
+    "over60": "Over 60 Days",
+    "over90": "Over 90 Days",
+    "taxCollected": "Tax Collected",
+    "taxableAmount": "Taxable Amount",
+    "taxRate": "Tax Rate",
+    "invoiceCount": "Invoice Count",
+    "invoices": "Invoices",
+    "totalServices": "Total Services",
+    "totalCustomers": "Total Customers",
+    "activeCustomers": "Active Customers",
+    "technician": "Technician",
+    "jobs": "Jobs",
+    "totalRevenue": "Total Revenue",
+    "avgRevenue": "Avg Revenue",
+    "totalHours": "Total Hours",
+    "avgHours": "Avg Hours",
+    "totalJobs": "Total Jobs",
+    "partName": "Part Name",
+    "partNumber": "Part #",
+    "usageCount": "Usage Count",
+    "totalQty": "Total Qty",
+    "totalPartsRevenue": "Parts Revenue",
+    "totalPartsUsed": "Parts Used",
+    "serviceType": "Service Type",
+    "avgValue": "Avg Value",
+    "avgJobValue": "Avg Job Value",
+    "returningCustomers": "Returning",
+    "newCustomers": "New",
+    "totalActive": "Total Active",
+    "avgTimeBetweenVisits": "Avg Days Between Visits",
+    "avgDaysBetweenVisits": "Avg Days",
+    "visits": "Visits",
+    "days": "days",
+    "totalParts": "Total Parts",
+    "totalItems": "Total Items",
+    "totalValue": "Total Value",
+    "totalSellValue": "Sell Value",
+    "quantity": "Qty",
+    "minQuantity": "Min Qty",
+    "unitCost": "Unit Cost"
+  },
   "empty": "Henüz veri yüklenmedi. Raporu yüklemek için Yenile'ye tıklayın."
 }

+ 80 - 0
messages/tr/settings.json

@@ -82,6 +82,10 @@
         "title": "AI",
         "description": "Yapay zeka saglayici ve ozellikler"
       },
+      "reportSchedule": {
+        "title": "Rapor zamanlayıcı",
+        "description": "Otomatik rapor gönderimi"
+      },
       "appearance": {
         "title": "Görünüm",
         "description": "Tema & görüntüleme"
@@ -1027,5 +1031,81 @@
       "medium": "Orta",
       "low": "Düşük maliyet"
     }
+  },
+  "reportSchedule": {
+    "title": "Rapor zamanlayıcı",
+    "description": "Otomatik olarak PDF raporları oluşturun ve e-posta ile tekrarlayan bir programa göre gönderin.",
+    "schedule": "Zamanlama",
+    "name": "Zamanlama adı",
+    "namePlaceholder": "Örn. Haftalık iş raporu",
+    "frequency": "Sıklık",
+    "daily": "Günlük",
+    "weekly": "Haftalık",
+    "biweekly": "2 haftada bir",
+    "monthly": "Aylık",
+    "bimonthly": "2 ayda bir",
+    "quarterly": "4 ayda bir",
+    "semiannually": "6 ayda bir",
+    "yearly": "Yıllık",
+    "dateRangeLabel": "Dönem",
+    "dateRanges": {
+      "last1d": "Son gün",
+      "last7d": "Son 7 gün",
+      "last14d": "Son 14 gün",
+      "last30d": "Son 30 gün",
+      "last60d": "Son 60 gün",
+      "last90d": "Son 90 gün",
+      "last6m": "Son 6 ay",
+      "last12m": "Son 12 ay",
+      "ytd": "Yıl başından bugüne",
+      "allTime": "Tüm zamanlar"
+    },
+    "sections": "Rapor bölümleri",
+    "sectionsDescription": "Zamanlanan rapora dahil edilecek bölümleri seçin.",
+    "sectionsIncluded": "Dahil edilen bölümler",
+    "selectAll": "Tümünü seç",
+    "clearAll": "Tümünü temizle",
+    "recipients": "Alıcılar",
+    "recipientsDescription": "Raporu e-posta ile alacak ekip üyelerini seçin.",
+    "recipientsCount": "Alıcılar",
+    "endDate": "Bitiş tarihi",
+    "endDateDescription": "İsteğe bağlı olarak bu tarihten sonra zamanlamayı durdurun.",
+    "enabled": "Etkin",
+    "disabled": "Devre dışı",
+    "nextRun": "Sonraki çalışma",
+    "lastRun": "Son çalışma",
+    "runCount": "Gönderilen raporlar",
+    "save": "Zamanlamayı kaydet",
+    "create": "Zamanlama oluştur",
+    "delete": "Zamanlamayı sil",
+    "created": "Rapor zamanlaması oluşturuldu",
+    "updated": "Rapor zamanlaması güncellendi",
+    "deleted": "Rapor zamanlaması silindi",
+    "toggledOn": "Rapor zamanlaması etkinleştirildi",
+    "toggledOff": "Rapor zamanlaması devre dışı bırakıldı",
+    "noMembers": "Ekip üyesi bulunamadı",
+    "addRecipient": "Alıcı ekle...",
+    "searchMembers": "Üye ara...",
+    "sendNow": "Şimdi gönder",
+    "sentNow": "Rapor başarıyla gönderildi",
+    "newSchedule": "Yeni zamanlama",
+    "editSchedule": "Zamanlamayı düzenle",
+    "edit": "Düzenle",
+    "cancel": "İptal",
+    "sent": "gönderildi",
+    "emptyTitle": "Zamanlanmış rapor yok",
+    "emptyDescription": "Ekibinize otomatik olarak rapor oluşturmak ve göndermek için bir zamanlama oluşturun.",
+    "sectionLabels": {
+      "revenue": "Gelir",
+      "tax": "Vergi",
+      "pastDue": "Vadesi geçmiş faturalar",
+      "services": "Hizmetler",
+      "customers": "Müşteriler",
+      "technicians": "Teknisyenler",
+      "parts": "Parça kullanımı",
+      "jobAnalytics": "İş analizleri",
+      "retention": "Müşteri sadakati",
+      "inventory": "Envanter"
+    }
   }
 }

+ 29 - 0
prisma/migrations/20260329102940_report_scheduler/migration.sql

@@ -0,0 +1,29 @@
+-- CreateTable
+CREATE TABLE "report_schedules" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL DEFAULT 'Scheduled Report',
+    "frequency" TEXT NOT NULL,
+    "dateRange" TEXT NOT NULL DEFAULT 'last30d',
+    "sections" TEXT NOT NULL,
+    "recipients" TEXT NOT NULL,
+    "nextRunDate" TIMESTAMP(3) NOT NULL,
+    "endDate" TIMESTAMP(3),
+    "isActive" BOOLEAN NOT NULL DEFAULT true,
+    "lastRunAt" TIMESTAMP(3),
+    "runCount" INTEGER NOT NULL DEFAULT 0,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdById" TEXT NOT NULL,
+
+    CONSTRAINT "report_schedules_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "report_schedules_organizationId_idx" ON "report_schedules"("organizationId");
+
+-- CreateIndex
+CREATE INDEX "report_schedules_nextRunDate_isActive_idx" ON "report_schedules"("nextRunDate", "isActive");
+
+-- AddForeignKey
+ALTER TABLE "report_schedules" ADD CONSTRAINT "report_schedules_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 26 - 0
prisma/schema.prisma

@@ -616,6 +616,7 @@ model Organization {
   technicians            Technician[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
+  reportSchedules        ReportSchedule[]
 
   @@map("organizations")
 }
@@ -1139,3 +1140,28 @@ model StatusReport {
   @@index([expiresAt])
   @@map("status_reports")
 }
+
+model ReportSchedule {
+  id          String    @id @default(cuid())
+  name        String    @default("Scheduled Report")
+  frequency   String // "daily" | "weekly" | "monthly" etc.
+  dateRange   String    @default("last30d") // "last1d" | "last7d" | "last30d" | "ytd" | "allTime" etc.
+  sections    String // JSON array of section keys
+  recipients  String // JSON array of user IDs
+  nextRunDate DateTime
+  endDate     DateTime?
+  isActive    Boolean   @default(true)
+  lastRunAt   DateTime?
+  runCount    Int       @default(0)
+  createdAt   DateTime  @default(now())
+  updatedAt   DateTime  @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdById String
+
+  @@index([organizationId])
+  @@index([nextRunDate, isActive])
+  @@map("report_schedules")
+}

+ 3 - 2
src/app/(authenticated)/reports/page.tsx

@@ -43,15 +43,16 @@ export default async function ReportsPage() {
     );
   }
 
-  const settingsResult = await getSettings([SETTING_KEYS.CURRENCY_CODE]);
+  const settingsResult = await getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.INVOICE_PRIMARY_COLOR]);
   const settings = settingsResult.success && settingsResult.data ? settingsResult.data : {};
   const currencyCode = settings[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const primaryColor = settings[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || "#d97706";
 
   return (
     <>
       <PageHeader />
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <ReportsClient currencyCode={currencyCode} />
+        <ReportsClient currencyCode={currencyCode} primaryColor={primaryColor} />
       </div>
     </>
   );

+ 270 - 33
src/app/(authenticated)/reports/reports-client.tsx

@@ -1,9 +1,18 @@
 "use client";
 
 import { useState, useEffect, useCallback } from "react";
+import { useSearchParams, useRouter, usePathname } from "next/navigation";
 import { useTranslations } from "next-intl";
 import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
+import { Calendar } from "@/components/ui/calendar";
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { format } from "date-fns";
+import type { DateRange } from "react-day-picker";
 import {
   Card,
   CardContent,
@@ -38,7 +47,9 @@ import {
   AlertTriangle,
   Wrench,
   Cog,
+  CalendarIcon,
   CalendarDays,
+  FileText,
   UserCheck,
   Receipt,
   Clock,
@@ -90,6 +101,8 @@ import {
   exportTaxCsv,
   exportPastDueInvoicesCsv,
 } 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 FinancialSubTab = "revenue" | "past-due-invoices" | "tax";
@@ -98,18 +111,41 @@ type PastDueSortDir = "asc" | "desc";
 
 interface ReportsClientProps {
   currencyCode: string;
+  primaryColor: string;
 }
 
-export default function ReportsClient({ currencyCode }: ReportsClientProps) {
+const VALID_TABS: ReportTab[] = ["financial", "services", "customers", "inventory", "technicians", "parts", "job-analytics", "retention"];
+const VALID_SUB_TABS: FinancialSubTab[] = ["revenue", "past-due-invoices", "tax"];
+
+export default function ReportsClient({ currencyCode, primaryColor }: ReportsClientProps) {
   const t = useTranslations("reports");
+  const searchParams = useSearchParams();
+  const router = useRouter();
+  const pathname = usePathname();
   const currentYear = new Date().getFullYear();
-  const defaultStart = `${currentYear}-01-01`;
-  const defaultEnd = new Date().toISOString().split("T")[0];
 
-  const [activeTab, setActiveTab] = useState<ReportTab>("financial");
-  const [financialSubTab, setFinancialSubTab] = useState<FinancialSubTab>("revenue");
-  const [startDate, setStartDate] = useState(defaultStart);
-  const [endDate, setEndDate] = useState(defaultEnd);
+  const initialTab = (VALID_TABS.includes(searchParams.get("tab") as ReportTab) ? searchParams.get("tab") : "financial") as ReportTab;
+  const initialSubTab = (VALID_SUB_TABS.includes(searchParams.get("subtab") as FinancialSubTab) ? searchParams.get("subtab") : "revenue") as FinancialSubTab;
+
+  const [activeTab, setActiveTab] = useState<ReportTab>(initialTab);
+  const [financialSubTab, setFinancialSubTab] = useState<FinancialSubTab>(initialSubTab);
+
+  const updateUrl = useCallback((tab: string, subtab?: string) => {
+    const params = new URLSearchParams(searchParams.toString());
+    params.set("tab", tab);
+    if (subtab) {
+      params.set("subtab", subtab);
+    } else {
+      params.delete("subtab");
+    }
+    router.replace(`${pathname}?${params.toString()}`, { scroll: false });
+  }, [searchParams, router, pathname]);
+  const [dateRange, setDateRange] = useState<DateRange>({
+    from: new Date(currentYear, 0, 1),
+    to: new Date(),
+  });
+  const [pendingDateRange, setPendingDateRange] = useState<DateRange>(dateRange);
+  const [datePickerOpen, setDatePickerOpen] = useState(false);
   const [loading, setLoading] = useState(false);
 
   // Past due invoices state
@@ -136,10 +172,14 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
   type FetchableReport = ReportTab | FinancialSubTab;
 
   const fetchReport = useCallback(
-    async (type: FetchableReport) => {
+    async (type: FetchableReport, overrideDateRange?: DateRange) => {
+      const range = overrideDateRange ?? dateRange;
       setLoading(true);
       try {
-        const dateParams = { startDate, endDate };
+        const dateParams = {
+          startDate: range.from ? format(range.from, "yyyy-MM-dd") : "",
+          endDate: range.to ? format(range.to, "yyyy-MM-dd") : "",
+        };
         switch (type) {
           case "revenue": {
             const result = await getRevenueReport(dateParams);
@@ -198,19 +238,23 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
         setLoading(false);
       }
     },
-    [startDate, endDate],
+    [dateRange],
   );
 
-  // Auto-fetch revenue on mount
+  // Auto-fetch current tab on mount
   useEffect(() => {
-    fetchReport("revenue");
+    if (activeTab === "financial") {
+      fetchReport(financialSubTab);
+    } else {
+      fetchReport(activeTab);
+    }
   }, []); // eslint-disable-line react-hooks/exhaustive-deps
 
   const handleTabChange = (value: string) => {
     const tab = value as ReportTab;
     setActiveTab(tab);
+    updateUrl(tab, tab === "financial" ? financialSubTab : undefined);
     if (tab === "financial") {
-      // Fetch the current financial sub-tab if not loaded
       const subDataMap: Record<FinancialSubTab, unknown> = {
         revenue: revenueData,
         "past-due-invoices": pastDueData,
@@ -238,6 +282,7 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
   const handleFinancialSubTabChange = (value: string) => {
     const subTab = value as FinancialSubTab;
     setFinancialSubTab(subTab);
+    updateUrl("financial", subTab);
     const subDataMap: Record<FinancialSubTab, unknown> = {
       revenue: revenueData,
       "past-due-invoices": pastDueData,
@@ -297,6 +342,148 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
     }
   };
 
+  const [pdfLoading, setPdfLoading] = useState(false);
+
+  const handlePdfExport = async () => {
+    setPdfLoading(true);
+    try {
+      const from = dateRange.from ? format(dateRange.from, "LLL dd, y") : "";
+      const to = dateRange.to ? format(dateRange.to, "LLL dd, y") : "";
+      const dateRangeStr = from && to ? `${from} – ${to}` : from || to;
+
+      const labels: Record<string, string> = {
+        reportTitle: t("pdf.reportTitle"),
+        revenueSection: t("pdf.revenueSection"),
+        taxSection: t("pdf.taxSection"),
+        pastDueSection: t("pdf.pastDueSection"),
+        servicesSection: t("pdf.servicesSection"),
+        customersSection: t("pdf.customersSection"),
+        techniciansSection: t("pdf.techniciansSection"),
+        partsSection: t("pdf.partsSection"),
+        jobAnalyticsSection: t("pdf.jobAnalyticsSection"),
+        retentionSection: t("pdf.retentionSection"),
+        inventorySection: t("pdf.inventorySection"),
+        monthlyBreakdown: t("pdf.monthlyBreakdown"),
+        revenueByType: t("pdf.revenueByType"),
+        taxByRate: t("pdf.taxByRate"),
+        servicesByStatus: t("pdf.servicesByStatus"),
+        servicesByType: t("pdf.servicesByType"),
+        topCustomers: t("pdf.topCustomers"),
+        topServiceTypes: t("pdf.topServiceTypes"),
+        topReturning: t("pdf.topReturning"),
+        dayOfWeek: t("pdf.dayOfWeek"),
+        monthlyTrend: t("pdf.monthlyTrend"),
+        lowStock: t("pdf.lowStock"),
+        // common labels
+        revenue: t("pdf.revenue"),
+        collected: t("pdf.collected"),
+        outstanding: t("pdf.outstanding"),
+        services: t("pdf.services"),
+        partsCost: t("pdf.partsCost"),
+        netProfit: t("pdf.netProfit"),
+        month: t("pdf.month"),
+        count: t("pdf.count"),
+        type: t("pdf.type"),
+        status: t("pdf.status"),
+        name: t("pdf.name"),
+        company: t("pdf.company"),
+        totalSpent: t("pdf.totalSpent"),
+        customer: t("pdf.customer"),
+        invoiceNumber: t("pdf.invoiceNumber"),
+        totalAmount: t("pdf.totalAmount"),
+        amountDue: t("pdf.amountDue"),
+        dueDate: t("pdf.dueDate"),
+        daysPastDue: t("pdf.daysPastDue"),
+        totalPastDue: t("pdf.totalPastDue"),
+        totalAmountDue: t("pdf.totalAmountDue"),
+        over30: t("pdf.over30"),
+        over60: t("pdf.over60"),
+        over90: t("pdf.over90"),
+        taxCollected: t("pdf.taxCollected"),
+        taxableAmount: t("pdf.taxableAmount"),
+        taxRate: t("pdf.taxRate"),
+        invoiceCount: t("pdf.invoiceCount"),
+        invoices: t("pdf.invoices"),
+        totalServices: t("pdf.totalServices"),
+        totalCustomers: t("pdf.totalCustomers"),
+        activeCustomers: t("pdf.activeCustomers"),
+        technician: t("pdf.technician"),
+        jobs: t("pdf.jobs"),
+        totalRevenue: t("pdf.totalRevenue"),
+        avgRevenue: t("pdf.avgRevenue"),
+        totalHours: t("pdf.totalHours"),
+        avgHours: t("pdf.avgHours"),
+        totalJobs: t("pdf.totalJobs"),
+        partName: t("pdf.partName"),
+        partNumber: t("pdf.partNumber"),
+        usageCount: t("pdf.usageCount"),
+        totalQty: t("pdf.totalQty"),
+        totalPartsRevenue: t("pdf.totalPartsRevenue"),
+        totalPartsUsed: t("pdf.totalPartsUsed"),
+        serviceType: t("pdf.serviceType"),
+        avgValue: t("pdf.avgValue"),
+        avgJobValue: t("pdf.avgJobValue"),
+        returningCustomers: t("pdf.returningCustomers"),
+        newCustomers: t("pdf.newCustomers"),
+        totalActive: t("pdf.totalActive"),
+        avgTimeBetweenVisits: t("pdf.avgTimeBetweenVisits"),
+        avgDaysBetweenVisits: t("pdf.avgDaysBetweenVisits"),
+        visits: t("pdf.visits"),
+        days: t("pdf.days"),
+        totalParts: t("pdf.totalParts"),
+        totalItems: t("pdf.totalItems"),
+        totalValue: t("pdf.totalValue"),
+        totalSellValue: t("pdf.totalSellValue"),
+        quantity: t("pdf.quantity"),
+        minQuantity: t("pdf.minQuantity"),
+        unitCost: t("pdf.unitCost"),
+      };
+
+      // Only include data for the current tab
+      const pdfProps: Record<string, unknown> = {};
+      let filename = "report";
+      if (activeTab === "financial") {
+        if (financialSubTab === "revenue") { pdfProps.revenueData = revenueData; filename = "revenue-report"; }
+        else if (financialSubTab === "tax") { pdfProps.taxData = taxData; filename = "tax-report"; }
+        else if (financialSubTab === "past-due-invoices") { pdfProps.pastDueData = pastDueData; filename = "past-due-invoices-report"; }
+      } else {
+        filename = `${activeTab}-report`;
+        const dataMap: Record<string, [string, unknown]> = {
+          services: ["serviceData", serviceData],
+          customers: ["customerData", customerData],
+          inventory: ["inventoryData", inventoryData],
+          technicians: ["technicianData", technicianData],
+          parts: ["partsData", partsData],
+          "job-analytics": ["jobAnalyticsData", jobAnalyticsData],
+          retention: ["retentionData", retentionData],
+        };
+        const entry = dataMap[activeTab];
+        if (entry) pdfProps[entry[0] as string] = entry[1];
+      }
+
+      const blob = await pdf(
+        <ReportPDF
+          dateRange={dateRangeStr}
+          currencyCode={currencyCode}
+          primaryColor={primaryColor}
+          labels={labels}
+          {...pdfProps}
+        />,
+      ).toBlob();
+
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement("a");
+      a.href = url;
+      a.download = `${filename}-${format(new Date(), "yyyy-MM-dd")}.pdf`;
+      a.click();
+      URL.revokeObjectURL(url);
+    } catch (error) {
+      console.error("Failed to generate PDF:", error);
+    } finally {
+      setPdfLoading(false);
+    }
+  };
+
   // Determine if we have data for the current view
   const hasData = (() => {
     if (activeTab === "financial") {
@@ -387,30 +574,80 @@ export default function ReportsClient({ currencyCode }: ReportsClientProps) {
 
         <div className="flex items-center gap-2">
           {showDateRange && (
-            <>
-              <Input
-                type="date"
-                value={startDate}
-                onChange={(e) => setStartDate(e.target.value)}
-                className="h-8 w-36 text-sm"
-              />
-              <span className="text-muted-foreground text-sm">{t("dateRange.to")}</span>
-              <Input
-                type="date"
-                value={endDate}
-                onChange={(e) => setEndDate(e.target.value)}
-                className="h-8 w-36 text-sm"
-              />
-            </>
+            <Popover open={datePickerOpen} onOpenChange={(open) => {
+              setDatePickerOpen(open);
+              if (open) setPendingDateRange(dateRange);
+            }}>
+              <PopoverTrigger asChild>
+                <Button
+                  variant="outline"
+                  size="sm"
+                  className={cn(
+                    "justify-start text-left font-normal",
+                    !dateRange.from && "text-muted-foreground",
+                  )}
+                >
+                  <CalendarIcon className="mr-2 h-3.5 w-3.5" />
+                  {dateRange.from ? (
+                    dateRange.to ? (
+                      <>
+                        {format(dateRange.from, "LLL dd, y")} –{" "}
+                        {format(dateRange.to, "LLL dd, y")}
+                      </>
+                    ) : (
+                      format(dateRange.from, "LLL dd, y")
+                    )
+                  ) : (
+                    <span>{t("dateRange.pickDate")}</span>
+                  )}
+                </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="border-t p-3 flex justify-end">
+                  <Button
+                    size="sm"
+                    disabled={!pendingDateRange.from || !pendingDateRange.to}
+                    onClick={() => {
+                      setDateRange(pendingDateRange);
+                      setDatePickerOpen(false);
+                      if (activeTab === "financial") {
+                        fetchReport(financialSubTab, pendingDateRange);
+                      } else {
+                        fetchReport(activeTab, pendingDateRange);
+                      }
+                    }}
+                  >
+                    {t("dateRange.apply")}
+                  </Button>
+                </div>
+              </PopoverContent>
+            </Popover>
           )}
           <Button size="sm" variant="outline" onClick={handleRefresh} disabled={loading}>
             {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : t("actions.refresh")}
           </Button>
           {hasData && (
-            <Button size="sm" variant="outline" onClick={handleExport}>
-              <Download className="mr-1.5 h-3.5 w-3.5" />
-              {t("actions.csv")}
-            </Button>
+            <>
+              <Button size="sm" variant="outline" onClick={handleExport}>
+                <Download className="mr-1.5 h-3.5 w-3.5" />
+                {t("actions.csv")}
+              </Button>
+              <Button size="sm" variant="outline" onClick={handlePdfExport} disabled={pdfLoading}>
+                {pdfLoading ? (
+                  <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
+                ) : (
+                  <FileText className="mr-1.5 h-3.5 w-3.5" />
+                )}
+                {t("actions.pdf")}
+              </Button>
+            </>
           )}
         </div>
       </div>

+ 47 - 0
src/app/(authenticated)/settings/report-schedule/page.tsx

@@ -0,0 +1,47 @@
+import { getLayoutData } from "@/lib/get-layout-data";
+import { getFeatures, isCloudMode } from "@/lib/features";
+import { redirect } from "next/navigation";
+import { FeatureLockedMessage } from "../feature-locked-message";
+import {
+  getReportSchedules,
+  getOrgMembers,
+} from "@/features/report-schedule/Actions/reportScheduleActions";
+import { ReportScheduleSettings } from "./report-schedule-settings";
+
+export default async function ReportSchedulePage() {
+  const data = await getLayoutData();
+
+  if (data.status === "unauthenticated") redirect("/auth/sign-in");
+  if (data.status === "no-organization") redirect("/onboarding");
+
+  const features = await getFeatures(data.organizationId);
+
+  if (!features.reports) {
+    return (
+      <FeatureLockedMessage
+        feature="Report Schedule"
+        description="Automatically generate and email report PDFs on a recurring schedule."
+        isCloud={isCloudMode()}
+      />
+    );
+  }
+
+  const [schedulesResult, membersResult] = await Promise.all([
+    getReportSchedules(),
+    getOrgMembers(),
+  ]);
+
+  const schedules =
+    schedulesResult.success && schedulesResult.data
+      ? schedulesResult.data
+      : [];
+  const members =
+    membersResult.success && membersResult.data ? membersResult.data : [];
+
+  return (
+    <ReportScheduleSettings
+      schedules={schedules}
+      members={members}
+    />
+  );
+}

+ 642 - 0
src/app/(authenticated)/settings/report-schedule/report-schedule-settings.tsx

@@ -0,0 +1,642 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { useTranslations } from 'next-intl'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Switch } from '@/components/ui/switch'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Badge } from '@/components/ui/badge'
+import { Separator } from '@/components/ui/separator'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import {
+  Command,
+  CommandEmpty,
+  CommandInput,
+  CommandItem,
+  CommandList,
+} from '@/components/ui/command'
+import {
+  Loader2,
+  CalendarClock,
+  Clock,
+  Send,
+  Trash2,
+  Users,
+  Plus,
+  Pencil,
+  X,
+  ChevronsUpDown,
+} from 'lucide-react'
+import {
+  createReportSchedule,
+  updateReportSchedule,
+  deleteReportSchedule,
+  toggleReportSchedule,
+  sendReportNow,
+} from '@/features/report-schedule/Actions/reportScheduleActions'
+import {
+  REPORT_SECTIONS,
+  DATE_RANGES,
+} from '@/features/report-schedule/Schema/reportScheduleSchema'
+import type { ReportSection } from '@/features/report-schedule/Schema/reportScheduleSchema'
+
+interface Schedule {
+  id: string
+  name: string
+  frequency: string
+  dateRange: string
+  sections: string[]
+  recipients: string[]
+  nextRunDate: Date
+  endDate: Date | null
+  isActive: boolean
+  lastRunAt: Date | null
+  runCount: number
+}
+
+interface Member {
+  id: string
+  name: string
+  email: string
+}
+
+interface Props {
+  schedules: Schedule[]
+  members: Member[]
+}
+
+// --------------- Recipient Picker ---------------
+
+function RecipientPicker({
+  members,
+  selected,
+  onAdd,
+  onRemove,
+}: {
+  members: Member[]
+  selected: string[]
+  onAdd: (id: string) => void
+  onRemove: (id: string) => void
+}) {
+  const t = useTranslations('settings.reportSchedule')
+  const [open, setOpen] = useState(false)
+
+  const available = members.filter((m) => !selected.includes(m.id))
+  const selectedMembers = selected
+    .map((id) => members.find((m) => m.id === id))
+    .filter(Boolean) as Member[]
+
+  return (
+    <div className="space-y-2">
+      <Popover open={open} onOpenChange={setOpen}>
+        <PopoverTrigger asChild>
+          <Button variant="outline" size="sm" className="w-full justify-between font-normal">
+            {t('addRecipient')}
+            <ChevronsUpDown className="ml-2 h-3.5 w-3.5 opacity-50" />
+          </Button>
+        </PopoverTrigger>
+        <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
+          <Command>
+            <CommandInput placeholder={t('searchMembers')} />
+            <CommandList>
+              <CommandEmpty>{t('noMembers')}</CommandEmpty>
+              {available.map((member) => (
+                <CommandItem
+                  key={member.id}
+                  value={`${member.name} ${member.email}`}
+                  onSelect={() => {
+                    onAdd(member.id)
+                    setOpen(false)
+                  }}
+                >
+                  <div className="min-w-0 flex-1">
+                    <p className="truncate text-sm">{member.name}</p>
+                    <p className="truncate text-xs text-muted-foreground">{member.email}</p>
+                  </div>
+                </CommandItem>
+              ))}
+            </CommandList>
+          </Command>
+        </PopoverContent>
+      </Popover>
+
+      {selectedMembers.length > 0 && (
+        <div className="space-y-1.5">
+          {selectedMembers.map((member) => (
+            <div
+              key={member.id}
+              className="flex items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2"
+            >
+              <div className="min-w-0 flex-1">
+                <p className="truncate text-sm font-medium">{member.name}</p>
+                <p className="truncate text-xs text-muted-foreground">{member.email}</p>
+              </div>
+              <Button
+                variant="ghost"
+                size="sm"
+                className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
+                onClick={() => onRemove(member.id)}
+              >
+                <X className="h-3.5 w-3.5" />
+              </Button>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  )
+}
+
+// --------------- Schedule Form (used in dialog) ---------------
+
+function ScheduleForm({
+  schedule,
+  members,
+  onClose,
+}: {
+  schedule: Schedule | null
+  members: Member[]
+  onClose: () => void
+}) {
+  const t = useTranslations('settings.reportSchedule')
+  const router = useRouter()
+  const [isPending, startTransition] = useTransition()
+
+  const [name, setName] = useState(schedule?.name || '')
+  const [frequency, setFrequency] = useState(schedule?.frequency || 'weekly')
+  const [dateRange, setDateRange] = useState(schedule?.dateRange || 'last30d')
+  const [sections, setSections] = useState<string[]>(schedule?.sections || [...REPORT_SECTIONS])
+  const [recipients, setRecipients] = useState<string[]>(schedule?.recipients || [])
+  const [endDate, setEndDate] = useState(
+    schedule?.endDate ? new Date(schedule.endDate).toISOString().split('T')[0] : ''
+  )
+
+  const toggleSection = (section: string) => {
+    setSections((prev) =>
+      prev.includes(section) ? prev.filter((s) => s !== section) : [...prev, section]
+    )
+  }
+
+  const sectionLabels: Record<ReportSection, string> = {
+    revenue: t('sectionLabels.revenue'),
+    tax: t('sectionLabels.tax'),
+    pastDue: t('sectionLabels.pastDue'),
+    services: t('sectionLabels.services'),
+    customers: t('sectionLabels.customers'),
+    technicians: t('sectionLabels.technicians'),
+    parts: t('sectionLabels.parts'),
+    jobAnalytics: t('sectionLabels.jobAnalytics'),
+    retention: t('sectionLabels.retention'),
+    inventory: t('sectionLabels.inventory'),
+  }
+
+  const handleSave = () => {
+    startTransition(async () => {
+      const payload = {
+        name: name || undefined,
+        frequency,
+        dateRange,
+        sections,
+        recipients,
+        endDate: endDate || null,
+      }
+
+      const result = schedule
+        ? await updateReportSchedule({ ...payload, id: schedule.id })
+        : await createReportSchedule(payload)
+
+      if (result.success) {
+        toast.success(schedule ? t('updated') : t('created'))
+        router.refresh()
+        onClose()
+      } else {
+        toast.error(result.error || 'Failed to save')
+      }
+    })
+  }
+
+  return (
+    <div className="space-y-6 max-h-[70vh] overflow-y-auto pr-1">
+      {/* Name & Frequency */}
+      <div className="space-y-4">
+        <div className="space-y-2">
+          <Label htmlFor="schedule-name">{t('name')}</Label>
+          <Input
+            id="schedule-name"
+            value={name}
+            onChange={(e) => setName(e.target.value)}
+            placeholder={t('namePlaceholder')}
+          />
+        </div>
+        <div className="grid grid-cols-3 gap-4">
+          <div className="space-y-2">
+            <Label>{t('frequency')}</Label>
+            <Select value={frequency} onValueChange={setFrequency}>
+              <SelectTrigger>
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="daily">{t('daily')}</SelectItem>
+                <SelectItem value="weekly">{t('weekly')}</SelectItem>
+                <SelectItem value="biweekly">{t('biweekly')}</SelectItem>
+                <SelectItem value="monthly">{t('monthly')}</SelectItem>
+                <SelectItem value="bimonthly">{t('bimonthly')}</SelectItem>
+                <SelectItem value="quarterly">{t('quarterly')}</SelectItem>
+                <SelectItem value="semiannually">{t('semiannually')}</SelectItem>
+                <SelectItem value="yearly">{t('yearly')}</SelectItem>
+              </SelectContent>
+            </Select>
+          </div>
+          <div className="space-y-2">
+            <Label>{t('dateRangeLabel')}</Label>
+            <Select value={dateRange} onValueChange={setDateRange}>
+              <SelectTrigger>
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                {DATE_RANGES.map((key) => (
+                  <SelectItem key={key} value={key}>
+                    {t(`dateRanges.${key}`)}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+          <div className="space-y-2">
+            <Label htmlFor="end-date">{t('endDate')}</Label>
+            <Input
+              id="end-date"
+              type="date"
+              value={endDate}
+              onChange={(e) => setEndDate(e.target.value)}
+            />
+          </div>
+        </div>
+      </div>
+
+      <Separator />
+
+      {/* Report sections */}
+      <div className="space-y-3">
+        <div className="flex items-center justify-between">
+          <Label>{t('sections')}</Label>
+          <div className="flex gap-2">
+            <Button
+              variant="ghost"
+              size="sm"
+              className="h-6 text-xs"
+              onClick={() => setSections([...REPORT_SECTIONS])}
+            >
+              {t('selectAll')}
+            </Button>
+            <Button
+              variant="ghost"
+              size="sm"
+              className="h-6 text-xs"
+              onClick={() => setSections([])}
+            >
+              {t('clearAll')}
+            </Button>
+          </div>
+        </div>
+        <div className="grid grid-cols-2 gap-2">
+          {REPORT_SECTIONS.map((section) => (
+            <label
+              key={section}
+              className="flex cursor-pointer items-center gap-2.5 rounded-lg border p-2.5 text-sm transition-colors hover:bg-muted/50 has-[[data-state=checked]]:border-primary/50 has-[[data-state=checked]]:bg-primary/5"
+            >
+              <Checkbox
+                checked={sections.includes(section)}
+                onCheckedChange={() => toggleSection(section)}
+              />
+              {sectionLabels[section as ReportSection]}
+            </label>
+          ))}
+        </div>
+      </div>
+
+      <Separator />
+
+      {/* Recipients */}
+      <div className="space-y-3">
+        <Label>{t('recipients')}</Label>
+        <RecipientPicker
+          members={members}
+          selected={recipients}
+          onAdd={(id) => setRecipients((prev) => [...prev, id])}
+          onRemove={(id) => setRecipients((prev) => prev.filter((r) => r !== id))}
+        />
+      </div>
+
+      <Separator />
+
+      {/* Actions */}
+      <div className="flex justify-end gap-2">
+        <Button variant="outline" onClick={onClose} disabled={isPending}>
+          {t('cancel')}
+        </Button>
+        <Button
+          onClick={handleSave}
+          disabled={isPending || sections.length === 0 || recipients.length === 0}
+        >
+          {isPending && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
+          {schedule ? t('save') : t('create')}
+        </Button>
+      </div>
+    </div>
+  )
+}
+
+// --------------- Schedule Card ---------------
+
+function ScheduleCard({
+  schedule,
+  members,
+  onEdit,
+}: {
+  schedule: Schedule
+  members: Member[]
+  onEdit: () => void
+}) {
+  const t = useTranslations('settings.reportSchedule')
+  const router = useRouter()
+  const [isPending, startTransition] = useTransition()
+  const [deleting, setDeleting] = useState(false)
+  const [sending, setSending] = useState(false)
+
+  const handleSendNow = () => {
+    setSending(true)
+    startTransition(async () => {
+      const result = await sendReportNow(schedule.id)
+      if (result.success) {
+        toast.success(t('sentNow'))
+        router.refresh()
+      } else {
+        toast.error(result.error || 'Failed to send')
+      }
+      setSending(false)
+    })
+  }
+
+  const frequencyLabel = (f: string) => {
+    const key = [
+      'daily',
+      'weekly',
+      'biweekly',
+      'monthly',
+      'bimonthly',
+      'quarterly',
+      'semiannually',
+      'yearly',
+    ].includes(f)
+      ? f
+      : null
+    return key ? t(key) : f
+  }
+
+  const sectionLabels: Record<string, string> = {
+    revenue: t('sectionLabels.revenue'),
+    tax: t('sectionLabels.tax'),
+    pastDue: t('sectionLabels.pastDue'),
+    services: t('sectionLabels.services'),
+    customers: t('sectionLabels.customers'),
+    technicians: t('sectionLabels.technicians'),
+    parts: t('sectionLabels.parts'),
+    jobAnalytics: t('sectionLabels.jobAnalytics'),
+    retention: t('sectionLabels.retention'),
+    inventory: t('sectionLabels.inventory'),
+  }
+
+  const recipientNames = schedule.recipients
+    .map((id) => members.find((m) => m.id === id)?.name)
+    .filter(Boolean)
+
+  const handleToggle = () => {
+    startTransition(async () => {
+      const result = await toggleReportSchedule(schedule.id)
+      if (result.success) {
+        toast.success(schedule.isActive ? t('toggledOff') : t('toggledOn'))
+        router.refresh()
+      } else {
+        toast.error(result.error || 'Failed to toggle')
+      }
+    })
+  }
+
+  const handleDelete = () => {
+    setDeleting(true)
+    startTransition(async () => {
+      const result = await deleteReportSchedule(schedule.id)
+      if (result.success) {
+        toast.success(t('deleted'))
+        router.refresh()
+      } else {
+        toast.error(result.error || 'Failed to delete')
+      }
+      setDeleting(false)
+    })
+  }
+
+  return (
+    <Card className={!schedule.isActive ? 'opacity-60' : undefined}>
+      <CardContent className="p-4 space-y-3">
+        {/* Top row: name, badge, toggle */}
+        <div className="flex items-start justify-between gap-3">
+          <div className="min-w-0 flex-1">
+            <div className="flex items-center gap-2">
+              <h3 className="truncate font-medium text-sm">{schedule.name}</h3>
+              <Badge
+                variant={schedule.isActive ? 'default' : 'secondary'}
+                className="shrink-0 text-[10px]"
+              >
+                {frequencyLabel(schedule.frequency)}
+              </Badge>
+            </div>
+            {/* Sections as small tags */}
+            <div className="mt-1.5 flex flex-wrap gap-1">
+              {schedule.sections.map((s) => (
+                <span
+                  key={s}
+                  className="inline-block rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
+                >
+                  {sectionLabels[s] || s}
+                </span>
+              ))}
+            </div>
+          </div>
+          <Switch checked={schedule.isActive} onCheckedChange={handleToggle} disabled={isPending} />
+        </div>
+
+        {/* Meta row */}
+        <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
+          <span className="flex items-center gap-1">
+            <Users className="h-3 w-3" />
+            {recipientNames.length > 0
+              ? recipientNames.join(', ')
+              : `${schedule.recipients.length} recipient${schedule.recipients.length !== 1 ? 's' : ''}`}
+          </span>
+          {schedule.isActive && (
+            <span className="flex items-center gap-1">
+              <Clock className="h-3 w-3" />
+              {t('nextRun')}:{' '}
+              {new Date(schedule.nextRunDate).toLocaleDateString(undefined, {
+                month: 'short',
+                day: 'numeric',
+                hour: '2-digit',
+                minute: '2-digit',
+              })}
+            </span>
+          )}
+          {schedule.runCount > 0 && (
+            <span className="flex items-center gap-1">
+              <Send className="h-3 w-3" />
+              {schedule.runCount} {t('sent')}
+            </span>
+          )}
+          {schedule.lastRunAt && (
+            <span className="flex items-center gap-1">
+              <CalendarClock className="h-3 w-3" />
+              {t('lastRun')}:{' '}
+              {new Date(schedule.lastRunAt).toLocaleDateString(undefined, {
+                month: 'short',
+                day: 'numeric',
+              })}
+            </span>
+          )}
+        </div>
+
+        {/* Actions */}
+        <div className="flex items-center gap-2 pt-1">
+          <Button
+            variant="outline"
+            size="sm"
+            className="h-7 text-xs"
+            onClick={onEdit}
+            disabled={isPending}
+          >
+            <Pencil className="mr-1 h-3 w-3" />
+            {t('edit')}
+          </Button>
+          <Button
+            variant="outline"
+            size="sm"
+            className="h-7 text-xs"
+            onClick={handleSendNow}
+            disabled={isPending || sending}
+          >
+            {sending ? (
+              <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+            ) : (
+              <Send className="mr-1 h-3 w-3" />
+            )}
+            {t('sendNow')}
+          </Button>
+          <Button
+            variant="ghost"
+            size="sm"
+            className="h-7 text-xs text-destructive hover:text-destructive"
+            onClick={handleDelete}
+            disabled={isPending || deleting}
+          >
+            {deleting ? (
+              <Loader2 className="mr-1 h-3 w-3 animate-spin" />
+            ) : (
+              <Trash2 className="mr-1 h-3 w-3" />
+            )}
+            {t('delete')}
+          </Button>
+        </div>
+      </CardContent>
+    </Card>
+  )
+}
+
+// --------------- Main Component ---------------
+
+export function ReportScheduleSettings({ schedules, members }: Props) {
+  const t = useTranslations('settings.reportSchedule')
+  const [dialogOpen, setDialogOpen] = useState(false)
+  const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null)
+
+  const openNew = () => {
+    setEditingSchedule(null)
+    setDialogOpen(true)
+  }
+
+  const openEdit = (schedule: Schedule) => {
+    setEditingSchedule(schedule)
+    setDialogOpen(true)
+  }
+
+  return (
+    <div className="space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div>
+          <h2 className="text-lg font-semibold">{t('title')}</h2>
+          <p className="text-sm text-muted-foreground">{t('description')}</p>
+        </div>
+        <Button size="sm" onClick={openNew}>
+          <Plus className="mr-1.5 h-3.5 w-3.5" />
+          {t('newSchedule')}
+        </Button>
+      </div>
+
+      {/* Schedule list */}
+      {schedules.length === 0 ? (
+        <Card>
+          <CardContent className="flex flex-col items-center justify-center py-12 text-center">
+            <CalendarClock className="mb-3 h-10 w-10 text-muted-foreground/40" />
+            <p className="text-sm font-medium">{t('emptyTitle')}</p>
+            <p className="mt-1 text-xs text-muted-foreground">{t('emptyDescription')}</p>
+            <Button size="sm" className="mt-4" onClick={openNew}>
+              <Plus className="mr-1.5 h-3.5 w-3.5" />
+              {t('newSchedule')}
+            </Button>
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="grid gap-4 sm:grid-cols-2">
+          {schedules.map((schedule) => (
+            <ScheduleCard
+              key={schedule.id}
+              schedule={schedule}
+              members={members}
+              onEdit={() => openEdit(schedule)}
+            />
+          ))}
+        </div>
+      )}
+
+      {/* Create / Edit dialog */}
+      <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
+        <DialogContent className="sm:max-w-2xl">
+          <DialogHeader>
+            <DialogTitle>{editingSchedule ? t('editSchedule') : t('newSchedule')}</DialogTitle>
+          </DialogHeader>
+          {dialogOpen && (
+            <ScheduleForm
+              key={editingSchedule?.id || 'new'}
+              schedule={editingSchedule}
+              members={members}
+              onClose={() => setDialogOpen(false)}
+            />
+          )}
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 2 - 0
src/app/(authenticated)/settings/settings-nav.tsx

@@ -15,6 +15,7 @@ import {
 import {
   Banknote,
   Building2,
+  CalendarClock,
   CreditCard,
   Database,
   FileText,
@@ -85,6 +86,7 @@ const settingsCategories: SettingsCategory[] = [
       { key: 'workshop', href: '/settings/workshop', icon: Wrench },
       { key: 'maintenance', href: '/settings/maintenance', icon: Gauge },
       { key: 'ai', href: '/settings/ai', icon: Sparkles, gate: 'ai' },
+      { key: 'reportSchedule', href: '/settings/report-schedule', icon: CalendarClock, gate: 'reports' },
     ],
   },
   {

+ 1 - 0
src/cronTasks.ts

@@ -3,3 +3,4 @@ export { checkLicenses } from './lib/cron/check-licenses'
 export { processRecurringInvoices } from './lib/cron/recurring-invoices'
 export { cleanupPortalSessions } from './lib/cron/cleanup-portal-sessions'
 export { cleanupAuditLogs } from './lib/cron/cleanup-audit-logs'
+export { processReportSchedules } from './lib/cron/report-schedules'

+ 243 - 0
src/features/report-schedule/Actions/reportScheduleActions.ts

@@ -0,0 +1,243 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction, PermissionSubject } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import {
+  createReportScheduleSchema,
+  updateReportScheduleSchema,
+} from "../Schema/reportScheduleSchema";
+
+function calculateNextRunDate(from: Date, frequency: string): Date {
+  const next = new Date(from);
+  switch (frequency) {
+    case "daily":
+      next.setDate(next.getDate() + 1);
+      break;
+    case "weekly":
+      next.setDate(next.getDate() + 7);
+      break;
+    case "biweekly":
+      next.setDate(next.getDate() + 14);
+      break;
+    case "monthly":
+      next.setMonth(next.getMonth() + 1);
+      break;
+    case "bimonthly":
+      next.setMonth(next.getMonth() + 2);
+      break;
+    case "quarterly":
+      next.setMonth(next.getMonth() + 4);
+      break;
+    case "semiannually":
+      next.setMonth(next.getMonth() + 6);
+      break;
+    case "yearly":
+      next.setFullYear(next.getFullYear() + 1);
+      break;
+  }
+  // Set to 8:00 AM
+  next.setHours(8, 0, 0, 0);
+  return next;
+}
+
+export async function getReportSchedules() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const schedules = await db.reportSchedule.findMany({
+        where: { organizationId },
+        orderBy: { createdAt: "desc" },
+      });
+
+      return schedules.map((s) => ({
+        ...s,
+        sections: JSON.parse(s.sections) as string[],
+        recipients: JSON.parse(s.recipients) as string[],
+      }));
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.REPORTS },
+      ],
+    },
+  );
+}
+
+export async function getOrgMembers() {
+  return withAuth(
+    async ({ organizationId }) => {
+      const members = await db.organizationMember.findMany({
+        where: { organizationId },
+        select: {
+          user: { select: { id: true, name: true, email: true } },
+        },
+      });
+      return members.map((m) => m.user);
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.REPORTS },
+      ],
+    },
+  );
+}
+
+export async function createReportSchedule(input: unknown) {
+  return withAuth(
+    async ({ organizationId, userId }) => {
+      const data = createReportScheduleSchema.parse(input);
+      const nextRunDate = calculateNextRunDate(new Date(), data.frequency);
+
+      const schedule = await db.reportSchedule.create({
+        data: {
+          name: data.name || "Scheduled Report",
+          frequency: data.frequency,
+          dateRange: data.dateRange || "last30d",
+          sections: JSON.stringify(data.sections),
+          recipients: JSON.stringify(data.recipients),
+          nextRunDate,
+          endDate: data.endDate ? new Date(data.endDate) : null,
+          organizationId,
+          createdById: userId,
+        },
+      });
+
+      revalidatePath("/settings/report-schedule");
+      return schedule;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.CREATE,
+          subject: PermissionSubject.REPORTS,
+        },
+      ],
+    },
+  );
+}
+
+export async function updateReportSchedule(input: unknown) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const data = updateReportScheduleSchema.parse(input);
+
+      const existing = await db.reportSchedule.findFirst({
+        where: { id: data.id, organizationId },
+      });
+      if (!existing) throw new Error("Schedule not found");
+
+      const frequencyChanged = data.frequency !== existing.frequency;
+      const nextRunDate = frequencyChanged
+        ? calculateNextRunDate(new Date(), data.frequency)
+        : existing.nextRunDate;
+
+      const schedule = await db.reportSchedule.update({
+        where: { id: data.id },
+        data: {
+          name: data.name || "Scheduled Report",
+          frequency: data.frequency,
+          dateRange: data.dateRange || "last30d",
+          sections: JSON.stringify(data.sections),
+          recipients: JSON.stringify(data.recipients),
+          nextRunDate,
+          endDate: data.endDate ? new Date(data.endDate) : null,
+          isActive: data.isActive ?? existing.isActive,
+        },
+      });
+
+      revalidatePath("/settings/report-schedule");
+      return schedule;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.REPORTS,
+        },
+      ],
+    },
+  );
+}
+
+export async function deleteReportSchedule(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.reportSchedule.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) throw new Error("Schedule not found");
+
+      await db.reportSchedule.delete({ where: { id } });
+
+      revalidatePath("/settings/report-schedule");
+      return { deleted: true };
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.DELETE,
+          subject: PermissionSubject.REPORTS,
+        },
+      ],
+    },
+  );
+}
+
+export async function sendReportNow(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const schedule = await db.reportSchedule.findFirst({
+        where: { id, organizationId },
+      });
+      if (!schedule) throw new Error("Schedule not found");
+
+      // Dynamically import and run the cron's processing logic for this single schedule
+      const { processOneSchedule } = await import("@/lib/cron/report-schedules");
+      await processOneSchedule(schedule);
+
+      revalidatePath("/settings/report-schedule");
+      return { sent: true };
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.REPORTS,
+        },
+      ],
+    },
+  );
+}
+
+export async function toggleReportSchedule(id: string) {
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.reportSchedule.findFirst({
+        where: { id, organizationId },
+      });
+      if (!existing) throw new Error("Schedule not found");
+
+      const isActive = !existing.isActive;
+      const nextRunDate = isActive
+        ? calculateNextRunDate(new Date(), existing.frequency)
+        : existing.nextRunDate;
+
+      const schedule = await db.reportSchedule.update({
+        where: { id },
+        data: { isActive, nextRunDate },
+      });
+
+      revalidatePath("/settings/report-schedule");
+      return schedule;
+    },
+    {
+      requiredPermissions: [
+        {
+          action: PermissionAction.UPDATE,
+          subject: PermissionSubject.REPORTS,
+        },
+      ],
+    },
+  );
+}

+ 47 - 0
src/features/report-schedule/Schema/reportScheduleSchema.ts

@@ -0,0 +1,47 @@
+import { z } from "zod";
+
+export const REPORT_SECTIONS = [
+  "revenue",
+  "tax",
+  "pastDue",
+  "services",
+  "customers",
+  "technicians",
+  "parts",
+  "jobAnalytics",
+  "retention",
+  "inventory",
+] as const;
+
+export type ReportSection = (typeof REPORT_SECTIONS)[number];
+
+export const DATE_RANGES = [
+  "last1d",
+  "last7d",
+  "last14d",
+  "last30d",
+  "last60d",
+  "last90d",
+  "last6m",
+  "last12m",
+  "ytd",
+  "allTime",
+] as const;
+
+export type DateRangeKey = (typeof DATE_RANGES)[number];
+
+export const createReportScheduleSchema = z.object({
+  name: z.string().min(1).max(100).optional(),
+  frequency: z.enum(["daily", "weekly", "biweekly", "monthly", "bimonthly", "quarterly", "semiannually", "yearly"]),
+  dateRange: z.enum(DATE_RANGES).optional(),
+  sections: z
+    .array(z.enum(REPORT_SECTIONS))
+    .min(1, "Select at least one section"),
+  recipients: z.array(z.string()).min(1, "Select at least one recipient"),
+  endDate: z.string().nullable().optional(),
+});
+
+export const updateReportScheduleSchema = createReportScheduleSchema.extend({
+  id: z.string(),
+  isActive: z.boolean().optional(),
+});

+ 4 - 3
src/features/reports/Components/CustomerCharts.tsx

@@ -48,11 +48,12 @@ export function TopCustomersChart({ data, formatCurrency, labels }: TopCustomers
         />
         <Tooltip
           formatter={(value) => formatCurrency(Number(value))}
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Bar dataKey="totalSpent" name={labels?.totalSpent ?? "Total Spent"} fill="#3b82f6" radius={[0, 4, 4, 0]} />

+ 12 - 9
src/features/reports/Components/JobAnalyticsCharts.tsx

@@ -48,11 +48,12 @@ export function DayOfWeekChart({ data, labels }: DayOfWeekChartProps) {
         />
         <YAxis className="text-xs fill-muted-foreground" tick={{ fontSize: 12 }} />
         <Tooltip
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Bar dataKey="count" name={labels?.jobs ?? "Jobs"} fill="#3b82f6" radius={[4, 4, 0, 0]} />
@@ -86,11 +87,12 @@ export function ServiceTypeAnalyticsDonut({ data }: ServiceTypeDonutProps) {
           ))}
         </Pie>
         <Tooltip
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Legend
@@ -140,11 +142,12 @@ export function MonthlyTrendChart({ data, formatCurrency, labels }: MonthlyTrend
           formatter={(value, name) =>
             name === revenueLabel ? formatCurrency(Number(value)) : value
           }
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Line yAxisId="left" type="monotone" dataKey="count" name={jobsLabel} stroke="#3b82f6" strokeWidth={2} dot={false} />

+ 4 - 3
src/features/reports/Components/PartsCharts.tsx

@@ -51,11 +51,12 @@ export function PartsDonut({ data }: PartsDonutProps) {
           ))}
         </Pie>
         <Tooltip
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Legend

+ 782 - 0
src/features/reports/Components/ReportPDF.tsx

@@ -0,0 +1,782 @@
+import {
+  Document,
+  Page,
+  Text,
+  View,
+  StyleSheet,
+  Font,
+} from "@react-pdf/renderer";
+import type {
+  RevenueReport,
+  ServiceReport,
+  CustomerReport,
+  InventoryReport,
+  TechnicianReport,
+  PartsUsageReport,
+  JobAnalyticsReport,
+  CustomerRetentionReport,
+  TaxReport,
+  PastDueInvoicesReport,
+} from "../Schema/reportTypes";
+import { formatCurrency } from "@/lib/format";
+
+// --------------- fonts ---------------
+
+Font.register({
+  family: "Roboto",
+  fonts: [
+    { src: "/fonts/Roboto-Regular.ttf", fontWeight: 400 },
+    { src: "/fonts/Roboto-Bold.ttf", fontWeight: 700 },
+  ],
+});
+Font.register({ family: "Roboto-Bold", src: "/fonts/Roboto-Bold.ttf" });
+Font.registerHyphenationCallback((word) => [word]);
+
+// --------------- colour helpers ---------------
+
+import { lightenColor, darkenColor } from "@/features/vehicles/Components/invoice-pdf/styles";
+
+const gray = "#6b7280";
+const grayLight = "#f3f4f6";
+const dark = "#111827";
+const border = "#e5e7eb";
+
+// --------------- styles (dynamic) ---------------
+
+function createReportStyles(primary: string) {
+  const primaryLight = lightenColor(primary, 0.9);
+  const primaryDark = darkenColor(primary, 0.3);
+
+  return StyleSheet.create({
+    page: { padding: 40, fontSize: 9, fontFamily: "Roboto", color: dark },
+    // header
+    headerRow: {
+      flexDirection: "row",
+      justifyContent: "space-between",
+      alignItems: "flex-end",
+      marginBottom: 24,
+      paddingBottom: 12,
+      borderBottomWidth: 2,
+      borderBottomColor: primary,
+    },
+    title: { fontSize: 20, fontFamily: "Roboto-Bold", color: primary },
+    subtitle: { fontSize: 9, color: gray, marginTop: 2 },
+    dateBadge: {
+      fontSize: 8,
+      color: gray,
+      backgroundColor: grayLight,
+      paddingHorizontal: 8,
+      paddingVertical: 4,
+      borderRadius: 3,
+    },
+    // section
+    section: { marginBottom: 18 },
+    sectionTitle: {
+      fontSize: 12,
+      fontFamily: "Roboto-Bold",
+      color: dark,
+      marginBottom: 8,
+      paddingBottom: 4,
+      borderBottomWidth: 1,
+      borderBottomColor: border,
+    },
+    // summary cards row
+    cardsRow: { flexDirection: "row", gap: 8, marginBottom: 14 },
+    card: {
+      flex: 1,
+      padding: 10,
+      backgroundColor: grayLight,
+      borderRadius: 4,
+    },
+    cardLabel: { fontSize: 7, color: gray, textTransform: "uppercase", marginBottom: 3 },
+    cardValue: { fontSize: 13, fontFamily: "Roboto-Bold" },
+    // table
+    table: { marginBottom: 4 },
+    tableHeader: {
+      flexDirection: "row",
+      backgroundColor: primaryLight,
+      paddingVertical: 5,
+      paddingHorizontal: 6,
+      borderRadius: 2,
+    },
+    tableRow: {
+      flexDirection: "row",
+      paddingVertical: 4,
+      paddingHorizontal: 6,
+      borderBottomWidth: 0.5,
+      borderBottomColor: border,
+    },
+    tableRowAlt: {
+      flexDirection: "row",
+      paddingVertical: 4,
+      paddingHorizontal: 6,
+      borderBottomWidth: 0.5,
+      borderBottomColor: border,
+      backgroundColor: "#fafafa",
+    },
+    th: { fontSize: 7, fontFamily: "Roboto-Bold", color: primaryDark },
+    td: { fontSize: 8 },
+    tdRight: { fontSize: 8, textAlign: "right" },
+    // footer
+    footer: {
+      position: "absolute",
+      bottom: 25,
+      left: 40,
+      right: 40,
+      textAlign: "center",
+      fontSize: 7,
+      color: gray,
+      paddingTop: 6,
+      borderTopWidth: 0.5,
+      borderTopColor: border,
+    },
+    pageNumber: { fontSize: 7, color: gray },
+  });
+}
+
+// --------------- helpers ---------------
+
+interface ReportPDFProps {
+  dateRange: string;
+  currencyCode: string;
+  primaryColor: string;
+  labels: Record<string, string>;
+  revenueData?: RevenueReport | null;
+  serviceData?: ServiceReport | null;
+  customerData?: CustomerReport | null;
+  inventoryData?: InventoryReport | null;
+  technicianData?: TechnicianReport | null;
+  partsData?: PartsUsageReport | null;
+  jobAnalyticsData?: JobAnalyticsReport | null;
+  retentionData?: CustomerRetentionReport | null;
+  taxData?: TaxReport | null;
+  pastDueData?: PastDueInvoicesReport | null;
+}
+
+type ReportStyles = ReturnType<typeof createReportStyles>;
+
+function SummaryCards({ items, s }: { items: { label: string; value: string }[]; s: ReportStyles }) {
+  const rows: { label: string; value: string }[][] = [];
+  for (let i = 0; i < items.length; i += 3) {
+    rows.push(items.slice(i, i + 3));
+  }
+  return (
+    <>
+      {rows.map((row, ri) => (
+        <View key={ri} style={s.cardsRow}>
+          {row.map((item, ci) => (
+            <View key={ci} style={s.card}>
+              <Text style={s.cardLabel}>{item.label}</Text>
+              <Text style={s.cardValue}>{item.value}</Text>
+            </View>
+          ))}
+          {row.length < 3 &&
+            Array.from({ length: 3 - row.length }).map((_, i) => (
+              <View key={`empty-${i}`} style={{ flex: 1 }} />
+            ))}
+        </View>
+      ))}
+    </>
+  );
+}
+
+function TableBlock({
+  headers,
+  rows,
+  widths,
+  s,
+}: {
+  headers: string[];
+  rows: string[][];
+  widths: number[];
+  s: ReportStyles;
+}) {
+  return (
+    <View style={s.table}>
+      <View style={s.tableHeader}>
+        {headers.map((h, i) => (
+          <Text
+            key={i}
+            style={[s.th, { width: `${widths[i]}%`, textAlign: i === 0 ? "left" : "right" }]}
+          >
+            {h}
+          </Text>
+        ))}
+      </View>
+      {rows.map((row, ri) => (
+        <View key={ri} style={ri % 2 === 0 ? s.tableRow : s.tableRowAlt}>
+          {row.map((cell, ci) => (
+            <Text
+              key={ci}
+              style={[
+                ci === 0 ? s.td : s.tdRight,
+                { width: `${widths[ci]}%` },
+              ]}
+            >
+              {cell}
+            </Text>
+          ))}
+        </View>
+      ))}
+    </View>
+  );
+}
+
+function PageFooter({ s }: { s: ReportStyles }) {
+  return (
+    <Text
+      style={s.footer}
+      render={({ pageNumber, totalPages }) => `Page ${pageNumber} / ${totalPages}`}
+      fixed
+    />
+  );
+}
+
+// --------------- main document ---------------
+
+export function ReportPDF({
+  dateRange,
+  currencyCode,
+  primaryColor,
+  labels: l,
+  revenueData,
+  serviceData,
+  customerData,
+  inventoryData,
+  technicianData,
+  partsData,
+  jobAnalyticsData,
+  retentionData,
+  taxData,
+  pastDueData,
+}: ReportPDFProps) {
+  const fmt = (n: number) => formatCurrency(n, currencyCode);
+  const s = createReportStyles(primaryColor);
+
+  return (
+    <Document>
+      {/* ==================== REVENUE ==================== */}
+      {revenueData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.revenueSection || "Revenue Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.revenue || "Revenue", value: fmt(revenueData.summary.totalRevenue) },
+              { label: l.collected || "Collected", value: fmt(revenueData.summary.totalCollected) },
+              { label: l.outstanding || "Outstanding", value: fmt(revenueData.summary.outstanding) },
+              { label: l.services || "Services", value: String(revenueData.summary.totalCount) },
+              { label: l.partsCost || "Parts Cost", value: fmt(revenueData.summary.totalPartsCost) },
+              { label: l.netProfit || "Net Profit", value: fmt(revenueData.summary.netProfit) },
+            ]}
+          />
+
+          <View style={s.section}>
+            <Text style={s.sectionTitle}>{l.monthlyBreakdown || "Monthly Breakdown"}</Text>
+            <TableBlock
+              s={s}
+              headers={[
+                l.month || "Month",
+                l.revenue || "Revenue",
+                l.collected || "Collected",
+                l.partsCost || "Parts Cost",
+                l.netProfit || "Net Profit",
+                l.count || "Jobs",
+              ]}
+              widths={[22, 18, 18, 16, 16, 10]}
+              rows={revenueData.monthly.map((m) => [
+                m.month,
+                fmt(m.revenue),
+                fmt(m.collected),
+                fmt(m.partsCost),
+                fmt(m.netProfit),
+                String(m.count),
+              ])}
+            />
+          </View>
+
+          {revenueData.byType.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.revenueByType || "Revenue by Type"}</Text>
+              <TableBlock
+                s={s}
+                headers={[l.type || "Type", l.revenue || "Revenue", l.count || "Count"]}
+                widths={[50, 30, 20]}
+                rows={revenueData.byType.map((t) => [
+                  t.type,
+                  fmt(t.revenue),
+                  String(t.count),
+                ])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== TAX ==================== */}
+      {taxData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.taxSection || "Tax Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.taxCollected || "Tax Collected", value: fmt(taxData.summary.totalTaxCollected) },
+              { label: l.taxableAmount || "Taxable Amount", value: fmt(taxData.summary.totalTaxableAmount) },
+              { label: l.invoices || "Invoices", value: String(taxData.summary.totalInvoices) },
+            ]}
+          />
+
+          <View style={s.section}>
+            <Text style={s.sectionTitle}>{l.monthlyBreakdown || "Monthly Breakdown"}</Text>
+            <TableBlock
+              s={s}
+              headers={[
+                l.month || "Month",
+                l.taxCollected || "Tax Collected",
+                l.taxableAmount || "Taxable Amount",
+                l.invoiceCount || "Invoices",
+              ]}
+              widths={[30, 25, 25, 20]}
+              rows={taxData.monthly.map((m) => [
+                m.month,
+                fmt(m.taxCollected),
+                fmt(m.taxableAmount),
+                String(m.invoiceCount),
+              ])}
+            />
+          </View>
+
+          {taxData.byRate.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.taxByRate || "Tax by Rate"}</Text>
+              <TableBlock
+                s={s}
+                headers={[l.taxRate || "Tax Rate", l.taxCollected || "Tax Collected", l.invoiceCount || "Invoices"]}
+                widths={[40, 35, 25]}
+                rows={taxData.byRate.map((r) => [
+                  `${r.taxRate}%`,
+                  fmt(r.taxCollected),
+                  String(r.invoiceCount),
+                ])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== PAST DUE INVOICES ==================== */}
+      {pastDueData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.pastDueSection || "Past Due Invoices"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalPastDue || "Total Past Due", value: String(pastDueData.summary.totalPastDue) },
+              { label: l.totalAmountDue || "Amount Due", value: fmt(pastDueData.summary.totalAmountDue) },
+              { label: l.over30 || "Over 30 Days", value: String(pastDueData.summary.over30) },
+              { label: l.over60 || "Over 60 Days", value: String(pastDueData.summary.over60) },
+              { label: l.over90 || "Over 90 Days", value: String(pastDueData.summary.over90) },
+            ]}
+          />
+
+          <View style={s.section}>
+            <TableBlock
+              s={s}
+              headers={[
+                l.customer || "Customer",
+                l.invoiceNumber || "Invoice #",
+                l.totalAmount || "Total",
+                l.amountDue || "Due",
+                l.dueDate || "Due Date",
+                l.daysPastDue || "Days",
+              ]}
+              widths={[24, 14, 16, 16, 16, 14]}
+              rows={pastDueData.invoices.map((inv) => [
+                inv.customerName,
+                inv.invoiceNumber || "-",
+                fmt(inv.totalAmount),
+                fmt(inv.amountDue),
+                inv.dueDate,
+                String(inv.daysPastDue),
+              ])}
+            />
+          </View>
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== SERVICES ==================== */}
+      {serviceData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.servicesSection || "Services Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalServices || "Total Services", value: String(serviceData.totalServices) },
+            ]}
+          />
+
+          {serviceData.byStatus.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.servicesByStatus || "By Status"}</Text>
+              <TableBlock
+                s={s}
+                headers={[l.status || "Status", l.count || "Count"]}
+                widths={[60, 40]}
+                rows={serviceData.byStatus.map((s) => [s.status, String(s.count)])}
+              />
+            </View>
+          )}
+
+          {serviceData.byType.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.servicesByType || "By Type"}</Text>
+              <TableBlock
+                s={s}
+                headers={[l.type || "Type", l.count || "Count"]}
+                widths={[60, 40]}
+                rows={serviceData.byType.map((t) => [t.type, String(t.count)])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== CUSTOMERS ==================== */}
+      {customerData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.customersSection || "Customers Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalCustomers || "Total Customers", value: String(customerData.totalCustomers) },
+              { label: l.activeCustomers || "Active Customers", value: String(customerData.activeCustomers) },
+            ]}
+          />
+
+          {customerData.topCustomers.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.topCustomers || "Top Customers"}</Text>
+              <TableBlock
+                s={s}
+                headers={[
+                  l.name || "Name",
+                  l.company || "Company",
+                  l.services || "Services",
+                  l.totalSpent || "Total Spent",
+                ]}
+                widths={[30, 30, 15, 25]}
+                rows={customerData.topCustomers.map((c) => [
+                  c.name,
+                  c.company || "-",
+                  String(c.serviceCount),
+                  fmt(c.totalSpent),
+                ])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== TECHNICIANS ==================== */}
+      {technicianData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.techniciansSection || "Technicians Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalJobs || "Total Jobs", value: String(technicianData.totalJobs) },
+              { label: l.totalRevenue || "Total Revenue", value: fmt(technicianData.totalRevenue) },
+            ]}
+          />
+
+          <View style={s.section}>
+            <TableBlock
+              s={s}
+              headers={[
+                l.technician || "Technician",
+                l.jobs || "Jobs",
+                l.totalRevenue || "Revenue",
+                l.avgRevenue || "Avg Revenue",
+                l.totalHours || "Hours",
+                l.avgHours || "Avg Hours",
+              ]}
+              widths={[22, 12, 18, 18, 15, 15]}
+              rows={technicianData.technicians.map((t) => [
+                t.techName,
+                String(t.jobCount),
+                fmt(t.totalRevenue),
+                fmt(t.avgRevenue),
+                t.totalLaborHours.toFixed(1),
+                t.avgHours.toFixed(1),
+              ])}
+            />
+          </View>
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== PARTS USAGE ==================== */}
+      {partsData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.partsSection || "Parts Usage Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalPartsRevenue || "Parts Revenue", value: fmt(partsData.totalPartsRevenue) },
+              { label: l.totalPartsUsed || "Parts Used", value: String(partsData.totalPartsUsed) },
+            ]}
+          />
+
+          <View style={s.section}>
+            <TableBlock
+              s={s}
+              headers={[
+                l.partName || "Part",
+                l.partNumber || "Part #",
+                l.usageCount || "Used",
+                l.totalQty || "Qty",
+                l.totalRevenue || "Revenue",
+              ]}
+              widths={[30, 20, 15, 15, 20]}
+              rows={partsData.parts.map((p) => [
+                p.name,
+                p.partNumber || "-",
+                String(p.usageCount),
+                String(p.totalQuantity),
+                fmt(p.totalRevenue),
+              ])}
+            />
+          </View>
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== JOB ANALYTICS ==================== */}
+      {jobAnalyticsData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.jobAnalyticsSection || "Job Analytics"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalJobs || "Total Jobs", value: String(jobAnalyticsData.totalJobs) },
+              { label: l.avgJobValue || "Avg Job Value", value: fmt(jobAnalyticsData.avgJobValue) },
+            ]}
+          />
+
+          {jobAnalyticsData.topServiceTypes.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.topServiceTypes || "Top Service Types"}</Text>
+              <TableBlock
+                s={s}
+                headers={[
+                  l.serviceType || "Service Type",
+                  l.count || "Count",
+                  l.avgValue || "Avg Value",
+                  l.avgHours || "Avg Hours",
+                ]}
+                widths={[35, 20, 25, 20]}
+                rows={jobAnalyticsData.topServiceTypes.map((t) => [
+                  t.type,
+                  String(t.count),
+                  fmt(t.avgValue),
+                  t.avgHours.toFixed(1),
+                ])}
+              />
+            </View>
+          )}
+
+          {jobAnalyticsData.dayOfWeek.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.dayOfWeek || "Jobs by Day of Week"}</Text>
+              <TableBlock
+                s={s}
+                headers={[l.day || "Day", l.count || "Count"]}
+                widths={[60, 40]}
+                rows={jobAnalyticsData.dayOfWeek.map((d) => [d.day, String(d.count)])}
+              />
+            </View>
+          )}
+
+          {jobAnalyticsData.monthlyTrend.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.monthlyTrend || "Monthly Trend"}</Text>
+              <TableBlock
+                s={s}
+                headers={[l.month || "Month", l.count || "Jobs", l.revenue || "Revenue"]}
+                widths={[40, 25, 35]}
+                rows={jobAnalyticsData.monthlyTrend.map((m) => [
+                  m.month,
+                  String(m.count),
+                  fmt(m.revenue),
+                ])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== RETENTION ==================== */}
+      {retentionData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.retentionSection || "Customer Retention"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.returningCustomers || "Returning", value: String(retentionData.returningCustomers) },
+              { label: l.newCustomers || "New", value: String(retentionData.newCustomers) },
+              { label: l.totalActive || "Total Active", value: String(retentionData.totalActive) },
+              {
+                label: l.avgTimeBetweenVisits || "Avg Days Between Visits",
+                value: retentionData.avgTimeBetweenVisits != null
+                  ? `${retentionData.avgTimeBetweenVisits} ${l.days || "days"}`
+                  : "-",
+              },
+            ]}
+          />
+
+          {retentionData.topReturning.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.topReturning || "Top Returning Customers"}</Text>
+              <TableBlock
+                s={s}
+                headers={[
+                  l.customer || "Customer",
+                  l.company || "Company",
+                  l.visits || "Visits",
+                  l.totalSpent || "Total Spent",
+                  l.avgDaysBetweenVisits || "Avg Days",
+                ]}
+                widths={[25, 22, 13, 22, 18]}
+                rows={retentionData.topReturning.map((c) => [
+                  c.name,
+                  c.company || "-",
+                  String(c.visitCount),
+                  fmt(c.totalSpent),
+                  c.avgTimeBetweenVisits != null ? String(c.avgTimeBetweenVisits) : "-",
+                ])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+
+      {/* ==================== INVENTORY ==================== */}
+      {inventoryData && (
+        <Page size="A4" style={s.page}>
+          <View style={s.headerRow}>
+            <View>
+              <Text style={s.title}>{l.reportTitle || "Business Report"}</Text>
+              <Text style={s.subtitle}>{l.inventorySection || "Inventory Report"}</Text>
+            </View>
+            <Text style={s.dateBadge}>{dateRange}</Text>
+          </View>
+
+          <SummaryCards
+            s={s}
+            items={[
+              { label: l.totalParts || "Total Parts", value: String(inventoryData.totalParts) },
+              { label: l.totalItems || "Total Items", value: String(inventoryData.totalItems) },
+              { label: l.totalValue || "Total Value", value: fmt(inventoryData.totalValue) },
+              { label: l.totalSellValue || "Sell Value", value: fmt(inventoryData.totalSellValue) },
+            ]}
+          />
+
+          {inventoryData.lowStock.length > 0 && (
+            <View style={s.section}>
+              <Text style={s.sectionTitle}>{l.lowStock || "Low Stock Items"}</Text>
+              <TableBlock
+                s={s}
+                headers={[
+                  l.name || "Name",
+                  l.partNumber || "Part #",
+                  l.quantity || "Qty",
+                  l.minQuantity || "Min Qty",
+                  l.unitCost || "Unit Cost",
+                ]}
+                widths={[30, 20, 15, 15, 20]}
+                rows={inventoryData.lowStock.map((p) => [
+                  p.name,
+                  p.partNumber || "-",
+                  String(p.quantity),
+                  p.minQuantity != null ? String(p.minQuantity) : "-",
+                  p.unitCost != null ? fmt(p.unitCost) : "-",
+                ])}
+              />
+            </View>
+          )}
+          <PageFooter s={s} />
+        </Page>
+      )}
+    </Document>
+  );
+}

+ 4 - 3
src/features/reports/Components/RetentionCharts.tsx

@@ -52,11 +52,12 @@ export function RetentionBarChart({ data, formatCurrency, labels }: RetentionBar
           formatter={(value, name) =>
             name === totalSpentLabel ? formatCurrency(Number(value)) : value
           }
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Bar dataKey="visitCount" name={labels?.visits ?? "Visits"} fill="#8b5cf6" radius={[0, 4, 4, 0]} />

+ 33 - 13
src/features/reports/Components/RevenueCharts.tsx

@@ -35,32 +35,51 @@ interface RevenueBarChartProps {
 export function RevenueBarChart({ data, formatCurrency, labels }: RevenueBarChartProps) {
   if (data.length === 0) return null;
 
+  const compactCurrency = (value: number) => {
+    if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
+    if (value >= 1000) return `${(value / 1000).toFixed(0)}k`;
+    return value.toString();
+  };
+
   return (
-    <ResponsiveContainer width="100%" height={300}>
-      <BarChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
-        <CartesianGrid strokeDasharray="3 3" className="stroke-border" />
+    <ResponsiveContainer width="100%" height={350}>
+      <BarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }} barGap={1} barCategoryGap="20%">
+        <CartesianGrid strokeDasharray="3 3" vertical={false} className="stroke-border" />
         <XAxis
           dataKey="month"
           className="text-xs fill-muted-foreground"
-          tick={{ fontSize: 12 }}
+          tick={{ fontSize: 11 }}
+          axisLine={false}
+          tickLine={false}
         />
         <YAxis
           className="text-xs fill-muted-foreground"
-          tick={{ fontSize: 12 }}
-          tickFormatter={(v) => formatCurrency(v)}
+          tick={{ fontSize: 11 }}
+          tickFormatter={compactCurrency}
+          axisLine={false}
+          tickLine={false}
+          width={45}
         />
         <Tooltip
           formatter={(value) => formatCurrency(Number(value))}
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
+            fontSize: "13px",
           }}
+          cursor={{ fill: "var(--muted)", opacity: 0.5 }}
+        />
+        <Legend
+          formatter={(value) => (
+            <span className="text-xs text-muted-foreground">{value}</span>
+          )}
         />
         <Bar dataKey="revenue" name={labels?.revenue ?? "Revenue"} fill="#3b82f6" radius={[4, 4, 0, 0]} />
         <Bar dataKey="collected" name={labels?.collected ?? "Collected"} fill="#10b981" radius={[4, 4, 0, 0]} />
-        <Bar dataKey="netProfit" name={labels?.netProfit ?? "Net Profit"} fill="#10b981" radius={[4, 4, 0, 0]} />
+        <Bar dataKey="netProfit" name={labels?.netProfit ?? "Net Profit"} fill="#8b5cf6" radius={[4, 4, 0, 0]} />
       </BarChart>
     </ResponsiveContainer>
   );
@@ -93,11 +112,12 @@ export function RevenueTypeDonut({ data, formatCurrency }: RevenueTypeDonutProps
         </Pie>
         <Tooltip
           formatter={(value) => formatCurrency(Number(value))}
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Legend

+ 8 - 6
src/features/reports/Components/ServiceCharts.tsx

@@ -51,11 +51,12 @@ export function ServiceStatusChart({ data, labels }: ServiceStatusChartProps) {
           width={100}
         />
         <Tooltip
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Bar dataKey="count" name={labels?.count ?? "Count"} fill="#3b82f6" radius={[0, 4, 4, 0]} />
@@ -89,11 +90,12 @@ export function ServiceTypeDonut({ data }: ServiceTypeDonutProps) {
           ))}
         </Pie>
         <Tooltip
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Legend

+ 4 - 3
src/features/reports/Components/TaxCharts.tsx

@@ -36,11 +36,12 @@ export function TaxBarChart({ data, formatCurrency, labels }: TaxBarChartProps)
         />
         <Tooltip
           formatter={(value) => formatCurrency(Number(value))}
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Bar dataKey="taxCollected" name={labels?.taxCollected ?? "Tax Collected"} fill="#f59e0b" radius={[4, 4, 0, 0]} />

+ 4 - 3
src/features/reports/Components/TechnicianCharts.tsx

@@ -48,11 +48,12 @@ export function TechnicianBarChart({ data, formatCurrency, labels }: TechnicianB
         />
         <Tooltip
           formatter={(value) => formatCurrency(Number(value))}
+          wrapperStyle={{ outline: "none" }}
           contentStyle={{
-            backgroundColor: "hsl(var(--popover))",
-            border: "1px solid hsl(var(--border))",
+            backgroundColor: "var(--popover)",
+            border: "1px solid var(--border)",
             borderRadius: "8px",
-            color: "hsl(var(--popover-foreground))",
+            color: "var(--popover-foreground)",
           }}
         />
         <Bar dataKey="totalRevenue" name={labels?.revenue ?? "Revenue"} fill="#8b5cf6" radius={[0, 4, 4, 0]} />

+ 2 - 1
src/instrumentation.ts

@@ -1,10 +1,11 @@
 export async function register() {
   if (process.env.NEXT_RUNTIME === 'nodejs') {
-    const { checkLicenses, checkSubscriptions, processRecurringInvoices, cleanupPortalSessions, cleanupAuditLogs } = await import('./cronTasks')
+    const { checkLicenses, checkSubscriptions, processRecurringInvoices, cleanupPortalSessions, cleanupAuditLogs, processReportSchedules } = await import('./cronTasks')
     checkLicenses()
     checkSubscriptions()
     processRecurringInvoices()
     cleanupPortalSessions()
     cleanupAuditLogs()
+    processReportSchedules()
   }
 }

+ 449 - 0
src/lib/cron/report-schedules.ts

@@ -0,0 +1,449 @@
+import { CronJob } from "cron";
+import { db } from "@/lib/db";
+import { renderToBuffer } from "@react-pdf/renderer";
+import "@/features/vehicles/Components/invoice-pdf/fonts";
+import React from "react";
+import { sendOrgMail, getOrgFromAddress } from "@/lib/email";
+import { ReportPDF } from "@/features/reports/Components/ReportPDF";
+import { SETTING_KEYS } from "@/features/settings/Schema/settingsSchema";
+
+// --------------- date helpers ---------------
+
+function calculateNextRunDate(current: Date, frequency: string): Date {
+  const next = new Date(current);
+  switch (frequency) {
+    case "daily":
+      next.setDate(next.getDate() + 1);
+      break;
+    case "weekly":
+      next.setDate(next.getDate() + 7);
+      break;
+    case "biweekly":
+      next.setDate(next.getDate() + 14);
+      break;
+    case "monthly":
+      next.setMonth(next.getMonth() + 1);
+      break;
+    case "bimonthly":
+      next.setMonth(next.getMonth() + 2);
+      break;
+    case "quarterly":
+      next.setMonth(next.getMonth() + 4);
+      break;
+    case "semiannually":
+      next.setMonth(next.getMonth() + 6);
+      break;
+    case "yearly":
+      next.setFullYear(next.getFullYear() + 1);
+      break;
+  }
+  next.setHours(8, 0, 0, 0);
+  return next;
+}
+
+function getDateRange(dateRange: string): { start: Date; end: Date } {
+  const end = new Date();
+  end.setHours(23, 59, 59, 999);
+  const start = new Date(end);
+
+  switch (dateRange) {
+    case "last1d":
+      start.setDate(start.getDate() - 1);
+      break;
+    case "last7d":
+      start.setDate(start.getDate() - 7);
+      break;
+    case "last14d":
+      start.setDate(start.getDate() - 14);
+      break;
+    case "last30d":
+      start.setMonth(start.getMonth() - 1);
+      break;
+    case "last60d":
+      start.setMonth(start.getMonth() - 2);
+      break;
+    case "last90d":
+      start.setMonth(start.getMonth() - 3);
+      break;
+    case "last6m":
+      start.setMonth(start.getMonth() - 6);
+      break;
+    case "last12m":
+      start.setFullYear(start.getFullYear() - 1);
+      break;
+    case "ytd":
+      start.setMonth(0, 1);
+      break;
+    case "allTime":
+      start.setFullYear(2000, 0, 1);
+      break;
+    default:
+      start.setMonth(start.getMonth() - 1);
+      break;
+  }
+  start.setHours(0, 0, 0, 0);
+  return { start, end };
+}
+
+function formatDateRange(start: Date, end: Date): string {
+  const fmt = (d: Date) =>
+    d.toLocaleDateString("en-US", {
+      month: "short",
+      day: "numeric",
+      year: "numeric",
+    });
+  return `${fmt(start)} – ${fmt(end)}`;
+}
+
+// --------------- internal report fetchers ---------------
+// These replicate the logic from reportActions.ts but accept orgId directly
+
+async function fetchRevenue(orgId: string, start: Date, end: Date) {
+  const records = await db.serviceRecord.findMany({
+    where: { vehicle: { organizationId: orgId }, startDateTime: { gte: start, lte: end } },
+    select: {
+      serviceDate: true, startDateTime: true, totalAmount: true, cost: true, type: true,
+      manuallyPaid: true, payments: { select: { amount: true } },
+      partItems: { select: { unitCost: true, quantity: true } },
+    },
+    orderBy: [{ startDateTime: { sort: "asc", nulls: "last" } }, { serviceDate: "asc" }],
+  });
+
+  const monthly: Record<string, { revenue: number; collected: number; count: number; partsCost: number }> = {};
+  const byType: Record<string, { revenue: number; count: number }> = {};
+  let totalRevenue = 0, totalCollected = 0, totalCount = 0, totalPartsCost = 0;
+
+  for (const r of records) {
+    const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
+    const paid = r.manuallyPaid ? total : r.payments.reduce((s, p) => s + p.amount, 0);
+    const partsCost = r.partItems.reduce((s, p) => s + (p.unitCost * p.quantity), 0);
+    const _d = r.startDateTime ?? r.serviceDate;
+    const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, "0")}`;
+
+    if (!monthly[month]) monthly[month] = { revenue: 0, collected: 0, count: 0, partsCost: 0 };
+    monthly[month].revenue += total; monthly[month].collected += paid;
+    monthly[month].count += 1; monthly[month].partsCost += partsCost;
+    totalRevenue += total; totalCollected += paid; totalCount += 1; totalPartsCost += partsCost;
+
+    if (!byType[r.type]) byType[r.type] = { revenue: 0, count: 0 };
+    byType[r.type].revenue += total; byType[r.type].count += 1;
+  }
+
+  return {
+    monthly: Object.entries(monthly).map(([month, d]) => ({ month, ...d, netProfit: d.revenue - d.partsCost })),
+    byType: Object.entries(byType).map(([type, d]) => ({ type, ...d })),
+    summary: { totalRevenue, totalCollected, outstanding: totalRevenue - totalCollected, totalCount, totalPartsCost, netProfit: totalRevenue - totalPartsCost },
+  };
+}
+
+async function fetchServices(orgId: string, start: Date, end: Date) {
+  const records = await db.serviceRecord.findMany({
+    where: { vehicle: { organizationId: orgId }, startDateTime: { gte: start, lte: end } },
+    select: { type: true, status: true },
+  });
+  const byStatus: Record<string, number> = {};
+  const byType: Record<string, number> = {};
+  for (const r of records) {
+    byStatus[r.status] = (byStatus[r.status] || 0) + 1;
+    byType[r.type] = (byType[r.type] || 0) + 1;
+  }
+  return {
+    totalServices: records.length,
+    byStatus: Object.entries(byStatus).map(([status, count]) => ({ status, count })),
+    byType: Object.entries(byType).map(([type, count]) => ({ type, count })),
+  };
+}
+
+async function fetchCustomers(orgId: string, start: Date, end: Date) {
+  const customers = await db.customer.findMany({
+    where: { organizationId: orgId },
+    select: { id: true, name: true, company: true, vehicles: { select: { serviceRecords: { where: { startDateTime: { gte: start, lte: end } }, select: { totalAmount: true, cost: true } } } } },
+  });
+  const ranked = customers.map((c) => {
+    let totalSpent = 0, serviceCount = 0;
+    for (const v of c.vehicles) for (const sr of v.serviceRecords) { totalSpent += sr.totalAmount > 0 ? sr.totalAmount : sr.cost; serviceCount++; }
+    return { id: c.id, name: c.name, company: c.company, totalSpent, serviceCount };
+  }).filter((c) => c.serviceCount > 0).sort((a, b) => b.totalSpent - a.totalSpent);
+  return { totalCustomers: customers.length, activeCustomers: ranked.length, topCustomers: ranked.slice(0, 20) };
+}
+
+async function fetchTechnicians(orgId: string, start: Date, end: Date) {
+  const records = await db.serviceRecord.findMany({
+    where: { vehicle: { organizationId: orgId }, startDateTime: { gte: start, lte: end }, OR: [{ technicianId: { not: null } }, { techName: { not: null } }] },
+    select: { techName: true, technicianId: true, technician: { select: { name: true } }, totalAmount: true, cost: true, laborItems: { select: { hours: true } } },
+  });
+  const byTech: Record<string, { techName: string; jobCount: number; totalRevenue: number; totalLaborHours: number }> = {};
+  for (const r of records) {
+    const key = r.technicianId || `name:${r.techName}`;
+    const name = r.technician?.name || r.techName || "Unassigned";
+    if (!byTech[key]) byTech[key] = { techName: name, jobCount: 0, totalRevenue: 0, totalLaborHours: 0 };
+    byTech[key].jobCount += 1;
+    byTech[key].totalRevenue += r.totalAmount > 0 ? r.totalAmount : r.cost;
+    byTech[key].totalLaborHours += r.laborItems.reduce((s, l) => s + l.hours, 0);
+  }
+  const technicians = Object.values(byTech).map((d) => ({
+    techName: d.techName, jobCount: d.jobCount, totalRevenue: d.totalRevenue,
+    avgRevenue: d.jobCount > 0 ? d.totalRevenue / d.jobCount : 0,
+    totalLaborHours: d.totalLaborHours, avgHours: d.jobCount > 0 ? d.totalLaborHours / d.jobCount : 0,
+  })).sort((a, b) => b.totalRevenue - a.totalRevenue);
+  return { technicians, totalJobs: records.length, totalRevenue: technicians.reduce((s, t) => s + t.totalRevenue, 0) };
+}
+
+async function fetchParts(orgId: string, start: Date, end: Date) {
+  const parts = await db.servicePart.findMany({
+    where: { serviceRecord: { vehicle: { organizationId: orgId }, startDateTime: { gte: start, lte: end } } },
+    select: { name: true, partNumber: true, quantity: true, total: true },
+  });
+  const byPart: Record<string, { name: string; partNumber: string | null; usageCount: number; totalQuantity: number; totalRevenue: number }> = {};
+  for (const p of parts) {
+    const key = p.name.toLowerCase();
+    if (!byPart[key]) byPart[key] = { name: p.name, partNumber: p.partNumber, usageCount: 0, totalQuantity: 0, totalRevenue: 0 };
+    byPart[key].usageCount += 1; byPart[key].totalQuantity += p.quantity; byPart[key].totalRevenue += p.total;
+    if (p.partNumber && !byPart[key].partNumber) byPart[key].partNumber = p.partNumber;
+  }
+  const result = Object.values(byPart).sort((a, b) => b.usageCount - a.usageCount).slice(0, 20);
+  return { parts: result, totalPartsRevenue: parts.reduce((s, p) => s + p.total, 0), totalPartsUsed: parts.reduce((s, p) => s + p.quantity, 0) };
+}
+
+async function fetchJobAnalytics(orgId: string, start: Date, end: Date) {
+  const records = await db.serviceRecord.findMany({
+    where: { vehicle: { organizationId: orgId }, startDateTime: { gte: start, lte: end } },
+    select: { type: true, totalAmount: true, cost: true, serviceDate: true, startDateTime: true, laborItems: { select: { hours: true } } },
+  });
+  const totalValue = records.reduce((s, r) => s + (r.totalAmount > 0 ? r.totalAmount : r.cost), 0);
+  const byType: Record<string, { count: number; totalValue: number; totalHours: number }> = {};
+  const dayCount = [0, 0, 0, 0, 0, 0, 0];
+  const monthly: Record<string, { count: number; revenue: number }> = {};
+  for (const r of records) {
+    if (!byType[r.type]) byType[r.type] = { count: 0, totalValue: 0, totalHours: 0 };
+    byType[r.type].count += 1;
+    byType[r.type].totalValue += r.totalAmount > 0 ? r.totalAmount : r.cost;
+    byType[r.type].totalHours += r.laborItems.reduce((s, l) => s + l.hours, 0);
+    const _d = r.startDateTime ?? r.serviceDate;
+    dayCount[_d.getDay()] += 1;
+    const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, "0")}`;
+    if (!monthly[month]) monthly[month] = { count: 0, revenue: 0 };
+    monthly[month].count += 1; monthly[month].revenue += r.totalAmount > 0 ? r.totalAmount : r.cost;
+  }
+  const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+  return {
+    avgJobValue: records.length > 0 ? totalValue / records.length : 0, totalJobs: records.length,
+    topServiceTypes: Object.entries(byType).map(([type, d]) => ({ type, count: d.count, avgValue: d.count > 0 ? d.totalValue / d.count : 0, avgHours: d.count > 0 ? d.totalHours / d.count : 0 })).sort((a, b) => b.count - a.count).slice(0, 10),
+    dayOfWeek: dayNames.map((day, i) => ({ day, count: dayCount[i] })),
+    monthlyTrend: Object.entries(monthly).map(([month, d]) => ({ month, ...d })).sort((a, b) => a.month.localeCompare(b.month)),
+  };
+}
+
+async function fetchRetention(orgId: string, start: Date, end: Date) {
+  const customers = await db.customer.findMany({
+    where: { organizationId: orgId },
+    select: { id: true, name: true, company: true, vehicles: { select: { serviceRecords: { where: { startDateTime: { gte: start, lte: end } }, select: { serviceDate: true, startDateTime: true, totalAmount: true, cost: true }, orderBy: [{ startDateTime: { sort: "asc", nulls: "last" } }, { serviceDate: "asc" }] } } } },
+  });
+  let returning = 0, newC = 0;
+  const allGaps: number[] = [];
+  const top: { id: string; name: string; company: string | null; visitCount: number; totalSpent: number; avgTimeBetweenVisits: number | null }[] = [];
+  for (const c of customers) {
+    const visits: { date: Date; amount: number }[] = [];
+    for (const v of c.vehicles) for (const sr of v.serviceRecords) visits.push({ date: sr.startDateTime ?? sr.serviceDate, amount: sr.totalAmount > 0 ? sr.totalAmount : sr.cost });
+    if (visits.length === 0) continue;
+    visits.sort((a, b) => a.date.getTime() - b.date.getTime());
+    if (visits.length > 1) {
+      returning++;
+      const gaps: number[] = [];
+      for (let i = 1; i < visits.length; i++) gaps.push((visits[i].date.getTime() - visits[i - 1].date.getTime()) / 86400000);
+      const avg = gaps.reduce((s, g) => s + g, 0) / gaps.length;
+      allGaps.push(avg);
+      top.push({ id: c.id, name: c.name, company: c.company, visitCount: visits.length, totalSpent: visits.reduce((s, v) => s + v.amount, 0), avgTimeBetweenVisits: Math.round(avg) });
+    } else { newC++; }
+  }
+  top.sort((a, b) => b.visitCount - a.visitCount);
+  return { returningCustomers: returning, newCustomers: newC, totalActive: returning + newC, avgTimeBetweenVisits: allGaps.length > 0 ? Math.round(allGaps.reduce((s, v) => s + v, 0) / allGaps.length) : null, topReturning: top.slice(0, 20) };
+}
+
+async function fetchInventory(orgId: string) {
+  const parts = await db.inventoryPart.findMany({ where: { organizationId: orgId, isArchived: false }, select: { id: true, name: true, partNumber: true, quantity: true, unitCost: true, sellPrice: true, minQuantity: true }, orderBy: { name: "asc" } });
+  let totalValue = 0, totalSellValue = 0, totalItems = 0;
+  const lowStock: typeof parts = [];
+  for (const p of parts) { totalValue += (p.unitCost || 0) * p.quantity; totalSellValue += (p.sellPrice > 0 ? p.sellPrice : p.unitCost || 0) * p.quantity; totalItems += p.quantity; if (p.minQuantity && p.quantity <= p.minQuantity) lowStock.push(p); }
+  return { totalParts: parts.length, totalItems, totalValue, totalSellValue, lowStock };
+}
+
+async function fetchPastDue(orgId: string) {
+  const now = new Date();
+  const records = await db.serviceRecord.findMany({
+    where: { vehicle: { organizationId: orgId }, invoiceDueDate: { lt: now }, status: { not: "cancelled" } },
+    select: { id: true, invoiceNumber: true, invoiceDueDate: true, totalAmount: true, cost: true, manuallyPaid: true, payments: { select: { amount: true } }, vehicle: { select: { year: true, make: true, model: true, customer: { select: { name: true, company: true } } } } },
+    orderBy: { invoiceDueDate: "asc" },
+  });
+  const invoices: { id: string; invoiceNumber: string | null; customerName: string; customerCompany: string | null; vehicleInfo: string; totalAmount: number; amountPaid: number; amountDue: number; dueDate: string; daysPastDue: number }[] = [];
+  let totalAmountDue = 0, over30 = 0, over60 = 0, over90 = 0;
+  for (const r of records) {
+    const total = r.totalAmount > 0 ? r.totalAmount : r.cost;
+    const paid = r.manuallyPaid ? total : r.payments.reduce((s, p) => s + p.amount, 0);
+    const amountDue = total - paid;
+    if (amountDue <= 0) continue;
+    const dueDate = r.invoiceDueDate!;
+    const days = Math.floor((now.getTime() - dueDate.getTime()) / 86400000);
+    invoices.push({ id: r.id, invoiceNumber: r.invoiceNumber, customerName: r.vehicle.customer?.name ?? "Unknown", customerCompany: r.vehicle.customer?.company ?? null, vehicleInfo: [r.vehicle.year, r.vehicle.make, r.vehicle.model].filter(Boolean).join(" ") || "N/A", totalAmount: total, amountPaid: paid, amountDue, dueDate: dueDate.toISOString().split("T")[0], daysPastDue: days });
+    totalAmountDue += amountDue; if (days > 30) over30++; if (days > 60) over60++; if (days > 90) over90++;
+  }
+  return { invoices, summary: { totalPastDue: invoices.length, totalAmountDue, totalInvoices: invoices.length, over30, over60, over90 } };
+}
+
+async function fetchTax(orgId: string, start: Date, end: Date) {
+  const records = await db.serviceRecord.findMany({
+    where: { vehicle: { organizationId: orgId }, startDateTime: { gte: start, lte: end } },
+    select: { serviceDate: true, startDateTime: true, subtotal: true, taxRate: true, taxAmount: true },
+    orderBy: [{ startDateTime: { sort: "asc", nulls: "last" } }, { serviceDate: "asc" }],
+  });
+  const monthly: Record<string, { taxCollected: number; invoiceCount: number; taxableAmount: number }> = {};
+  const byRate: Record<number, { taxCollected: number; invoiceCount: number }> = {};
+  let totalTaxCollected = 0, totalTaxableAmount = 0, totalInvoices = 0;
+  for (const r of records) {
+    if (r.taxAmount <= 0) continue;
+    const _d = r.startDateTime ?? r.serviceDate;
+    const month = `${_d.getFullYear()}-${String(_d.getMonth() + 1).padStart(2, "0")}`;
+    if (!monthly[month]) monthly[month] = { taxCollected: 0, invoiceCount: 0, taxableAmount: 0 };
+    monthly[month].taxCollected += r.taxAmount; monthly[month].invoiceCount += 1; monthly[month].taxableAmount += r.subtotal;
+    if (!byRate[r.taxRate]) byRate[r.taxRate] = { taxCollected: 0, invoiceCount: 0 };
+    byRate[r.taxRate].taxCollected += r.taxAmount; byRate[r.taxRate].invoiceCount += 1;
+    totalTaxCollected += r.taxAmount; totalTaxableAmount += r.subtotal; totalInvoices += 1;
+  }
+  return {
+    monthly: Object.entries(monthly).map(([month, d]) => ({ month, ...d })),
+    byRate: Object.entries(byRate).map(([rate, d]) => ({ taxRate: Number(rate), ...d })),
+    summary: { totalTaxCollected, totalTaxableAmount, totalInvoices },
+  };
+}
+
+// --------------- process a single schedule ---------------
+
+export async function processOneSchedule(schedule: {
+  id: string;
+  name: string;
+  frequency: string;
+  dateRange: string;
+  sections: string;
+  recipients: string;
+  organizationId: string;
+  endDate: Date | null;
+}) {
+  const now = new Date();
+  const sections: string[] = JSON.parse(schedule.sections);
+  const recipientIds: string[] = JSON.parse(schedule.recipients);
+  const { start, end } = getDateRange(schedule.dateRange || "last30d");
+  const dateRangeStr = formatDateRange(start, end);
+
+  // Fetch org settings
+  const settings = await db.appSetting.findMany({
+    where: { organizationId: schedule.organizationId, key: { in: [SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.INVOICE_PRIMARY_COLOR] } },
+  });
+  const settingsMap: Record<string, string> = {};
+  for (const s of settings) settingsMap[s.key] = s.value;
+  const currencyCode = settingsMap[SETTING_KEYS.CURRENCY_CODE] || "USD";
+  const primaryColor = settingsMap[SETTING_KEYS.INVOICE_PRIMARY_COLOR] || "#d97706";
+
+  // Fetch report data for selected sections
+  const [revenueData, taxData, pastDueData, serviceData, customerData, technicianData, partsData, jobAnalyticsData, retentionData, inventoryData] = await Promise.all([
+    sections.includes("revenue") ? fetchRevenue(schedule.organizationId, start, end) : null,
+    sections.includes("tax") ? fetchTax(schedule.organizationId, start, end) : null,
+    sections.includes("pastDue") ? fetchPastDue(schedule.organizationId) : null,
+    sections.includes("services") ? fetchServices(schedule.organizationId, start, end) : null,
+    sections.includes("customers") ? fetchCustomers(schedule.organizationId, start, end) : null,
+    sections.includes("technicians") ? fetchTechnicians(schedule.organizationId, start, end) : null,
+    sections.includes("parts") ? fetchParts(schedule.organizationId, start, end) : null,
+    sections.includes("jobAnalytics") ? fetchJobAnalytics(schedule.organizationId, start, end) : null,
+    sections.includes("retention") ? fetchRetention(schedule.organizationId, start, end) : null,
+    sections.includes("inventory") ? fetchInventory(schedule.organizationId) : null,
+  ]);
+
+  // Generate PDF
+  const element = React.createElement(ReportPDF, {
+    dateRange: dateRangeStr,
+    currencyCode,
+    primaryColor,
+    labels: {},
+    revenueData,
+    taxData,
+    pastDueData,
+    serviceData,
+    customerData,
+    technicianData,
+    partsData,
+    jobAnalyticsData,
+    retentionData,
+    inventoryData,
+  });
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const pdfBuffer = await renderToBuffer(element as any);
+
+  // Resolve recipient emails
+  const recipients = await db.user.findMany({
+    where: { id: { in: recipientIds } },
+    select: { name: true, email: true },
+  });
+
+  const fromAddress = await getOrgFromAddress(schedule.organizationId);
+  const org = await db.organization.findUnique({
+    where: { id: schedule.organizationId },
+    select: { name: true },
+  });
+
+  // Send email to each recipient
+  for (const recipient of recipients) {
+    try {
+      await sendOrgMail(schedule.organizationId, {
+        to: recipient.email,
+        subject: `${schedule.name} – ${dateRangeStr}`,
+        html: `<p>Hi ${recipient.name || ""},</p><p>Please find attached your scheduled business report from <strong>${org?.name || "your organization"}</strong>.</p><p>Report period: ${dateRangeStr}<br/>Frequency: ${schedule.frequency}</p><p>This is an automated report.</p>`,
+        from: fromAddress,
+        attachments: [
+          {
+            filename: `report-${now.toISOString().split("T")[0]}.pdf`,
+            content: pdfBuffer,
+          },
+        ],
+      });
+    } catch (emailErr) {
+      console.error(`[cron] Failed to email report to ${recipient.email}:`, emailErr);
+    }
+  }
+
+  // Update schedule
+  const nextRunDate = calculateNextRunDate(now, schedule.frequency);
+  const shouldDeactivate = schedule.endDate && nextRunDate > schedule.endDate;
+
+  await db.reportSchedule.update({
+    where: { id: schedule.id },
+    data: {
+      lastRunAt: now,
+      runCount: { increment: 1 },
+      nextRunDate,
+      isActive: !shouldDeactivate,
+    },
+  });
+
+  console.warn(`[cron] Report schedule ${schedule.id} processed, sent to ${recipients.length} recipients`);
+}
+
+// --------------- main cron ---------------
+
+export function processReportSchedules() {
+  const job = new CronJob("0 * * * *", async () => {
+    const dueSchedules = await db.reportSchedule.findMany({
+      where: { isActive: true, nextRunDate: { lte: new Date() } },
+    });
+
+    for (const schedule of dueSchedules) {
+      try {
+        await processOneSchedule(schedule);
+      } catch (err) {
+        console.error(`[cron] Failed to process report schedule ${schedule.id}:`, err);
+      }
+    }
+  });
+
+  job.start();
+  console.warn("[cron] Report schedule processor started (hourly)");
+}