Ver Fonte

Rebuild the dashboard as a drag-and-resize grid with custom table cards (#224)

* Rebuild the dashboard as a drag-and-resize grid

The dashboard cards now live in a 12-column react-grid-layout v2 grid:
a Customize button enters edit mode where cards drag anywhere and
resize from the corner, with show/hide toggles, reset-to-default and a
wiggle-mode overlay that shields card content while arranging. The
layout persists per user (User.dashboardLayout, server-side) and
normalizes against the card set, so new cards slot into their default
spot. Cards fill their tile and scroll internally; small screens render
a compacted read-only single column. The Recent Activity card joins
the toggleable set, and the localStorage visibility hook is replaced
by the persisted layout.

* Make the resize handle clearly visible

The default faint gray arrow is replaced with a corner grip in the
theme color that grows on hover and while resizing.

* Add user-defined custom table cards to the dashboard

In edit mode, Add card opens a builder: pick a data source (vehicles,
customers, work orders, quotes, inventory), stack filters with
type-aware operators and inputs, choose display columns and a row
limit. Cards render as clickable tables in the grid, participate in
drag/resize/hide like built-in cards, and can be edited or deleted
from their header while editing.

Security: entities, fields, operators and enum values form a closed
registry validated on save and on every run; queries are read-only
Prisma findMany calls with fixed selects, org scoping and per-entity
READ permission checks — user input only ever appears as parameterized
condition values. Widgets are per user per org (max 20), and the
layout normalizer accepts custom card ids so positions persist and
deleted widgets clean up. Labels in all 12 languages.

* Drop the example card name placeholder

* Apply the initial grid width correction without animation

The container width settles shortly after mount (scrollbar appearance
shifts it a few pixels) and both our and the library's transitions
played that correction as a visible grow. All grid transitions are now
suppressed until 250ms after mount, so the correction is instant and
drag/resize animations remain smooth.

* Render the sidebar in its saved state and gate the grid on width stability

The authenticated layout now reads the sidebar_state cookie, so a
collapsed sidebar is collapsed from the first server-rendered paint
instead of collapsing after hydration - the ~300px shift that made the
dashboard grid measure a too-narrow container (and jolted every other
page too). As a safety net the grid stays hidden and unanimated until
its measured width has been stable for 200ms, then fades in at its
final layout.

* Show a skeleton while the dashboard grid prepares

Four pulsing card placeholders in the default two-column arrangement
render until the container width has stabilized; the real grid then
mounts directly at its final size and fades in. The grid never renders
at a wrong width, so the transition-suppression CSS is gone.

* Match the dashboard route loading skeleton to the card grid

The route-level loading state now mirrors the real dashboard: stats
tiles, toolbar, and the same four large card skeletons the grid shows
before mounting, so streaming loading, page render and grid fade-in
read as one continuous state.

* Actually render the grid pre-mount skeleton

The skeleton branch from the previous commit never applied (bad text
replacement), leaving only an invisible grid and an unused import that
failed lint. The grid now shows four card placeholders until the width
stabilizes, then mounts the real grid at final size.
Bernt Christian Egeland há 3 meses atrás
pai
commit
38ab1aa6af
32 ficheiros alterados com 2915 adições e 144 exclusões
  1. 97 2
      messages/de/dashboard.json
  2. 97 2
      messages/en/dashboard.json
  3. 97 2
      messages/es/dashboard.json
  4. 97 2
      messages/fr/dashboard.json
  5. 97 2
      messages/it/dashboard.json
  6. 97 2
      messages/lt/dashboard.json
  7. 97 2
      messages/nb/dashboard.json
  8. 97 2
      messages/nl/dashboard.json
  9. 97 2
      messages/pl/dashboard.json
  10. 97 2
      messages/pt-BR/dashboard.json
  11. 97 2
      messages/ru/dashboard.json
  12. 97 2
      messages/tr/dashboard.json
  13. 69 0
      package-lock.json
  14. 2 0
      package.json
  15. 2 0
      prisma/migrations/20260811164154_user_dashboard_layout/migration.sql
  16. 18 0
      prisma/migrations/20260811170236_dashboard_widgets/migration.sql
  17. 21 0
      prisma/schema.prisma
  18. 241 59
      src/app/(authenticated)/dashboard-client.tsx
  19. 7 0
      src/app/(authenticated)/layout.tsx
  20. 14 4
      src/app/(authenticated)/loading.tsx
  21. 23 0
      src/app/(authenticated)/page.tsx
  22. 70 0
      src/app/globals.css
  23. 2 0
      src/app/layout.tsx
  24. 105 0
      src/features/dashboard/Actions/customCardActions.ts
  25. 41 0
      src/features/dashboard/Actions/dashboardLayoutActions.ts
  26. 324 0
      src/features/dashboard/Components/CustomCardDialog.tsx
  27. 146 0
      src/features/dashboard/Components/CustomTableCard.tsx
  28. 117 0
      src/features/dashboard/Components/DashboardGrid.tsx
  29. 177 0
      src/features/dashboard/custom-cards/registry.ts
  30. 246 0
      src/features/dashboard/custom-cards/server-registry.ts
  31. 126 0
      src/features/dashboard/dashboard-grid-config.ts
  32. 0 57
      src/hooks/use-dashboard-visibility.ts

+ 97 - 2
messages/de/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Letzte Benachrichtigungen",
     "recentCompleted": "Kürzlich abgeschlossen",
     "activeJobs": "Aktive Aufträge",
-    "recentObservations": "Aktuelle Beobachtungen"
+    "recentObservations": "Aktuelle Beobachtungen",
+    "recentActivity": "Letzte Aktivitäten"
   },
   "maintenance": {
     "title": "Voraussichtlich fällige Wartung",
@@ -138,5 +139,99 @@
     "hours": "{count}h",
     "days": "{count}T"
   },
-  "error": "Dashboard konnte nicht geladen werden"
+  "error": "Dashboard konnte nicht geladen werden",
+  "editHint": "Karten ziehen zum Anordnen, Ecke ziehen zum Vergrößern.",
+  "resetLayout": "Layout zurücksetzen",
+  "doneEditing": "Fertig",
+  "customCards": {
+    "addCard": "Karte hinzufügen",
+    "editCard": "Karte bearbeiten",
+    "deleteCard": "Karte löschen",
+    "cardName": "Kartenname",
+    "dataSource": "Datenquelle",
+    "filters": "Filter",
+    "addFilter": "Filter hinzufügen",
+    "columns": "Spalten",
+    "rowLimit": "Zeilen",
+    "save": "Speichern",
+    "cancel": "Abbrechen",
+    "valuePlaceholder": "Wert…",
+    "noResults": "Keine passenden Einträge.",
+    "loadError": "Karte konnte nicht geladen werden.",
+    "saveError": "Karte konnte nicht gespeichert werden.",
+    "deleteConfirmTitle": "Karte löschen?",
+    "deleteConfirmDescription": "„{name}“ wird vom Dashboard entfernt. Die Daten selbst bleiben erhalten.",
+    "entities": {
+      "vehicles": "Fahrzeuge",
+      "customers": "Kunden",
+      "workOrders": "Arbeitsaufträge",
+      "quotes": "Angebote",
+      "inventory": "Lager"
+    },
+    "fields": {
+      "licensePlate": "Kennzeichen",
+      "make": "Marke",
+      "model": "Modell",
+      "year": "Jahr",
+      "color": "Farbe",
+      "vin": "FIN",
+      "mileage": "Kilometerstand",
+      "fuelType": "Kraftstoff",
+      "transmission": "Getriebe",
+      "customer": "Kunde",
+      "createdAt": "Erstellt",
+      "updatedAt": "Aktualisiert",
+      "name": "Name",
+      "company": "Firma",
+      "email": "E-Mail",
+      "phone": "Telefon",
+      "address": "Adresse",
+      "taxId": "Steuernummer",
+      "invoiceNumber": "Rechnungsnr.",
+      "title": "Titel",
+      "type": "Typ",
+      "status": "Status",
+      "techName": "Techniker",
+      "totalAmount": "Summe",
+      "serviceDate": "Datum",
+      "vehicle": "Fahrzeug",
+      "quoteNumber": "Angebotsnr.",
+      "validUntil": "Gültig bis",
+      "partNumber": "Teilenr.",
+      "category": "Kategorie",
+      "quantity": "Menge",
+      "minQuantity": "Mindestmenge",
+      "unitCost": "Kosten",
+      "sellPrice": "Preis",
+      "supplier": "Lieferant"
+    },
+    "operators": {
+      "contains": "enthält",
+      "equals": "ist gleich",
+      "startsWith": "beginnt mit",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "nach",
+      "before": "vor"
+    },
+    "options": {
+      "maintenance": "Wartung",
+      "repair": "Reparatur",
+      "upgrade": "Umbau",
+      "inspection": "Inspektion",
+      "pending": "Ausstehend",
+      "in-progress": "In Arbeit",
+      "waiting-parts": "Wartet auf Teile",
+      "completed": "Abgeschlossen",
+      "draft": "Entwurf",
+      "sent": "Gesendet",
+      "accepted": "Angenommen",
+      "rejected": "Abgelehnt",
+      "expired": "Abgelaufen",
+      "converted": "Umgewandelt"
+    }
+  }
 }

+ 97 - 2
messages/en/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Recent Notifications",
     "recentCompleted": "Recent Completed",
     "activeJobs": "Active Jobs",
-    "recentObservations": "Recent Observations"
+    "recentObservations": "Recent Observations",
+    "recentActivity": "Recent Activity"
   },
   "maintenance": {
     "title": "Predicted Maintenance Due",
@@ -138,5 +139,99 @@
     "hours": "{count}h",
     "days": "{count}d"
   },
-  "error": "Failed to load dashboard"
+  "error": "Failed to load dashboard",
+  "editHint": "Drag cards to rearrange, pull the corner to resize.",
+  "resetLayout": "Reset layout",
+  "doneEditing": "Done",
+  "customCards": {
+    "addCard": "Add card",
+    "editCard": "Edit card",
+    "deleteCard": "Delete card",
+    "cardName": "Card name",
+    "dataSource": "Data source",
+    "filters": "Filters",
+    "addFilter": "Add filter",
+    "columns": "Columns",
+    "rowLimit": "Rows",
+    "save": "Save",
+    "cancel": "Cancel",
+    "valuePlaceholder": "Value…",
+    "noResults": "No matching records.",
+    "loadError": "Could not load this card.",
+    "saveError": "Could not save the card.",
+    "deleteConfirmTitle": "Delete card?",
+    "deleteConfirmDescription": "\"{name}\" will be removed from your dashboard. The underlying data is not affected.",
+    "entities": {
+      "vehicles": "Vehicles",
+      "customers": "Customers",
+      "workOrders": "Work orders",
+      "quotes": "Quotes",
+      "inventory": "Inventory"
+    },
+    "fields": {
+      "licensePlate": "Plate",
+      "make": "Make",
+      "model": "Model",
+      "year": "Year",
+      "color": "Color",
+      "vin": "VIN",
+      "mileage": "Mileage",
+      "fuelType": "Fuel type",
+      "transmission": "Transmission",
+      "customer": "Customer",
+      "createdAt": "Created",
+      "updatedAt": "Updated",
+      "name": "Name",
+      "company": "Company",
+      "email": "Email",
+      "phone": "Phone",
+      "address": "Address",
+      "taxId": "Tax ID",
+      "invoiceNumber": "Invoice #",
+      "title": "Title",
+      "type": "Type",
+      "status": "Status",
+      "techName": "Technician",
+      "totalAmount": "Total",
+      "serviceDate": "Date",
+      "vehicle": "Vehicle",
+      "quoteNumber": "Quote #",
+      "validUntil": "Valid until",
+      "partNumber": "Part #",
+      "category": "Category",
+      "quantity": "Quantity",
+      "minQuantity": "Min quantity",
+      "unitCost": "Cost",
+      "sellPrice": "Price",
+      "supplier": "Supplier"
+    },
+    "operators": {
+      "contains": "contains",
+      "equals": "equals",
+      "startsWith": "starts with",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "after",
+      "before": "before"
+    },
+    "options": {
+      "maintenance": "Maintenance",
+      "repair": "Repair",
+      "upgrade": "Upgrade",
+      "inspection": "Inspection",
+      "pending": "Pending",
+      "in-progress": "In progress",
+      "waiting-parts": "Waiting for parts",
+      "completed": "Completed",
+      "draft": "Draft",
+      "sent": "Sent",
+      "accepted": "Accepted",
+      "rejected": "Rejected",
+      "expired": "Expired",
+      "converted": "Converted"
+    }
+  }
 }

+ 97 - 2
messages/es/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Notificaciones recientes",
     "recentCompleted": "Completados recientemente",
     "activeJobs": "Trabajos activos",
-    "recentObservations": "Observaciones recientes"
+    "recentObservations": "Observaciones recientes",
+    "recentActivity": "Actividad reciente"
   },
   "maintenance": {
     "title": "Mantenimiento previsto pendiente",
@@ -138,5 +139,99 @@
     "hours": "{count}h",
     "days": "{count}d"
   },
-  "error": "Error al cargar el panel de control"
+  "error": "Error al cargar el panel de control",
+  "editHint": "Arrastra las tarjetas para reordenar; tira de la esquina para cambiar el tamaño.",
+  "resetLayout": "Restablecer diseño",
+  "doneEditing": "Listo",
+  "customCards": {
+    "addCard": "Añadir tarjeta",
+    "editCard": "Editar tarjeta",
+    "deleteCard": "Eliminar tarjeta",
+    "cardName": "Nombre de la tarjeta",
+    "dataSource": "Fuente de datos",
+    "filters": "Filtros",
+    "addFilter": "Añadir filtro",
+    "columns": "Columnas",
+    "rowLimit": "Filas",
+    "save": "Guardar",
+    "cancel": "Cancelar",
+    "valuePlaceholder": "Valor…",
+    "noResults": "Sin registros coincidentes.",
+    "loadError": "No se pudo cargar la tarjeta.",
+    "saveError": "No se pudo guardar la tarjeta.",
+    "deleteConfirmTitle": "¿Eliminar tarjeta?",
+    "deleteConfirmDescription": "«{name}» se eliminará del panel. Los datos no se ven afectados.",
+    "entities": {
+      "vehicles": "Vehículos",
+      "customers": "Clientes",
+      "workOrders": "Órdenes de trabajo",
+      "quotes": "Presupuestos",
+      "inventory": "Inventario"
+    },
+    "fields": {
+      "licensePlate": "Matrícula",
+      "make": "Marca",
+      "model": "Modelo",
+      "year": "Año",
+      "color": "Color",
+      "vin": "VIN",
+      "mileage": "Kilometraje",
+      "fuelType": "Combustible",
+      "transmission": "Transmisión",
+      "customer": "Cliente",
+      "createdAt": "Creado",
+      "updatedAt": "Actualizado",
+      "name": "Nombre",
+      "company": "Empresa",
+      "email": "Correo",
+      "phone": "Teléfono",
+      "address": "Dirección",
+      "taxId": "NIF",
+      "invoiceNumber": "N.º factura",
+      "title": "Título",
+      "type": "Tipo",
+      "status": "Estado",
+      "techName": "Técnico",
+      "totalAmount": "Total",
+      "serviceDate": "Fecha",
+      "vehicle": "Vehículo",
+      "quoteNumber": "N.º presupuesto",
+      "validUntil": "Válido hasta",
+      "partNumber": "N.º pieza",
+      "category": "Categoría",
+      "quantity": "Cantidad",
+      "minQuantity": "Cantidad mínima",
+      "unitCost": "Coste",
+      "sellPrice": "Precio",
+      "supplier": "Proveedor"
+    },
+    "operators": {
+      "contains": "contiene",
+      "equals": "es igual a",
+      "startsWith": "empieza por",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "después de",
+      "before": "antes de"
+    },
+    "options": {
+      "maintenance": "Mantenimiento",
+      "repair": "Reparación",
+      "upgrade": "Mejora",
+      "inspection": "Inspección",
+      "pending": "Pendiente",
+      "in-progress": "En curso",
+      "waiting-parts": "Esperando piezas",
+      "completed": "Completado",
+      "draft": "Borrador",
+      "sent": "Enviado",
+      "accepted": "Aceptado",
+      "rejected": "Rechazado",
+      "expired": "Caducado",
+      "converted": "Convertido"
+    }
+  }
 }

+ 97 - 2
messages/fr/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Notifications récentes",
     "recentCompleted": "Terminés récemment",
     "activeJobs": "Travaux en cours",
-    "recentObservations": "Observations récentes"
+    "recentObservations": "Observations récentes",
+    "recentActivity": "Activité récente"
   },
   "maintenance": {
     "title": "Maintenance prévue à effectuer",
@@ -138,5 +139,99 @@
     "hours": "{count} h",
     "days": "{count} j"
   },
-  "error": "Échec du chargement du tableau de bord"
+  "error": "Échec du chargement du tableau de bord",
+  "editHint": "Faites glisser les cartes pour les réorganiser, tirez le coin pour redimensionner.",
+  "resetLayout": "Réinitialiser la disposition",
+  "doneEditing": "Terminé",
+  "customCards": {
+    "addCard": "Ajouter une carte",
+    "editCard": "Modifier la carte",
+    "deleteCard": "Supprimer la carte",
+    "cardName": "Nom de la carte",
+    "dataSource": "Source de données",
+    "filters": "Filtres",
+    "addFilter": "Ajouter un filtre",
+    "columns": "Colonnes",
+    "rowLimit": "Lignes",
+    "save": "Enregistrer",
+    "cancel": "Annuler",
+    "valuePlaceholder": "Valeur…",
+    "noResults": "Aucun enregistrement correspondant.",
+    "loadError": "Impossible de charger la carte.",
+    "saveError": "Impossible d'enregistrer la carte.",
+    "deleteConfirmTitle": "Supprimer la carte ?",
+    "deleteConfirmDescription": "« {name} » sera retirée du tableau de bord. Les données ne sont pas affectées.",
+    "entities": {
+      "vehicles": "Véhicules",
+      "customers": "Clients",
+      "workOrders": "Ordres de travail",
+      "quotes": "Devis",
+      "inventory": "Stock"
+    },
+    "fields": {
+      "licensePlate": "Plaque",
+      "make": "Marque",
+      "model": "Modèle",
+      "year": "Année",
+      "color": "Couleur",
+      "vin": "VIN",
+      "mileage": "Kilométrage",
+      "fuelType": "Carburant",
+      "transmission": "Transmission",
+      "customer": "Client",
+      "createdAt": "Créé",
+      "updatedAt": "Mis à jour",
+      "name": "Nom",
+      "company": "Société",
+      "email": "E-mail",
+      "phone": "Téléphone",
+      "address": "Adresse",
+      "taxId": "N° fiscal",
+      "invoiceNumber": "N° facture",
+      "title": "Titre",
+      "type": "Type",
+      "status": "Statut",
+      "techName": "Technicien",
+      "totalAmount": "Total",
+      "serviceDate": "Date",
+      "vehicle": "Véhicule",
+      "quoteNumber": "N° devis",
+      "validUntil": "Valide jusqu'au",
+      "partNumber": "Réf. pièce",
+      "category": "Catégorie",
+      "quantity": "Quantité",
+      "minQuantity": "Quantité min.",
+      "unitCost": "Coût",
+      "sellPrice": "Prix",
+      "supplier": "Fournisseur"
+    },
+    "operators": {
+      "contains": "contient",
+      "equals": "est égal à",
+      "startsWith": "commence par",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "après",
+      "before": "avant"
+    },
+    "options": {
+      "maintenance": "Entretien",
+      "repair": "Réparation",
+      "upgrade": "Amélioration",
+      "inspection": "Inspection",
+      "pending": "En attente",
+      "in-progress": "En cours",
+      "waiting-parts": "En attente de pièces",
+      "completed": "Terminé",
+      "draft": "Brouillon",
+      "sent": "Envoyé",
+      "accepted": "Accepté",
+      "rejected": "Refusé",
+      "expired": "Expiré",
+      "converted": "Converti"
+    }
+  }
 }

+ 97 - 2
messages/it/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Notifiche recenti",
     "recentCompleted": "Completati di recente",
     "activeJobs": "Lavori attivi",
-    "recentObservations": "Osservazioni recenti"
+    "recentObservations": "Osservazioni recenti",
+    "recentActivity": "Attività recente"
   },
   "maintenance": {
     "title": "Manutenzione prevista in scadenza",
@@ -138,5 +139,99 @@
     "hours": "{count}h",
     "days": "{count}g"
   },
-  "error": "Impossibile caricare la dashboard"
+  "error": "Impossibile caricare la dashboard",
+  "editHint": "Trascina le schede per riordinarle, tira l’angolo per ridimensionare.",
+  "resetLayout": "Reimposta layout",
+  "doneEditing": "Fatto",
+  "customCards": {
+    "addCard": "Aggiungi scheda",
+    "editCard": "Modifica scheda",
+    "deleteCard": "Elimina scheda",
+    "cardName": "Nome scheda",
+    "dataSource": "Origine dati",
+    "filters": "Filtri",
+    "addFilter": "Aggiungi filtro",
+    "columns": "Colonne",
+    "rowLimit": "Righe",
+    "save": "Salva",
+    "cancel": "Annulla",
+    "valuePlaceholder": "Valore…",
+    "noResults": "Nessun record corrispondente.",
+    "loadError": "Impossibile caricare la scheda.",
+    "saveError": "Impossibile salvare la scheda.",
+    "deleteConfirmTitle": "Eliminare la scheda?",
+    "deleteConfirmDescription": "«{name}» verrà rimossa dalla dashboard. I dati non vengono toccati.",
+    "entities": {
+      "vehicles": "Veicoli",
+      "customers": "Clienti",
+      "workOrders": "Ordini di lavoro",
+      "quotes": "Preventivi",
+      "inventory": "Magazzino"
+    },
+    "fields": {
+      "licensePlate": "Targa",
+      "make": "Marca",
+      "model": "Modello",
+      "year": "Anno",
+      "color": "Colore",
+      "vin": "Telaio",
+      "mileage": "Chilometraggio",
+      "fuelType": "Carburante",
+      "transmission": "Cambio",
+      "customer": "Cliente",
+      "createdAt": "Creato",
+      "updatedAt": "Aggiornato",
+      "name": "Nome",
+      "company": "Azienda",
+      "email": "E-mail",
+      "phone": "Telefono",
+      "address": "Indirizzo",
+      "taxId": "P. IVA",
+      "invoiceNumber": "N. fattura",
+      "title": "Titolo",
+      "type": "Tipo",
+      "status": "Stato",
+      "techName": "Tecnico",
+      "totalAmount": "Totale",
+      "serviceDate": "Data",
+      "vehicle": "Veicolo",
+      "quoteNumber": "N. preventivo",
+      "validUntil": "Valido fino al",
+      "partNumber": "Cod. ricambio",
+      "category": "Categoria",
+      "quantity": "Quantità",
+      "minQuantity": "Quantità min.",
+      "unitCost": "Costo",
+      "sellPrice": "Prezzo",
+      "supplier": "Fornitore"
+    },
+    "operators": {
+      "contains": "contiene",
+      "equals": "uguale a",
+      "startsWith": "inizia con",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "dopo",
+      "before": "prima"
+    },
+    "options": {
+      "maintenance": "Manutenzione",
+      "repair": "Riparazione",
+      "upgrade": "Upgrade",
+      "inspection": "Ispezione",
+      "pending": "In attesa",
+      "in-progress": "In corso",
+      "waiting-parts": "In attesa di ricambi",
+      "completed": "Completato",
+      "draft": "Bozza",
+      "sent": "Inviato",
+      "accepted": "Accettato",
+      "rejected": "Rifiutato",
+      "expired": "Scaduto",
+      "converted": "Convertito"
+    }
+  }
 }

+ 97 - 2
messages/lt/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Naujausi pranešimai",
     "recentCompleted": "Neseniai užbaigti",
     "activeJobs": "Aktyvūs darbai",
-    "recentObservations": "Naujausi pastebėjimai"
+    "recentObservations": "Naujausi pastebėjimai",
+    "recentActivity": "Naujausia veikla"
   },
   "maintenance": {
     "title": "Numatoma priežiūra",
@@ -138,5 +139,99 @@
     "hours": "{count} val.",
     "days": "{count} d."
   },
-  "error": "Nepavyko įkelti valdymo skydelio"
+  "error": "Nepavyko įkelti valdymo skydelio",
+  "editHint": "Vilkite korteles išdėstymui, tempkite kampą dydžiui keisti.",
+  "resetLayout": "Atstatyti išdėstymą",
+  "doneEditing": "Atlikta",
+  "customCards": {
+    "addCard": "Pridėti kortelę",
+    "editCard": "Redaguoti kortelę",
+    "deleteCard": "Ištrinti kortelę",
+    "cardName": "Kortelės pavadinimas",
+    "dataSource": "Duomenų šaltinis",
+    "filters": "Filtrai",
+    "addFilter": "Pridėti filtrą",
+    "columns": "Stulpeliai",
+    "rowLimit": "Eilutės",
+    "save": "Išsaugoti",
+    "cancel": "Atšaukti",
+    "valuePlaceholder": "Reikšmė…",
+    "noResults": "Atitinkančių įrašų nėra.",
+    "loadError": "Nepavyko įkelti kortelės.",
+    "saveError": "Nepavyko išsaugoti kortelės.",
+    "deleteConfirmTitle": "Ištrinti kortelę?",
+    "deleteConfirmDescription": "„{name}“ bus pašalinta iš skydelio. Duomenys nepaveikiami.",
+    "entities": {
+      "vehicles": "Automobiliai",
+      "customers": "Klientai",
+      "workOrders": "Darbo užsakymai",
+      "quotes": "Pasiūlymai",
+      "inventory": "Sandėlis"
+    },
+    "fields": {
+      "licensePlate": "Valst. numeris",
+      "make": "Markė",
+      "model": "Modelis",
+      "year": "Metai",
+      "color": "Spalva",
+      "vin": "VIN",
+      "mileage": "Rida",
+      "fuelType": "Kuras",
+      "transmission": "Pavarų dėžė",
+      "customer": "Klientas",
+      "createdAt": "Sukurta",
+      "updatedAt": "Atnaujinta",
+      "name": "Vardas",
+      "company": "Įmonė",
+      "email": "El. paštas",
+      "phone": "Telefonas",
+      "address": "Adresas",
+      "taxId": "PVM kodas",
+      "invoiceNumber": "Sąskaitos nr.",
+      "title": "Pavadinimas",
+      "type": "Tipas",
+      "status": "Būsena",
+      "techName": "Meistras",
+      "totalAmount": "Suma",
+      "serviceDate": "Data",
+      "vehicle": "Automobilis",
+      "quoteNumber": "Pasiūlymo nr.",
+      "validUntil": "Galioja iki",
+      "partNumber": "Detalės nr.",
+      "category": "Kategorija",
+      "quantity": "Kiekis",
+      "minQuantity": "Min. kiekis",
+      "unitCost": "Savikaina",
+      "sellPrice": "Kaina",
+      "supplier": "Tiekėjas"
+    },
+    "operators": {
+      "contains": "turi",
+      "equals": "lygu",
+      "startsWith": "prasideda",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "po",
+      "before": "iki"
+    },
+    "options": {
+      "maintenance": "Priežiūra",
+      "repair": "Remontas",
+      "upgrade": "Patobulinimas",
+      "inspection": "Apžiūra",
+      "pending": "Laukiama",
+      "in-progress": "Vykdoma",
+      "waiting-parts": "Laukiama detalių",
+      "completed": "Užbaigta",
+      "draft": "Juodraštis",
+      "sent": "Išsiųsta",
+      "accepted": "Priimta",
+      "rejected": "Atmesta",
+      "expired": "Nebegalioja",
+      "converted": "Konvertuota"
+    }
+  }
 }

+ 97 - 2
messages/nb/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Siste varsler",
     "recentCompleted": "Nylig fullført",
     "activeJobs": "Aktive jobber",
-    "recentObservations": "Nylige observasjoner"
+    "recentObservations": "Nylige observasjoner",
+    "recentActivity": "Nylig aktivitet"
   },
   "maintenance": {
     "title": "Forventet vedlikehold",
@@ -138,5 +139,99 @@
     "hours": "{count}t",
     "days": "{count}d"
   },
-  "error": "Kunne ikke laste dashbord"
+  "error": "Kunne ikke laste dashbord",
+  "editHint": "Dra kortene for å flytte, dra i hjørnet for å endre størrelse.",
+  "resetLayout": "Tilbakestill oppsett",
+  "doneEditing": "Ferdig",
+  "customCards": {
+    "addCard": "Legg til kort",
+    "editCard": "Rediger kort",
+    "deleteCard": "Slett kort",
+    "cardName": "Kortnavn",
+    "dataSource": "Datakilde",
+    "filters": "Filtre",
+    "addFilter": "Legg til filter",
+    "columns": "Kolonner",
+    "rowLimit": "Rader",
+    "save": "Lagre",
+    "cancel": "Avbryt",
+    "valuePlaceholder": "Verdi…",
+    "noResults": "Ingen samsvarende oppføringer.",
+    "loadError": "Kunne ikke laste kortet.",
+    "saveError": "Kunne ikke lagre kortet.",
+    "deleteConfirmTitle": "Slette kortet?",
+    "deleteConfirmDescription": "«{name}» fjernes fra dashbordet. Dataene påvirkes ikke.",
+    "entities": {
+      "vehicles": "Kjøretøy",
+      "customers": "Kunder",
+      "workOrders": "Arbeidsordrer",
+      "quotes": "Tilbud",
+      "inventory": "Lager"
+    },
+    "fields": {
+      "licensePlate": "Skilt",
+      "make": "Merke",
+      "model": "Modell",
+      "year": "År",
+      "color": "Farge",
+      "vin": "VIN",
+      "mileage": "Kilometerstand",
+      "fuelType": "Drivstoff",
+      "transmission": "Girkasse",
+      "customer": "Kunde",
+      "createdAt": "Opprettet",
+      "updatedAt": "Oppdatert",
+      "name": "Navn",
+      "company": "Firma",
+      "email": "E-post",
+      "phone": "Telefon",
+      "address": "Adresse",
+      "taxId": "Org.nr.",
+      "invoiceNumber": "Fakturanr.",
+      "title": "Tittel",
+      "type": "Type",
+      "status": "Status",
+      "techName": "Tekniker",
+      "totalAmount": "Sum",
+      "serviceDate": "Dato",
+      "vehicle": "Kjøretøy",
+      "quoteNumber": "Tilbudsnr.",
+      "validUntil": "Gyldig til",
+      "partNumber": "Delenr.",
+      "category": "Kategori",
+      "quantity": "Antall",
+      "minQuantity": "Min. antall",
+      "unitCost": "Kostnad",
+      "sellPrice": "Pris",
+      "supplier": "Leverandør"
+    },
+    "operators": {
+      "contains": "inneholder",
+      "equals": "er lik",
+      "startsWith": "begynner med",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "etter",
+      "before": "før"
+    },
+    "options": {
+      "maintenance": "Vedlikehold",
+      "repair": "Reparasjon",
+      "upgrade": "Oppgradering",
+      "inspection": "Inspeksjon",
+      "pending": "Venter",
+      "in-progress": "Pågår",
+      "waiting-parts": "Venter på deler",
+      "completed": "Fullført",
+      "draft": "Utkast",
+      "sent": "Sendt",
+      "accepted": "Akseptert",
+      "rejected": "Avvist",
+      "expired": "Utløpt",
+      "converted": "Konvertert"
+    }
+  }
 }

+ 97 - 2
messages/nl/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Recente meldingen",
     "recentCompleted": "Recent afgerond",
     "activeJobs": "Actieve opdrachten",
-    "recentObservations": "Recente waarnemingen"
+    "recentObservations": "Recente waarnemingen",
+    "recentActivity": "Recente activiteit"
   },
   "maintenance": {
     "title": "Verwacht onderhoud gepland",
@@ -138,5 +139,99 @@
     "hours": "{count}u",
     "days": "{count}d"
   },
-  "error": "Laden van dashboard mislukt"
+  "error": "Laden van dashboard mislukt",
+  "editHint": "Sleep kaarten om te herschikken, trek aan de hoek om te vergroten.",
+  "resetLayout": "Indeling herstellen",
+  "doneEditing": "Klaar",
+  "customCards": {
+    "addCard": "Kaart toevoegen",
+    "editCard": "Kaart bewerken",
+    "deleteCard": "Kaart verwijderen",
+    "cardName": "Kaartnaam",
+    "dataSource": "Gegevensbron",
+    "filters": "Filters",
+    "addFilter": "Filter toevoegen",
+    "columns": "Kolommen",
+    "rowLimit": "Rijen",
+    "save": "Opslaan",
+    "cancel": "Annuleren",
+    "valuePlaceholder": "Waarde…",
+    "noResults": "Geen overeenkomende records.",
+    "loadError": "Kaart kon niet worden geladen.",
+    "saveError": "Kaart kon niet worden opgeslagen.",
+    "deleteConfirmTitle": "Kaart verwijderen?",
+    "deleteConfirmDescription": "„{name}” wordt van het dashboard verwijderd. De gegevens blijven behouden.",
+    "entities": {
+      "vehicles": "Voertuigen",
+      "customers": "Klanten",
+      "workOrders": "Werkorders",
+      "quotes": "Offertes",
+      "inventory": "Voorraad"
+    },
+    "fields": {
+      "licensePlate": "Kenteken",
+      "make": "Merk",
+      "model": "Model",
+      "year": "Jaar",
+      "color": "Kleur",
+      "vin": "VIN",
+      "mileage": "Kilometerstand",
+      "fuelType": "Brandstof",
+      "transmission": "Transmissie",
+      "customer": "Klant",
+      "createdAt": "Aangemaakt",
+      "updatedAt": "Bijgewerkt",
+      "name": "Naam",
+      "company": "Bedrijf",
+      "email": "E-mail",
+      "phone": "Telefoon",
+      "address": "Adres",
+      "taxId": "Btw-nummer",
+      "invoiceNumber": "Factuurnr.",
+      "title": "Titel",
+      "type": "Type",
+      "status": "Status",
+      "techName": "Monteur",
+      "totalAmount": "Totaal",
+      "serviceDate": "Datum",
+      "vehicle": "Voertuig",
+      "quoteNumber": "Offertenr.",
+      "validUntil": "Geldig tot",
+      "partNumber": "Onderdeelnr.",
+      "category": "Categorie",
+      "quantity": "Aantal",
+      "minQuantity": "Min. aantal",
+      "unitCost": "Kostprijs",
+      "sellPrice": "Prijs",
+      "supplier": "Leverancier"
+    },
+    "operators": {
+      "contains": "bevat",
+      "equals": "is gelijk aan",
+      "startsWith": "begint met",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "na",
+      "before": "voor"
+    },
+    "options": {
+      "maintenance": "Onderhoud",
+      "repair": "Reparatie",
+      "upgrade": "Upgrade",
+      "inspection": "Inspectie",
+      "pending": "In afwachting",
+      "in-progress": "In uitvoering",
+      "waiting-parts": "Wacht op onderdelen",
+      "completed": "Voltooid",
+      "draft": "Concept",
+      "sent": "Verzonden",
+      "accepted": "Geaccepteerd",
+      "rejected": "Afgewezen",
+      "expired": "Verlopen",
+      "converted": "Omgezet"
+    }
+  }
 }

+ 97 - 2
messages/pl/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Ostatnie powiadomienia",
     "recentCompleted": "Ostatnio ukończone",
     "activeJobs": "Aktywne zlecenia",
-    "recentObservations": "Ostatnie obserwacje"
+    "recentObservations": "Ostatnie obserwacje",
+    "recentActivity": "Ostatnia aktywność"
   },
   "maintenance": {
     "title": "Przewidywany termin konserwacji",
@@ -138,5 +139,99 @@
     "hours": "{count}godz.",
     "days": "{count}d"
   },
-  "error": "Nie udało się załadować pulpitu"
+  "error": "Nie udało się załadować pulpitu",
+  "editHint": "Przeciągaj karty, aby zmienić układ; przeciągnij róg, aby zmienić rozmiar.",
+  "resetLayout": "Przywróć układ",
+  "doneEditing": "Gotowe",
+  "customCards": {
+    "addCard": "Dodaj kartę",
+    "editCard": "Edytuj kartę",
+    "deleteCard": "Usuń kartę",
+    "cardName": "Nazwa karty",
+    "dataSource": "Źródło danych",
+    "filters": "Filtry",
+    "addFilter": "Dodaj filtr",
+    "columns": "Kolumny",
+    "rowLimit": "Wiersze",
+    "save": "Zapisz",
+    "cancel": "Anuluj",
+    "valuePlaceholder": "Wartość…",
+    "noResults": "Brak pasujących rekordów.",
+    "loadError": "Nie udało się wczytać karty.",
+    "saveError": "Nie udało się zapisać karty.",
+    "deleteConfirmTitle": "Usunąć kartę?",
+    "deleteConfirmDescription": "„{name}” zostanie usunięta z pulpitu. Dane nie zostaną naruszone.",
+    "entities": {
+      "vehicles": "Pojazdy",
+      "customers": "Klienci",
+      "workOrders": "Zlecenia",
+      "quotes": "Wyceny",
+      "inventory": "Magazyn"
+    },
+    "fields": {
+      "licensePlate": "Rejestracja",
+      "make": "Marka",
+      "model": "Model",
+      "year": "Rok",
+      "color": "Kolor",
+      "vin": "VIN",
+      "mileage": "Przebieg",
+      "fuelType": "Paliwo",
+      "transmission": "Skrzynia",
+      "customer": "Klient",
+      "createdAt": "Utworzono",
+      "updatedAt": "Zaktualizowano",
+      "name": "Nazwa",
+      "company": "Firma",
+      "email": "E-mail",
+      "phone": "Telefon",
+      "address": "Adres",
+      "taxId": "NIP",
+      "invoiceNumber": "Nr faktury",
+      "title": "Tytuł",
+      "type": "Typ",
+      "status": "Status",
+      "techName": "Mechanik",
+      "totalAmount": "Suma",
+      "serviceDate": "Data",
+      "vehicle": "Pojazd",
+      "quoteNumber": "Nr wyceny",
+      "validUntil": "Ważne do",
+      "partNumber": "Nr części",
+      "category": "Kategoria",
+      "quantity": "Ilość",
+      "minQuantity": "Min. ilość",
+      "unitCost": "Koszt",
+      "sellPrice": "Cena",
+      "supplier": "Dostawca"
+    },
+    "operators": {
+      "contains": "zawiera",
+      "equals": "równa się",
+      "startsWith": "zaczyna się od",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "po",
+      "before": "przed"
+    },
+    "options": {
+      "maintenance": "Przegląd",
+      "repair": "Naprawa",
+      "upgrade": "Modernizacja",
+      "inspection": "Inspekcja",
+      "pending": "Oczekuje",
+      "in-progress": "W trakcie",
+      "waiting-parts": "Czeka na części",
+      "completed": "Zakończone",
+      "draft": "Szkic",
+      "sent": "Wysłano",
+      "accepted": "Zaakceptowano",
+      "rejected": "Odrzucono",
+      "expired": "Wygasła",
+      "converted": "Przekształcona"
+    }
+  }
 }

+ 97 - 2
messages/pt-BR/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Notificações Recentes",
     "recentCompleted": "Concluídos Recentemente",
     "activeJobs": "Trabalhos Ativos",
-    "recentObservations": "Observações recentes"
+    "recentObservations": "Observações recentes",
+    "recentActivity": "Atividade recente"
   },
   "maintenance": {
     "title": "Manutenção Prevista",
@@ -138,5 +139,99 @@
     "hours": "{count}h",
     "days": "{count}d"
   },
-  "error": "Falha ao carregar o painel"
+  "error": "Falha ao carregar o painel",
+  "editHint": "Arraste os cartões para reorganizar; puxe o canto para redimensionar.",
+  "resetLayout": "Redefinir layout",
+  "doneEditing": "Concluído",
+  "customCards": {
+    "addCard": "Adicionar cartão",
+    "editCard": "Editar cartão",
+    "deleteCard": "Excluir cartão",
+    "cardName": "Nome do cartão",
+    "dataSource": "Fonte de dados",
+    "filters": "Filtros",
+    "addFilter": "Adicionar filtro",
+    "columns": "Colunas",
+    "rowLimit": "Linhas",
+    "save": "Salvar",
+    "cancel": "Cancelar",
+    "valuePlaceholder": "Valor…",
+    "noResults": "Nenhum registro correspondente.",
+    "loadError": "Não foi possível carregar o cartão.",
+    "saveError": "Não foi possível salvar o cartão.",
+    "deleteConfirmTitle": "Excluir cartão?",
+    "deleteConfirmDescription": "“{name}” será removido do painel. Os dados não são afetados.",
+    "entities": {
+      "vehicles": "Veículos",
+      "customers": "Clientes",
+      "workOrders": "Ordens de serviço",
+      "quotes": "Orçamentos",
+      "inventory": "Estoque"
+    },
+    "fields": {
+      "licensePlate": "Placa",
+      "make": "Marca",
+      "model": "Modelo",
+      "year": "Ano",
+      "color": "Cor",
+      "vin": "Chassi",
+      "mileage": "Quilometragem",
+      "fuelType": "Combustível",
+      "transmission": "Câmbio",
+      "customer": "Cliente",
+      "createdAt": "Criado",
+      "updatedAt": "Atualizado",
+      "name": "Nome",
+      "company": "Empresa",
+      "email": "E-mail",
+      "phone": "Telefone",
+      "address": "Endereço",
+      "taxId": "CNPJ/CPF",
+      "invoiceNumber": "Nº fatura",
+      "title": "Título",
+      "type": "Tipo",
+      "status": "Status",
+      "techName": "Técnico",
+      "totalAmount": "Total",
+      "serviceDate": "Data",
+      "vehicle": "Veículo",
+      "quoteNumber": "Nº orçamento",
+      "validUntil": "Válido até",
+      "partNumber": "Nº peça",
+      "category": "Categoria",
+      "quantity": "Quantidade",
+      "minQuantity": "Qtd. mínima",
+      "unitCost": "Custo",
+      "sellPrice": "Preço",
+      "supplier": "Fornecedor"
+    },
+    "operators": {
+      "contains": "contém",
+      "equals": "é igual a",
+      "startsWith": "começa com",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "depois de",
+      "before": "antes de"
+    },
+    "options": {
+      "maintenance": "Manutenção",
+      "repair": "Reparo",
+      "upgrade": "Melhoria",
+      "inspection": "Inspeção",
+      "pending": "Pendente",
+      "in-progress": "Em andamento",
+      "waiting-parts": "Aguardando peças",
+      "completed": "Concluído",
+      "draft": "Rascunho",
+      "sent": "Enviado",
+      "accepted": "Aceito",
+      "rejected": "Recusado",
+      "expired": "Expirado",
+      "converted": "Convertido"
+    }
+  }
 }

+ 97 - 2
messages/ru/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Последние уведомления",
     "recentCompleted": "Недавно завершённые",
     "activeJobs": "Активные работы",
-    "recentObservations": "Недавние наблюдения"
+    "recentObservations": "Недавние наблюдения",
+    "recentActivity": "Последняя активность"
   },
   "maintenance": {
     "title": "Прогнозируемое техобслуживание",
@@ -138,5 +139,99 @@
     "hours": "{count}ч",
     "days": "{count}д"
   },
-  "error": "Не удалось загрузить панель управления"
+  "error": "Не удалось загрузить панель управления",
+  "editHint": "Перетаскивайте карточки для изменения порядка, тяните за угол для изменения размера.",
+  "resetLayout": "Сбросить макет",
+  "doneEditing": "Готово",
+  "customCards": {
+    "addCard": "Добавить карточку",
+    "editCard": "Изменить карточку",
+    "deleteCard": "Удалить карточку",
+    "cardName": "Название карточки",
+    "dataSource": "Источник данных",
+    "filters": "Фильтры",
+    "addFilter": "Добавить фильтр",
+    "columns": "Столбцы",
+    "rowLimit": "Строки",
+    "save": "Сохранить",
+    "cancel": "Отмена",
+    "valuePlaceholder": "Значение…",
+    "noResults": "Совпадающих записей нет.",
+    "loadError": "Не удалось загрузить карточку.",
+    "saveError": "Не удалось сохранить карточку.",
+    "deleteConfirmTitle": "Удалить карточку?",
+    "deleteConfirmDescription": "«{name}» будет убрана с панели. Сами данные не затрагиваются.",
+    "entities": {
+      "vehicles": "Автомобили",
+      "customers": "Клиенты",
+      "workOrders": "Заказ-наряды",
+      "quotes": "Предложения",
+      "inventory": "Склад"
+    },
+    "fields": {
+      "licensePlate": "Номер",
+      "make": "Марка",
+      "model": "Модель",
+      "year": "Год",
+      "color": "Цвет",
+      "vin": "VIN",
+      "mileage": "Пробег",
+      "fuelType": "Топливо",
+      "transmission": "КПП",
+      "customer": "Клиент",
+      "createdAt": "Создано",
+      "updatedAt": "Обновлено",
+      "name": "Имя",
+      "company": "Компания",
+      "email": "Эл. почта",
+      "phone": "Телефон",
+      "address": "Адрес",
+      "taxId": "ИНН",
+      "invoiceNumber": "№ счёта",
+      "title": "Название",
+      "type": "Тип",
+      "status": "Статус",
+      "techName": "Механик",
+      "totalAmount": "Сумма",
+      "serviceDate": "Дата",
+      "vehicle": "Автомобиль",
+      "quoteNumber": "№ предложения",
+      "validUntil": "Действует до",
+      "partNumber": "№ детали",
+      "category": "Категория",
+      "quantity": "Кол-во",
+      "minQuantity": "Мин. кол-во",
+      "unitCost": "Себестоимость",
+      "sellPrice": "Цена",
+      "supplier": "Поставщик"
+    },
+    "operators": {
+      "contains": "содержит",
+      "equals": "равно",
+      "startsWith": "начинается с",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "после",
+      "before": "до"
+    },
+    "options": {
+      "maintenance": "ТО",
+      "repair": "Ремонт",
+      "upgrade": "Доработка",
+      "inspection": "Осмотр",
+      "pending": "Ожидает",
+      "in-progress": "В работе",
+      "waiting-parts": "Ожидание запчастей",
+      "completed": "Завершено",
+      "draft": "Черновик",
+      "sent": "Отправлено",
+      "accepted": "Принято",
+      "rejected": "Отклонено",
+      "expired": "Истекло",
+      "converted": "Преобразовано"
+    }
+  }
 }

+ 97 - 2
messages/tr/dashboard.json

@@ -52,7 +52,8 @@
     "notifications": "Son Bildirimler",
     "recentCompleted": "Son Tamamlananlar",
     "activeJobs": "Aktif İşler",
-    "recentObservations": "Son Gözlemler"
+    "recentObservations": "Son Gözlemler",
+    "recentActivity": "Son Etkinlik"
   },
   "maintenance": {
     "title": "Tahmini Bakım Zamanı",
@@ -138,5 +139,99 @@
     "hours": "{count}sa",
     "days": "{count}g"
   },
-  "error": "Kontrol paneli yüklenemedi"
+  "error": "Kontrol paneli yüklenemedi",
+  "editHint": "Kartları sürükleyerek taşıyın, köşeden boyutlandırın.",
+  "resetLayout": "Düzeni Sıfırla",
+  "doneEditing": "Bitti",
+  "customCards": {
+    "addCard": "Kart ekle",
+    "editCard": "Kartı düzenle",
+    "deleteCard": "Kartı sil",
+    "cardName": "Kart adı",
+    "dataSource": "Veri kaynağı",
+    "filters": "Filtreler",
+    "addFilter": "Filtre ekle",
+    "columns": "Sütunlar",
+    "rowLimit": "Satır",
+    "save": "Kaydet",
+    "cancel": "İptal",
+    "valuePlaceholder": "Değer…",
+    "noResults": "Eşleşen kayıt yok.",
+    "loadError": "Kart yüklenemedi.",
+    "saveError": "Kart kaydedilemedi.",
+    "deleteConfirmTitle": "Kart silinsin mi?",
+    "deleteConfirmDescription": "“{name}” panodan kaldırılacak. Veriler etkilenmez.",
+    "entities": {
+      "vehicles": "Araçlar",
+      "customers": "Müşteriler",
+      "workOrders": "İş emirleri",
+      "quotes": "Teklifler",
+      "inventory": "Envanter"
+    },
+    "fields": {
+      "licensePlate": "Plaka",
+      "make": "Marka",
+      "model": "Model",
+      "year": "Yıl",
+      "color": "Renk",
+      "vin": "Şasi no",
+      "mileage": "Kilometre",
+      "fuelType": "Yakıt",
+      "transmission": "Vites",
+      "customer": "Müşteri",
+      "createdAt": "Oluşturuldu",
+      "updatedAt": "Güncellendi",
+      "name": "Ad",
+      "company": "Şirket",
+      "email": "E-posta",
+      "phone": "Telefon",
+      "address": "Adres",
+      "taxId": "Vergi no",
+      "invoiceNumber": "Fatura no",
+      "title": "Başlık",
+      "type": "Tür",
+      "status": "Durum",
+      "techName": "Teknisyen",
+      "totalAmount": "Toplam",
+      "serviceDate": "Tarih",
+      "vehicle": "Araç",
+      "quoteNumber": "Teklif no",
+      "validUntil": "Geçerlilik",
+      "partNumber": "Parça no",
+      "category": "Kategori",
+      "quantity": "Adet",
+      "minQuantity": "Min. adet",
+      "unitCost": "Maliyet",
+      "sellPrice": "Fiyat",
+      "supplier": "Tedarikçi"
+    },
+    "operators": {
+      "contains": "içerir",
+      "equals": "eşittir",
+      "startsWith": "ile başlar",
+      "eq": "=",
+      "gt": ">",
+      "gte": "≥",
+      "lt": "<",
+      "lte": "≤",
+      "after": "sonra",
+      "before": "önce"
+    },
+    "options": {
+      "maintenance": "Bakım",
+      "repair": "Onarım",
+      "upgrade": "Yükseltme",
+      "inspection": "Muayene",
+      "pending": "Beklemede",
+      "in-progress": "Devam ediyor",
+      "waiting-parts": "Parça bekleniyor",
+      "completed": "Tamamlandı",
+      "draft": "Taslak",
+      "sent": "Gönderildi",
+      "accepted": "Kabul edildi",
+      "rejected": "Reddedildi",
+      "expired": "Süresi doldu",
+      "converted": "Dönüştürüldü"
+    }
+  }
 }

+ 69 - 0
package-lock.json

@@ -26,6 +26,7 @@
         "@tiptap/react": "^3.19.0",
         "@tiptap/starter-kit": "^3.19.0",
         "@types/qrcode": "^1.5.6",
+        "@types/react-grid-layout": "^1.3.6",
         "@zumer/snapdom": "^2.23.1",
         "axios": "^1.18.1",
         "better-auth": "^1.6.19",
@@ -57,6 +58,7 @@
         "react": "19.2.3",
         "react-day-picker": "^9.13.2",
         "react-dom": "19.2.3",
+        "react-grid-layout": "^2.2.4",
         "react-hook-form": "^7.71.1",
         "react-markdown": "^10.1.0",
         "react-resizable-panels": "^4.6.4",
@@ -8035,6 +8037,15 @@
         "@types/react": "^19.2.0"
       }
     },
+    "node_modules/@types/react-grid-layout": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz",
+      "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
     "node_modules/@types/statuses": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
@@ -15478,6 +15489,44 @@
         "react": "^19.2.3"
       }
     },
+    "node_modules/react-draggable": {
+      "version": "4.7.1",
+      "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
+      "integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^2.1.1",
+        "prop-types": "^15.8.1"
+      },
+      "peerDependencies": {
+        "react": ">= 16.3.0",
+        "react-dom": ">= 16.3.0"
+      }
+    },
+    "node_modules/react-grid-layout": {
+      "version": "2.2.4",
+      "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
+      "integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
+      "license": "MIT",
+      "dependencies": {
+        "clsx": "^2.1.1",
+        "fast-equals": "^4.0.3",
+        "prop-types": "^15.8.1",
+        "react-draggable": "^4.4.6",
+        "react-resizable": "^3.1.3",
+        "resize-observer-polyfill": "^1.5.1"
+      },
+      "peerDependencies": {
+        "react": ">= 16.3.0",
+        "react-dom": ">= 16.3.0"
+      }
+    },
+    "node_modules/react-grid-layout/node_modules/fast-equals": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
+      "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
+      "license": "MIT"
+    },
     "node_modules/react-hook-form": {
       "version": "7.71.1",
       "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
@@ -15597,6 +15646,20 @@
         }
       }
     },
+    "node_modules/react-resizable": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
+      "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "prop-types": "15.x",
+        "react-draggable": "^4.5.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.3",
+        "react-dom": ">= 16.3"
+      }
+    },
     "node_modules/react-resizable-panels": {
       "version": "4.6.4",
       "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.6.4.tgz",
@@ -15849,6 +15912,12 @@
         }
       }
     },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
+      "license": "MIT"
+    },
     "node_modules/resolve-from": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",

+ 2 - 0
package.json

@@ -34,6 +34,7 @@
     "@tiptap/react": "^3.19.0",
     "@tiptap/starter-kit": "^3.19.0",
     "@types/qrcode": "^1.5.6",
+    "@types/react-grid-layout": "^1.3.6",
     "@zumer/snapdom": "^2.23.1",
     "axios": "^1.18.1",
     "better-auth": "^1.6.19",
@@ -65,6 +66,7 @@
     "react": "19.2.3",
     "react-day-picker": "^9.13.2",
     "react-dom": "19.2.3",
+    "react-grid-layout": "^2.2.4",
     "react-hook-form": "^7.71.1",
     "react-markdown": "^10.1.0",
     "react-resizable-panels": "^4.6.4",

+ 2 - 0
prisma/migrations/20260811164154_user_dashboard_layout/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "dashboardLayout" JSONB;

+ 18 - 0
prisma/migrations/20260811170236_dashboard_widgets/migration.sql

@@ -0,0 +1,18 @@
+-- CreateTable
+CREATE TABLE "dashboard_widgets" (
+    "id" TEXT NOT NULL,
+    "name" TEXT NOT NULL,
+    "config" JSONB NOT NULL,
+    "userId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+
+    CONSTRAINT "dashboard_widgets_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "dashboard_widgets_userId_organizationId_idx" ON "dashboard_widgets"("userId", "organizationId");
+
+-- AddForeignKey
+ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 21 - 0
prisma/schema.prisma

@@ -24,6 +24,9 @@ model User {
   /// App version this user last saw/dismissed the update banner for.
   /// Null = never seeded (seeded silently on first load, no banner).
   lastSeenVersion  String?
+  /// Per-user dashboard grid layout (card positions/sizes/hidden set).
+  /// Null = default layout. Shape validated in dashboardLayoutActions.
+  dashboardLayout  Json?
 
   sessions               Session[]
   accounts               Account[]
@@ -36,6 +39,7 @@ model User {
   twoFactor              TwoFactor?
   passkeys               Passkey[]
   organizationMembers    OrganizationMember[]
+  dashboardWidgets       DashboardWidget[]
   auditLogs              AuditLog[]
   technicians            Technician[]
   stockMovements         StockMovement[]
@@ -711,6 +715,23 @@ model CustomFieldValue {
   @@map("custom_field_values")
 }
 
+/// A user-defined dashboard card: a named, filtered table over one entity.
+/// The config shape (entity, filters, columns, limit) is validated against
+/// the field registry in customCardActions — nothing here reaches SQL raw.
+model DashboardWidget {
+  id             String   @id @default(cuid())
+  name           String
+  config         Json
+  userId         String
+  user           User     @relation(fields: [userId], references: [id], onDelete: Cascade)
+  organizationId String
+  createdAt      DateTime @default(now())
+  updatedAt      DateTime @updatedAt
+
+  @@index([userId, organizationId])
+  @@map("dashboard_widgets")
+}
+
 model Organization {
   id         String   @id @default(cuid())
   name       String

+ 241 - 59
src/app/(authenticated)/dashboard-client.tsx

@@ -36,6 +36,8 @@ import {
   BellRing,
   MessageSquare,
   Settings,
+  Plus,
+  RotateCcw,
   SlidersHorizontal,
   Undo2,
   Users,
@@ -47,7 +49,25 @@ import { updateQuoteRequestStatus } from "@/features/inspections/Actions/quoteRe
 import { acknowledgeQuoteResponse } from "@/features/quotes/Actions/quoteResponseActions";
 import { toast } from "sonner";
 import { convertQuoteToServiceRecord, createQuote } from "@/features/quotes/Actions/quoteActions";
-import { useDashboardVisibility, DASHBOARD_CARD_IDS } from "@/hooks/use-dashboard-visibility";
+import type { ReactNode } from "react";
+import { DashboardGrid } from "@/features/dashboard/Components/DashboardGrid";
+import {
+  saveDashboardLayout,
+  resetDashboardLayout,
+} from "@/features/dashboard/Actions/dashboardLayoutActions";
+import {
+  DASHBOARD_CARD_IDS,
+  DEFAULT_LAYOUT,
+  normalizeLayout,
+  placeAtBottom,
+  type DashboardCardId,
+  type DashboardLayout,
+} from "@/features/dashboard/dashboard-grid-config";
+import { CustomTableCard } from "@/features/dashboard/Components/CustomTableCard";
+import { CustomCardDialog } from "@/features/dashboard/Components/CustomCardDialog";
+import { deleteDashboardWidget } from "@/features/dashboard/Actions/customCardActions";
+import { customCardId, type CustomWidget } from "@/features/dashboard/custom-cards/registry";
+import { useConfirm } from "@/components/confirm-dialog";
 
 interface ServiceItem {
   id: string;
@@ -228,6 +248,8 @@ export function DashboardClient({
   notifications = [],
   recentAuditLogs = [],
   recentObservations = [],
+  initialLayout = null,
+  customWidgets = [],
 }: {
   stats: DashboardStats;
   currencyCode?: string;
@@ -254,6 +276,8 @@ export function DashboardClient({
     user: { id: string; name: string | null; email: string | null } | null;
   }[];
   recentObservations?: DashboardObservation[];
+  initialLayout?: unknown;
+  customWidgets?: CustomWidget[];
 }) {
   const formatCurrency = useFormatCurrency();
   const t = useTranslations("dashboard");
@@ -266,7 +290,80 @@ export function DashboardClient({
   const [maintenanceTab, setMaintenanceTab] = useState<"active" | "dismissed">("active");
   const [restoringId, setRestoringId] = useState<string | null>(null);
   const [, startTransition] = useTransition();
-  const { toggleCard, isVisible, visibleCount, totalCount } = useDashboardVisibility(smsEnabled ? "notifications" : "sms");
+  // Grid layout: per-user, server-persisted. sms and notifications are
+  // mutually exclusive cards, so one of them is never available.
+  const confirm = useConfirm();
+  const [widgets, setWidgets] = useState<CustomWidget[]>(customWidgets);
+  const [layout, setLayout] = useState<DashboardLayout>(() =>
+    normalizeLayout(initialLayout, customWidgets.map((w) => customCardId(w.id)))
+  );
+  const [editing, setEditing] = useState(false);
+  const [cardDialogOpen, setCardDialogOpen] = useState(false);
+  const [editingWidget, setEditingWidget] = useState<CustomWidget | null>(null);
+  const excludedCard: DashboardCardId = smsEnabled ? "notifications" : "sms";
+  const availableIds = DASHBOARD_CARD_IDS.filter((id) => id !== excludedCard);
+  const hiddenSet = new Set<string>(layout.hidden);
+  const visibleIds = [
+    ...availableIds,
+    ...widgets.map((w) => customCardId(w.id)),
+  ].filter((id) => !hiddenSet.has(id));
+
+  const persistLayout = (next: DashboardLayout) => {
+    setLayout(next);
+    void saveDashboardLayout(next);
+  };
+  const commitCards = (cards: DashboardLayout["cards"]) =>
+    persistLayout({ ...layout, cards });
+  const toggleCard = (id: string) =>
+    persistLayout({
+      ...layout,
+      hidden: hiddenSet.has(id)
+        ? layout.hidden.filter((h) => h !== id)
+        : [...layout.hidden, id],
+    });
+  const handleResetLayout = () => {
+    setLayout(normalizeLayout(DEFAULT_LAYOUT, widgets.map((w) => customCardId(w.id))));
+    void resetDashboardLayout();
+  };
+
+  const handleWidgetSaved = (w: CustomWidget) => {
+    setWidgets((prev) =>
+      prev.some((p) => p.id === w.id)
+        ? prev.map((p) => (p.id === w.id ? w : p))
+        : [...prev, w]
+    );
+    const cid = customCardId(w.id);
+    if (!layout.cards[cid]) {
+      persistLayout({
+        ...layout,
+        cards: { ...layout.cards, [cid]: placeAtBottom(layout.cards) },
+      });
+    }
+  };
+
+  const handleDeleteWidget = async (w: CustomWidget) => {
+    const ok = await confirm({
+      title: t("customCards.deleteConfirmTitle"),
+      description: t("customCards.deleteConfirmDescription", { name: w.name }),
+      confirmLabel: t("customCards.deleteCard"),
+      destructive: true,
+    });
+    if (!ok) return;
+    const result = await deleteDashboardWidget(w.id);
+    if (!result.success) {
+      toast.error(result.error || t("customCards.saveError"));
+      return;
+    }
+    setWidgets((prev) => prev.filter((p) => p.id !== w.id));
+    const cid = customCardId(w.id);
+    const cards = { ...layout.cards };
+    delete cards[cid];
+    persistLayout({
+      ...layout,
+      cards,
+      hidden: layout.hidden.filter((h) => h !== cid),
+    });
+  };
 
   const formatRelativeTime = (date: string | Date) => {
     const now = new Date();
@@ -349,36 +446,90 @@ export function DashboardClient({
       </div>
 
       {/* Customize Dashboard */}
-      <div className="flex justify-end">
-        <Popover>
-          <PopoverTrigger asChild>
-            <Button variant="outline" size="sm" className="h-8 text-xs gap-1.5">
-              <SlidersHorizontal className="h-3.5 w-3.5" />
-              {t("customize")} ({visibleCount}/{totalCount})
+      <div className="flex items-center justify-end gap-2">
+        {editing ? (
+          <>
+            <p className="mr-auto hidden text-xs text-muted-foreground sm:block">
+              {t("editHint")}
+            </p>
+            <Popover>
+              <PopoverTrigger asChild>
+                <Button variant="outline" size="sm" className="h-8 text-xs gap-1.5">
+                  <SlidersHorizontal className="h-3.5 w-3.5" />
+                  {t("showCards")} ({visibleIds.length}/{availableIds.length})
+                </Button>
+              </PopoverTrigger>
+              <PopoverContent align="end" className="w-56 p-3">
+                <p className="text-sm font-medium mb-2">{t("showCards")}</p>
+                <div className="space-y-2">
+                  {availableIds.map((id) => (
+                    <label key={id} className="flex items-center justify-between gap-2 cursor-pointer">
+                      <span className="text-sm">{t(`cards.${id}`)}</span>
+                      <Switch
+                        size="sm"
+                        checked={!hiddenSet.has(id)}
+                        onCheckedChange={() => toggleCard(id)}
+                      />
+                    </label>
+                  ))}
+                  {widgets.map((w) => (
+                    <label key={w.id} className="flex items-center justify-between gap-2 cursor-pointer">
+                      <span className="truncate text-sm">{w.name}</span>
+                      <Switch
+                        size="sm"
+                        checked={!hiddenSet.has(customCardId(w.id))}
+                        onCheckedChange={() => toggleCard(customCardId(w.id))}
+                      />
+                    </label>
+                  ))}
+                </div>
+              </PopoverContent>
+            </Popover>
+            <Button
+              variant="outline"
+              size="sm"
+              className="h-8 text-xs gap-1.5"
+              onClick={() => {
+                setEditingWidget(null);
+                setCardDialogOpen(true);
+              }}
+            >
+              <Plus className="h-3.5 w-3.5" />
+              {t("customCards.addCard")}
             </Button>
-          </PopoverTrigger>
-          <PopoverContent align="end" className="w-56 p-3">
-            <p className="text-sm font-medium mb-2">{t("showCards")}</p>
-            <div className="space-y-2">
-              {DASHBOARD_CARD_IDS.filter((id) => smsEnabled ? id !== "notifications" : id !== "sms").map((id) => (
-                <label key={id} className="flex items-center justify-between gap-2 cursor-pointer">
-                  <span className="text-sm">{t(`cards.${id}`)}</span>
-                  <Switch
-                    size="sm"
-                    checked={isVisible(id)}
-                    onCheckedChange={() => toggleCard(id)}
-                  />
-                </label>
-              ))}
-            </div>
-          </PopoverContent>
-        </Popover>
+            <Button
+              variant="outline"
+              size="sm"
+              className="h-8 text-xs gap-1.5"
+              onClick={handleResetLayout}
+            >
+              <RotateCcw className="h-3.5 w-3.5" />
+              {t("resetLayout")}
+            </Button>
+            <Button size="sm" className="h-8 text-xs gap-1.5" onClick={() => setEditing(false)}>
+              <Check className="h-3.5 w-3.5" />
+              {t("doneEditing")}
+            </Button>
+          </>
+        ) : (
+          <Button
+            variant="outline"
+            size="sm"
+            className="h-8 text-xs gap-1.5"
+            onClick={() => setEditing(true)}
+          >
+            <SlidersHorizontal className="h-3.5 w-3.5" />
+            {t("customize")}
+          </Button>
+        )}
       </div>
 
-      {/* Card grid */}
-      <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
-        {/* Vehicles Due for Service */}
-        {isVisible("maintenance") && (
+      {/* Card grid: nodes keyed by card id, rendered by DashboardGrid in the
+          user's saved arrangement */}
+      {(() => {
+        const cardNodes: Partial<Record<string, ReactNode>> = {
+        // Vehicles Due for Service
+        maintenance: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-1">
               <div className="flex items-center justify-between">
@@ -540,10 +691,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Upcoming Reminders */}
-        {isVisible("reminders") && (
+        // Upcoming Reminders
+        reminders: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
@@ -624,10 +775,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* SMS Messages */}
-        {isVisible("sms") && smsEnabled && (
+        // SMS Messages (only offered when SMS is enabled; see availableIds)
+        sms: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
@@ -678,10 +829,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Recent Notifications (shown when SMS is not configured) */}
-        {isVisible("notifications") && !smsEnabled && (
+        // Recent Notifications (only offered when SMS is not configured)
+        notifications: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
@@ -720,10 +871,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Inspections */}
-        {isVisible("inspections") && (
+        // Inspections
+        inspections: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
@@ -834,10 +985,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Quote Requests */}
-        {isVisible("quoteRequests") && (
+        // Quote Requests
+        quoteRequests: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
@@ -960,10 +1111,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Customer Quote Responses */}
-        {isVisible("quoteResponses") && (
+        // Customer Quote Responses
+        quoteResponses: (
           <Card className="border-0 shadow-sm">
             <CardHeader className="pb-3">
               <div className="flex items-center justify-between">
@@ -1083,10 +1234,10 @@ export function DashboardClient({
               )}
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Recent Completed table */}
-        {isVisible("recentCompleted") && (
+        // Recent Completed table
+        recentCompleted: (
           <Card className="border-0 shadow-sm lg:col-span-2">
             <CardHeader className="pb-3">
               <CardTitle className="text-base">{t("recentCompleted.title")}</CardTitle>
@@ -1156,10 +1307,10 @@ export function DashboardClient({
               </Table>
             </CardContent>
           </Card>
-        )}
+        ),
 
-        {/* Active Jobs table */}
-        {isVisible("activeJobs") && (
+        // Active Jobs table
+        activeJobs: (
           <Card className="border-0 shadow-sm lg:col-span-2">
             <CardHeader className="pb-3">
               <CardTitle className="text-base">{t("activeJobsTable.title")}</CardTitle>
@@ -1226,8 +1377,9 @@ export function DashboardClient({
               </Table>
             </CardContent>
           </Card>
-        )}
-        {/* Recent Activity (Audit Logs) */}
+        ),
+        // Recent Activity (Audit Logs)
+        recentActivity: (
         <Card className="border-0 shadow-sm">
           <CardHeader className="pb-1">
             <div className="flex items-center justify-between">
@@ -1286,8 +1438,9 @@ export function DashboardClient({
             )}
           </CardContent>
         </Card>
-        {/* Recent Observations */}
-        {isVisible("recentObservations") && (
+        ),
+        // Recent Observations
+        recentObservations: (
         <Card className="border-0 shadow-sm">
           <CardHeader className="pb-1">
             <div className="flex items-center justify-between">
@@ -1354,8 +1507,37 @@ export function DashboardClient({
             )}
           </CardContent>
         </Card>
-        )}
-      </div>
+        ),
+        };
+        for (const w of widgets) {
+          cardNodes[customCardId(w.id)] = (
+            <CustomTableCard
+              widget={w}
+              editing={editing}
+              onEdit={() => {
+                setEditingWidget(w);
+                setCardDialogOpen(true);
+              }}
+              onDelete={() => handleDeleteWidget(w)}
+            />
+          );
+        }
+        return (
+          <DashboardGrid
+            cards={layout.cards}
+            visibleIds={visibleIds}
+            editing={editing}
+            onCardsCommit={commitCards}
+            cardNodes={cardNodes}
+          />
+        );
+      })()}
+      <CustomCardDialog
+        open={cardDialogOpen}
+        onOpenChange={setCardDialogOpen}
+        widget={editingWidget}
+        onSaved={handleWidgetSaved}
+      />
     </div>
     </TooltipProvider>
   );

+ 7 - 0
src/app/(authenticated)/layout.tsx

@@ -1,6 +1,7 @@
 import { redirect } from "next/navigation";
 import { AppSidebar } from "@/components/app-sidebar";
 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import { cookies } from "next/headers";
 import { SearchCommand } from "@/features/search/Components/SearchCommand";
 import { NotificationInitializer } from "@/features/notifications/Components/NotificationInitializer";
 import { ConfirmProvider } from "@/components/confirm-dialog";
@@ -27,6 +28,11 @@ export default async function DashboardLayout({
   children: React.ReactNode;
 }) {
   const data = await getLayoutData();
+  // Render the sidebar in its saved state from the first paint: without this
+  // the server always renders it expanded, and collapsed-sidebar users get a
+  // ~300px layout shift after hydration (which also mismeasured the
+  // dashboard grid).
+  const sidebarOpen = (await cookies()).get("sidebar_state")?.value !== "false";
 
   if (data.status === "unauthenticated") redirect("/auth/sign-in");
   if (data.status === "no-organization") redirect("/onboarding");
@@ -118,6 +124,7 @@ export default async function DashboardLayout({
       }
     />
     <SidebarProvider
+      defaultOpen={sidebarOpen}
       style={
         {
           "--sidebar-width": "19rem",

+ 14 - 4
src/app/(authenticated)/loading.tsx

@@ -8,13 +8,23 @@ export default function DashboardLoading() {
         <Skeleton className="h-4 w-24" />
       </header>
       <div className="flex flex-1 flex-col gap-4 p-4 pt-0">
-        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+        {/* Quick stats row */}
+        <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
           {Array.from({ length: 4 }).map((_, i) => (
-            <Skeleton key={i} className="h-28 rounded-xl" />
+            <Skeleton key={i} className="h-16 rounded-lg" />
+          ))}
+        </div>
+        {/* Customize toolbar */}
+        <div className="flex justify-end">
+          <Skeleton className="h-8 w-28 rounded-md" />
+        </div>
+        {/* Card grid — mirrors the DashboardGrid pre-mount skeleton so the
+            handoff from route loading to page render is seamless */}
+        <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
+          {Array.from({ length: 4 }).map((_, i) => (
+            <Skeleton key={i} className="h-[480px] rounded-xl" />
           ))}
         </div>
-        <Skeleton className="h-64 rounded-xl" />
-        <Skeleton className="h-48 rounded-xl" />
       </div>
     </div>
   );

+ 23 - 0
src/app/(authenticated)/page.tsx

@@ -14,6 +14,8 @@ import { getRecentAuditLogs } from "@/features/audit/Actions/auditActions";
 import { getRecentObservations } from "@/features/vehicles/Actions/findingActions";
 import { getMyActiveJobs } from "@/features/vehicles/Actions/getMyActiveJobs";
 import { DashboardClient } from "./dashboard-client";
+import { db } from "@/lib/db";
+import { sanitizeConfig, type CustomWidget } from "@/features/dashboard/custom-cards/registry";
 import { MyActiveJobs } from "@/features/vehicles/Components/MyActiveJobs";
 import { PageHeader } from "@/components/page-header";
 
@@ -39,6 +41,25 @@ export default async function DashboardPage() {
     getMyActiveJobs(),
   ]);
 
+  const [layoutUser, widgetRows] = auth
+    ? await Promise.all([
+        db.user.findUnique({
+          where: { id: auth.userId },
+          select: { dashboardLayout: true },
+        }),
+        db.dashboardWidget.findMany({
+          where: { userId: auth.userId, organizationId: auth.organizationId },
+          select: { id: true, name: true, config: true },
+          orderBy: { createdAt: "asc" },
+        }),
+      ])
+    : [null, []];
+
+  const customWidgets: CustomWidget[] = widgetRows.flatMap((w) => {
+    const config = sanitizeConfig(w.config);
+    return config ? [{ id: w.id, name: w.name, config }] : [];
+  });
+
   if (!result.success || !result.data) {
     const t = await getTranslations("dashboard");
     return (
@@ -84,6 +105,8 @@ export default async function DashboardPage() {
           notifications={notifications}
           recentAuditLogs={recentAuditLogs}
           recentObservations={recentObservations}
+          initialLayout={layoutUser?.dashboardLayout ?? null}
+          customWidgets={customWidgets}
         />
       </div>
     </>

+ 70 - 0
src/app/globals.css

@@ -121,6 +121,11 @@
   * {
     @apply border-border outline-ring/50;
   }
+  html {
+    /* Reserve scrollbar space so its appearance never shifts layout width
+       (the dashboard grid measures its container on mount) */
+    scrollbar-gutter: stable;
+  }
   body {
     @apply bg-background text-foreground;
   }
@@ -341,3 +346,68 @@
     border-bottom: none;
   }
 }
+
+/* ─── Dashboard grid (react-grid-layout) ─────────────────────────────── */
+.dashboard-grid .react-grid-item {
+  transition: transform 200ms ease, width 200ms ease, height 200ms ease;
+}
+.dashboard-grid .react-grid-item.react-grid-placeholder {
+  background: color-mix(in oklab, var(--primary) 12%, transparent);
+  border: 2px dashed color-mix(in oklab, var(--primary) 50%, transparent);
+  border-radius: var(--radius, 0.75rem);
+  opacity: 1;
+}
+.dashboard-grid .react-grid-item.react-draggable-dragging,
+.dashboard-grid .react-grid-item.resizing {
+  z-index: 30;
+  transition: none;
+  box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.25);
+}
+/* Replace the faint default resize arrow with a clear corner grip in the
+   theme color; only rendered in edit mode (when resizing is enabled) */
+.dashboard-grid .react-grid-item > .react-resizable-handle {
+  z-index: 20;
+  width: 28px;
+  height: 28px;
+  bottom: 0;
+  right: 0;
+  padding: 0;
+  background-image: none;
+  cursor: se-resize;
+}
+.dashboard-grid .react-grid-item > .react-resizable-handle::after {
+  content: "";
+  position: absolute;
+  right: 7px;
+  bottom: 7px;
+  width: 13px;
+  height: 13px;
+  border-right: 3px solid var(--primary);
+  border-bottom: 3px solid var(--primary);
+  border-bottom-right-radius: 5px;
+  opacity: 0.9;
+  transition: transform 120ms ease, opacity 120ms ease;
+}
+.dashboard-grid .react-grid-item > .react-resizable-handle:hover::after,
+.dashboard-grid .react-grid-item.resizing > .react-resizable-handle::after {
+  transform: scale(1.25);
+  opacity: 1;
+}
+.dashboard-grid-editing .react-grid-item {
+  user-select: none;
+}
+/* Cards fill their grid tile; the last section (CardContent) scrolls */
+.dashboard-card-wrap {
+  height: 100%;
+}
+.dashboard-card-wrap > *:first-child {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+.dashboard-card-wrap > *:first-child > *:last-child {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+}

+ 2 - 0
src/app/layout.tsx

@@ -10,6 +10,8 @@ import { TooltipProvider } from '@/components/ui/tooltip'
 import { PWAServiceWorker } from '@/components/pwa-service-worker'
 import { PostHogProvider } from '@/components/posthog-provider'
 import { isCloudMode } from '@/lib/features'
+import 'react-grid-layout/css/styles.css'
+import 'react-resizable/css/styles.css'
 import './globals.css'
 
 const geistSans = Geist({

+ 105 - 0
src/features/dashboard/Actions/customCardActions.ts

@@ -0,0 +1,105 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
+import { withAuth } from "@/lib/with-auth";
+import { PermissionAction } from "@/lib/permissions";
+import { revalidatePath } from "next/cache";
+import { sanitizeConfig, type CustomWidget } from "../custom-cards/registry";
+import {
+  ENTITY_PERMISSION_SUBJECT,
+  runEntityQuery,
+  type CardRow,
+} from "../custom-cards/server-registry";
+
+const MAX_WIDGETS = 20;
+
+function cleanName(name: unknown): string {
+  const title = typeof name === "string" ? name.trim().slice(0, 60) : "";
+  if (!title) throw new Error("Name is required");
+  return title;
+}
+
+export async function createDashboardWidget(name: unknown, config: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const clean = sanitizeConfig(config);
+    if (!clean) throw new Error("Invalid card configuration");
+    const title = cleanName(name);
+
+    const count = await db.dashboardWidget.count({ where: { userId, organizationId } });
+    if (count >= MAX_WIDGETS) throw new Error("Card limit reached");
+
+    const widget = await db.dashboardWidget.create({
+      data: {
+        name: title,
+        config: clean as unknown as Prisma.InputJsonValue,
+        userId,
+        organizationId,
+      },
+    });
+    revalidatePath("/");
+    return { id: widget.id, name: widget.name, config: clean } satisfies CustomWidget;
+  });
+}
+
+export async function updateDashboardWidget(id: string, name: unknown, config: unknown) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const clean = sanitizeConfig(config);
+    if (!clean) throw new Error("Invalid card configuration");
+    const title = cleanName(name);
+
+    const result = await db.dashboardWidget.updateMany({
+      where: { id, userId, organizationId },
+      data: { name: title, config: clean as unknown as Prisma.InputJsonValue },
+    });
+    if (result.count === 0) throw new Error("Card not found");
+    revalidatePath("/");
+    return { id, name: title, config: clean } satisfies CustomWidget;
+  });
+}
+
+export async function deleteDashboardWidget(id: string) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const result = await db.dashboardWidget.deleteMany({
+      where: { id, userId, organizationId },
+    });
+    if (result.count === 0) throw new Error("Card not found");
+    revalidatePath("/");
+    return { id };
+  });
+}
+
+/**
+ * Executes a widget's query. The widget must belong to the calling user in
+ * their current organization; the inner withAuth enforces the READ
+ * permission for the widget's entity, so a user who loses access to e.g.
+ * inventory gets an error rather than data.
+ */
+export async function runDashboardWidget(id: string) {
+  return withAuth(async ({ userId, organizationId }) => {
+    const widget = await db.dashboardWidget.findFirst({
+      where: { id, userId, organizationId },
+      select: { config: true },
+    });
+    if (!widget) throw new Error("Card not found");
+
+    const config = sanitizeConfig(widget.config);
+    if (!config) throw new Error("Invalid card configuration");
+
+    const result = await withAuth(
+      async ({ organizationId: orgId }) => runEntityQuery(config, orgId),
+      {
+        requiredPermissions: [
+          {
+            action: PermissionAction.READ,
+            subject: ENTITY_PERMISSION_SUBJECT[config.entity],
+          },
+        ],
+      }
+    );
+    if (!result.success || !result.data) {
+      throw new Error(result.error || "Failed to run card");
+    }
+    return result.data satisfies CardRow[];
+  });
+}

+ 41 - 0
src/features/dashboard/Actions/dashboardLayoutActions.ts

@@ -0,0 +1,41 @@
+"use server";
+
+import { db } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
+import { getCachedSession } from "@/lib/cached-session";
+import { normalizeLayout } from "../dashboard-grid-config";
+import { customCardId } from "../custom-cards/registry";
+
+/**
+ * Persists the current user's dashboard layout. Input is normalized against
+ * the known card set and grid bounds, so arbitrary payloads can never store
+ * anything but a valid layout. Session-scoped: the layout is a property of
+ * the user, not the organization.
+ */
+export async function saveDashboardLayout(input: unknown) {
+  const session = await getCachedSession();
+  if (!session?.user?.id) return { success: false };
+
+  const widgets = await db.dashboardWidget.findMany({
+    where: { userId: session.user.id },
+    select: { id: true },
+  });
+  const layout = normalizeLayout(input, widgets.map((w) => customCardId(w.id)));
+  await db.user.update({
+    where: { id: session.user.id },
+    data: { dashboardLayout: layout as unknown as Prisma.InputJsonValue },
+  });
+  return { success: true };
+}
+
+/** Resets the current user's dashboard to the default layout. */
+export async function resetDashboardLayout() {
+  const session = await getCachedSession();
+  if (!session?.user?.id) return { success: false };
+
+  await db.user.update({
+    where: { id: session.user.id },
+    data: { dashboardLayout: Prisma.DbNull },
+  });
+  return { success: true };
+}

+ 324 - 0
src/features/dashboard/Components/CustomCardDialog.tsx

@@ -0,0 +1,324 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { DateInput } from "@/components/ui/date-input";
+import { Loader2, Plus, X } from "lucide-react";
+import {
+  CARD_ENTITIES,
+  CARD_ENTITY_FIELDS,
+  OPERATORS_BY_TYPE,
+  getField,
+  type CardEntity,
+  type CardFilter,
+  type CustomWidget,
+} from "../custom-cards/registry";
+import {
+  createDashboardWidget,
+  updateDashboardWidget,
+} from "../Actions/customCardActions";
+
+/**
+ * Create/edit dialog for custom table cards: pick a data source, stack
+ * filters (field / operator / type-aware value input), choose columns and a
+ * row limit. Everything offered here comes from the field registry, which
+ * the server validates again on save and on every run.
+ */
+export function CustomCardDialog({
+  open,
+  onOpenChange,
+  widget,
+  onSaved,
+}: {
+  open: boolean;
+  onOpenChange: (open: boolean) => void;
+  /** When set, the dialog edits this widget instead of creating one */
+  widget?: CustomWidget | null;
+  onSaved: (widget: CustomWidget) => void;
+}) {
+  const t = useTranslations("dashboard.customCards");
+  const [name, setName] = useState("");
+  const [entity, setEntity] = useState<CardEntity>("vehicles");
+  const [filters, setFilters] = useState<CardFilter[]>([]);
+  const [columns, setColumns] = useState<string[]>([]);
+  const [limit, setLimit] = useState(10);
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    if (!open) return;
+    if (widget) {
+      setName(widget.name);
+      setEntity(widget.config.entity);
+      setFilters(widget.config.filters);
+      setColumns(widget.config.columns);
+      setLimit(widget.config.limit);
+    } else {
+      setName("");
+      setEntity("vehicles");
+      setFilters([]);
+      setColumns(CARD_ENTITY_FIELDS.vehicles.slice(0, 4).map((f) => f.id));
+      setLimit(10);
+    }
+  }, [open, widget]);
+
+  const fields = CARD_ENTITY_FIELDS[entity];
+
+  const changeEntity = (next: CardEntity) => {
+    setEntity(next);
+    setFilters([]);
+    setColumns(CARD_ENTITY_FIELDS[next].slice(0, 4).map((f) => f.id));
+  };
+
+  const addFilter = () => {
+    const first = fields.find((f) => f.filterable !== false);
+    if (!first) return;
+    setFilters([
+      ...filters,
+      { field: first.id, operator: OPERATORS_BY_TYPE[first.type][0], value: "" },
+    ]);
+  };
+
+  const updateFilter = (index: number, patch: Partial<CardFilter>) => {
+    setFilters(filters.map((flt, i) => {
+      if (i !== index) return flt;
+      const next = { ...flt, ...patch };
+      // Field change resets operator and value to something valid for the type
+      if (patch.field) {
+        const def = getField(entity, patch.field);
+        next.operator = def ? OPERATORS_BY_TYPE[def.type][0] : "contains";
+        next.value = "";
+      }
+      return next;
+    }));
+  };
+
+  const toggleColumn = (id: string) => {
+    setColumns((prev) =>
+      prev.includes(id)
+        ? prev.filter((c) => c !== id)
+        : prev.length < 8
+          ? [...prev, id]
+          : prev
+    );
+  };
+
+  const handleSave = async () => {
+    if (!name.trim() || columns.length === 0) return;
+    setSaving(true);
+    const config = {
+      entity,
+      filters: filters.filter((f) => f.value.trim() !== ""),
+      columns,
+      limit,
+    };
+    const result = widget
+      ? await updateDashboardWidget(widget.id, name.trim(), config)
+      : await createDashboardWidget(name.trim(), config);
+    setSaving(false);
+    if (result.success && result.data) {
+      onSaved(result.data);
+      onOpenChange(false);
+    } else {
+      toast.error(result.error || t("saveError"));
+    }
+  };
+
+  const valueInput = (filter: CardFilter, index: number) => {
+    const def = getField(entity, filter.field);
+    if (!def) return null;
+    if (def.type === "date") {
+      return (
+        <DateInput
+          value={filter.value}
+          onChange={(v) => updateFilter(index, { value: v })}
+          className="h-9"
+        />
+      );
+    }
+    if (def.type === "select") {
+      return (
+        <Select
+          value={filter.value || undefined}
+          onValueChange={(v) => updateFilter(index, { value: v })}
+        >
+          <SelectTrigger className="h-9 w-full">
+            <SelectValue placeholder={t("valuePlaceholder")} />
+          </SelectTrigger>
+          <SelectContent>
+            {def.options?.map((opt) => (
+              <SelectItem key={opt} value={opt}>
+                {t(`options.${opt}`)}
+              </SelectItem>
+            ))}
+          </SelectContent>
+        </Select>
+      );
+    }
+    return (
+      <Input
+        type={def.type === "number" ? "number" : "text"}
+        value={filter.value}
+        onChange={(e) => updateFilter(index, { value: e.target.value })}
+        placeholder={t("valuePlaceholder")}
+        className="h-9"
+      />
+    );
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl" aria-describedby={undefined}>
+        <DialogHeader>
+          <DialogTitle>{widget ? t("editCard") : t("addCard")}</DialogTitle>
+        </DialogHeader>
+
+        <div className="space-y-5">
+          <div className="grid gap-4 sm:grid-cols-2">
+            <div className="space-y-2">
+              <Label htmlFor="card-name">{t("cardName")}</Label>
+              <Input
+                id="card-name"
+                value={name}
+                onChange={(e) => setName(e.target.value)}
+                maxLength={60}
+                autoFocus
+              />
+            </div>
+            <div className="space-y-2">
+              <Label>{t("dataSource")}</Label>
+              <Select value={entity} onValueChange={(v) => changeEntity(v as CardEntity)}>
+                <SelectTrigger className="w-full">
+                  <SelectValue />
+                </SelectTrigger>
+                <SelectContent>
+                  {CARD_ENTITIES.map((e) => (
+                    <SelectItem key={e} value={e}>
+                      {t(`entities.${e}`)}
+                    </SelectItem>
+                  ))}
+                </SelectContent>
+              </Select>
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label>{t("filters")}</Label>
+            {filters.map((filter, index) => {
+              const def = getField(entity, filter.field);
+              return (
+                <div key={index} className="flex items-center gap-2">
+                  <Select
+                    value={filter.field}
+                    onValueChange={(v) => updateFilter(index, { field: v })}
+                  >
+                    <SelectTrigger className="h-9 w-[34%]">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {fields
+                        .filter((f) => f.filterable !== false)
+                        .map((f) => (
+                          <SelectItem key={f.id} value={f.id}>
+                            {t(`fields.${f.labelKey}`)}
+                          </SelectItem>
+                        ))}
+                    </SelectContent>
+                  </Select>
+                  <Select
+                    value={filter.operator}
+                    onValueChange={(v) => updateFilter(index, { operator: v })}
+                  >
+                    <SelectTrigger className="h-9 w-[26%]">
+                      <SelectValue />
+                    </SelectTrigger>
+                    <SelectContent>
+                      {(def ? OPERATORS_BY_TYPE[def.type] : []).map((op) => (
+                        <SelectItem key={op} value={op}>
+                          {t(`operators.${op}`)}
+                        </SelectItem>
+                      ))}
+                    </SelectContent>
+                  </Select>
+                  <div className="flex-1">{valueInput(filter, index)}</div>
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    className="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
+                    onClick={() => setFilters(filters.filter((_, i) => i !== index))}
+                  >
+                    <X className="h-4 w-4" />
+                  </Button>
+                </div>
+              );
+            })}
+            {filters.length < 8 && (
+              <Button variant="outline" size="sm" className="h-8 text-xs" onClick={addFilter}>
+                <Plus className="mr-1 h-3.5 w-3.5" />
+                {t("addFilter")}
+              </Button>
+            )}
+          </div>
+
+          <div className="space-y-2">
+            <Label>{t("columns")}</Label>
+            <div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3">
+              {fields.map((f) => (
+                <label key={f.id} className="flex cursor-pointer items-center gap-2 text-sm">
+                  <Checkbox
+                    checked={columns.includes(f.id)}
+                    onCheckedChange={() => toggleColumn(f.id)}
+                  />
+                  {t(`fields.${f.labelKey}`)}
+                </label>
+              ))}
+            </div>
+          </div>
+
+          <div className="space-y-2">
+            <Label>{t("rowLimit")}</Label>
+            <Select value={String(limit)} onValueChange={(v) => setLimit(Number(v))}>
+              <SelectTrigger className="w-32">
+                <SelectValue />
+              </SelectTrigger>
+              <SelectContent>
+                {[5, 10, 25].map((n) => (
+                  <SelectItem key={n} value={String(n)}>
+                    {n}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+          </div>
+
+          <div className="flex justify-end gap-3 pt-2">
+            <Button variant="outline" onClick={() => onOpenChange(false)}>
+              {t("cancel")}
+            </Button>
+            <Button onClick={handleSave} disabled={saving || !name.trim() || columns.length === 0}>
+              {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+              {t("save")}
+            </Button>
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 146 - 0
src/features/dashboard/Components/CustomTableCard.tsx

@@ -0,0 +1,146 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { Loader2, Pencil, Table2, Trash2 } from "lucide-react";
+import { useFormatDate } from "@/lib/use-format-date";
+import { getField, type CustomWidget } from "../custom-cards/registry";
+import { runDashboardWidget } from "../Actions/customCardActions";
+import type { CardRow } from "../custom-cards/server-registry";
+
+/**
+ * Renders one user-defined table card. Rows are fetched lazily through the
+ * widget's server action; in edit mode the header exposes edit/delete
+ * controls that sit above the drag overlay.
+ */
+export function CustomTableCard({
+  widget,
+  editing,
+  onEdit,
+  onDelete,
+}: {
+  widget: CustomWidget;
+  editing: boolean;
+  onEdit: () => void;
+  onDelete: () => void;
+}) {
+  const t = useTranslations("dashboard.customCards");
+  const router = useRouter();
+  const { formatDate } = useFormatDate();
+  const [rows, setRows] = useState<CardRow[] | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  const configKey = JSON.stringify(widget.config);
+  useEffect(() => {
+    let cancelled = false;
+    setRows(null);
+    setError(null);
+    runDashboardWidget(widget.id).then((result) => {
+      if (cancelled) return;
+      if (result.success && result.data) setRows(result.data);
+      else setError(result.error || t("loadError"));
+    });
+    return () => {
+      cancelled = true;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [widget.id, configKey]);
+
+  const { entity, columns } = widget.config;
+
+  const renderCell = (fieldId: string, value: string | number | null) => {
+    if (value === null || value === "") return "-";
+    const def = getField(entity, fieldId);
+    if (def?.type === "date" && typeof value === "string") {
+      return formatDate(new Date(value));
+    }
+    if (def?.type === "select" && typeof value === "string") {
+      return t(`options.${value}`);
+    }
+    return value;
+  };
+
+  return (
+    <Card className="border-0 shadow-sm">
+      <CardHeader className="pb-3">
+        <div className="flex items-center justify-between">
+          <CardTitle className="flex items-center gap-2 text-base">
+            <Table2 className="h-4 w-4" />
+            {widget.name}
+          </CardTitle>
+          {editing && (
+            <div className="dashboard-no-drag relative z-20 flex items-center gap-1">
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-7 w-7"
+                onClick={onEdit}
+              >
+                <Pencil className="h-3.5 w-3.5" />
+                <span className="sr-only">{t("editCard")}</span>
+              </Button>
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-7 w-7 text-muted-foreground hover:text-destructive"
+                onClick={onDelete}
+              >
+                <Trash2 className="h-3.5 w-3.5" />
+                <span className="sr-only">{t("deleteCard")}</span>
+              </Button>
+            </div>
+          )}
+        </div>
+      </CardHeader>
+      <CardContent className="pt-0">
+        {error ? (
+          <p className="py-6 text-center text-sm text-muted-foreground">{error}</p>
+        ) : rows === null ? (
+          <div className="flex justify-center py-6">
+            <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
+          </div>
+        ) : rows.length === 0 ? (
+          <p className="py-6 text-center text-sm text-muted-foreground">{t("noResults")}</p>
+        ) : (
+          <Table>
+            <TableHeader>
+              <TableRow>
+                {columns.map((col) => (
+                  <TableHead key={col} className="text-xs">
+                    {t(`fields.${getField(entity, col)?.labelKey ?? col}`)}
+                  </TableHead>
+                ))}
+              </TableRow>
+            </TableHeader>
+            <TableBody>
+              {rows.map((row) => (
+                <TableRow
+                  key={row.id}
+                  className="cursor-pointer"
+                  onClick={() => router.push(row.href)}
+                >
+                  {columns.map((col) => (
+                    <TableCell key={col} className="truncate text-sm">
+                      {renderCell(col, row.cells[col] ?? null)}
+                    </TableCell>
+                  ))}
+                </TableRow>
+              ))}
+            </TableBody>
+          </Table>
+        )}
+      </CardContent>
+    </Card>
+  );
+}

+ 117 - 0
src/features/dashboard/Components/DashboardGrid.tsx

@@ -0,0 +1,117 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ReactNode } from "react";
+import { Responsive, useContainerWidth, type Layout } from "react-grid-layout";
+import { cn } from "@/lib/utils";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+  CARD_MIN_H,
+  CARD_MIN_W,
+  GRID_COLS,
+  GRID_MARGIN,
+  GRID_ROW_HEIGHT,
+  type CardLayout,
+} from "../dashboard-grid-config";
+
+/**
+ * The dashboard's 12-column drag/resize grid (react-grid-layout v2).
+ * Purely presentational: card positions come in via `cards`, commits go out
+ * through `onCardsCommit` when the user rearranges or resizes in edit mode.
+ * Below the lg breakpoint the grid renders a compacted single column and is
+ * never interactive.
+ */
+export function DashboardGrid({
+  cards,
+  visibleIds,
+  editing,
+  onCardsCommit,
+  cardNodes,
+}: {
+  cards: Record<string, CardLayout>;
+  visibleIds: string[];
+  editing: boolean;
+  onCardsCommit: (cards: Record<string, CardLayout>) => void;
+  cardNodes: Partial<Record<string, ReactNode>>;
+}) {
+  const { width, containerRef, mounted } = useContainerWidth();
+  const [breakpoint, setBreakpoint] = useState("lg");
+  // The container width keeps moving briefly after mount (sidebar mount /
+  // collapse transition, scrollbar appearance). The grid stays invisible and
+  // unanimated until the measured width has been stable for a beat, then
+  // fades in at its final layout — `width` in the deps restarts the timer on
+  // every change, and once ready it stays ready.
+  const [ready, setReady] = useState(false);
+  useEffect(() => {
+    if (!mounted || ready) return;
+    const timer = setTimeout(() => setReady(true), 300);
+    return () => clearTimeout(timer);
+  }, [mounted, width, ready]);
+  const interactive = editing && breakpoint === "lg";
+
+  const layout: Layout = visibleIds.map((id) => ({
+    i: id,
+    ...cards[id],
+    minW: CARD_MIN_W,
+    minH: CARD_MIN_H,
+  }));
+
+  const handleLayoutChange = (next: Layout) => {
+    // Only user edits on the full grid are persisted; the generated
+    // single-column layout on small screens never overwrites saved positions.
+    if (!editing || breakpoint !== "lg") return;
+    const merged = { ...cards };
+    let changed = false;
+    for (const item of next) {
+      const id = item.i;
+      const prev = merged[id];
+      if (!prev) continue;
+      if (prev.x !== item.x || prev.y !== item.y || prev.w !== item.w || prev.h !== item.h) {
+        merged[id] = { x: item.x, y: item.y, w: item.w, h: item.h };
+        changed = true;
+      }
+    }
+    if (changed) onCardsCommit(merged);
+  };
+
+  return (
+    <div ref={containerRef}>
+      {!ready && (
+        // Placeholder mirroring the default two-column card layout while the
+        // container width settles; the real grid mounts only at stable width
+        <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
+          {Array.from({ length: 4 }).map((_, i) => (
+            <Skeleton key={i} className="h-[480px] rounded-xl" />
+          ))}
+        </div>
+      )}
+      {ready && (
+        <Responsive
+          className={cn("dashboard-grid animate-in fade-in duration-200", editing && "dashboard-grid-editing")}
+          width={width}
+          layouts={{ lg: layout }}
+          breakpoints={{ lg: 900, xs: 0 }}
+          cols={{ lg: GRID_COLS, xs: 1 }}
+          rowHeight={GRID_ROW_HEIGHT}
+          margin={GRID_MARGIN}
+          containerPadding={[0, 0]}
+          dragConfig={{ enabled: interactive, cancel: ".dashboard-no-drag" }}
+          resizeConfig={{ enabled: interactive, handles: ["se"] }}
+          onBreakpointChange={(bp) => setBreakpoint(bp)}
+          onLayoutChange={handleLayoutChange}
+        >
+          {visibleIds.map((id) => (
+            <div key={id} className="dashboard-card-wrap relative">
+              {cardNodes[id]}
+              {editing && (
+                // Wiggle-mode overlay: makes the whole tile the drag surface
+                // and shields the card's own links/buttons while rearranging
+                <div className="absolute inset-0 z-10 cursor-grab rounded-xl ring-2 ring-primary/40 transition-shadow hover:ring-primary active:cursor-grabbing" />
+              )}
+            </div>
+          ))}
+        </Responsive>
+      )}
+    </div>
+  );
+}

+ 177 - 0
src/features/dashboard/custom-cards/registry.ts

@@ -0,0 +1,177 @@
+/**
+ * Custom card field registry — the closed vocabulary for user-defined
+ * dashboard tables. Users can only reference entities, fields and operators
+ * listed here; anything else is rejected server-side. This file is
+ * client-safe metadata; the Prisma mapping lives in server-registry.ts.
+ */
+
+export const CARD_ENTITIES = [
+  "vehicles",
+  "customers",
+  "workOrders",
+  "quotes",
+  "inventory",
+] as const;
+
+export type CardEntity = (typeof CARD_ENTITIES)[number];
+
+export type FieldType = "text" | "number" | "date" | "select";
+
+export const OPERATORS_BY_TYPE: Record<FieldType, readonly string[]> = {
+  text: ["contains", "equals", "startsWith"],
+  number: ["eq", "gt", "gte", "lt", "lte"],
+  date: ["after", "before"],
+  select: ["eq"],
+};
+
+export interface CardFieldDef {
+  id: string;
+  /** Key under dashboard.customCards.fields.* */
+  labelKey: string;
+  type: FieldType;
+  /** For select fields: allowed values (translated via labelKey.<value>) */
+  options?: readonly string[];
+  /** Fields that make poor filter targets can be display-only */
+  filterable?: boolean;
+}
+
+const f = (
+  id: string,
+  type: FieldType,
+  opts?: { options?: readonly string[]; filterable?: boolean }
+): CardFieldDef => ({
+  id,
+  labelKey: id,
+  type,
+  options: opts?.options,
+  filterable: opts?.filterable ?? true,
+});
+
+export const CARD_ENTITY_FIELDS: Record<CardEntity, CardFieldDef[]> = {
+  vehicles: [
+    f("licensePlate", "text"),
+    f("make", "text"),
+    f("model", "text"),
+    f("year", "number"),
+    f("color", "text"),
+    f("vin", "text"),
+    f("mileage", "number"),
+    f("fuelType", "text"),
+    f("transmission", "text"),
+    f("customer", "text"),
+    f("createdAt", "date"),
+    f("updatedAt", "date"),
+  ],
+  customers: [
+    f("name", "text"),
+    f("company", "text"),
+    f("email", "text"),
+    f("phone", "text"),
+    f("address", "text"),
+    f("taxId", "text"),
+    f("createdAt", "date"),
+  ],
+  workOrders: [
+    f("invoiceNumber", "text"),
+    f("title", "text"),
+    f("type", "select", {
+      options: ["maintenance", "repair", "upgrade", "inspection"],
+    }),
+    f("status", "select", {
+      options: ["pending", "in-progress", "waiting-parts", "completed"],
+    }),
+    f("techName", "text"),
+    f("totalAmount", "number"),
+    f("serviceDate", "date"),
+    f("vehicle", "text"),
+    f("customer", "text"),
+  ],
+  quotes: [
+    f("quoteNumber", "text"),
+    f("title", "text"),
+    f("status", "select", {
+      options: ["draft", "sent", "accepted", "rejected", "expired", "converted"],
+    }),
+    f("totalAmount", "number"),
+    f("validUntil", "date"),
+    f("createdAt", "date"),
+    f("customer", "text"),
+    f("vehicle", "text"),
+  ],
+  inventory: [
+    f("name", "text"),
+    f("partNumber", "text"),
+    f("category", "text"),
+    f("quantity", "number"),
+    f("minQuantity", "number"),
+    f("unitCost", "number"),
+    f("sellPrice", "number"),
+    f("supplier", "text"),
+  ],
+};
+
+export interface CardFilter {
+  field: string;
+  operator: string;
+  value: string;
+}
+
+export interface CustomCardConfig {
+  entity: CardEntity;
+  filters: CardFilter[];
+  columns: string[];
+  limit: number;
+}
+
+export interface CustomWidget {
+  id: string;
+  name: string;
+  config: CustomCardConfig;
+}
+
+export const CUSTOM_CARD_PREFIX = "custom:";
+
+export function customCardId(widgetId: string): string {
+  return `${CUSTOM_CARD_PREFIX}${widgetId}`;
+}
+
+export function getField(entity: CardEntity, fieldId: string): CardFieldDef | undefined {
+  return CARD_ENTITY_FIELDS[entity]?.find((fd) => fd.id === fieldId);
+}
+
+/** Validates a raw config against the registry. Returns null when invalid. */
+export function sanitizeConfig(raw: unknown): CustomCardConfig | null {
+  if (!raw || typeof raw !== "object") return null;
+  const c = raw as Record<string, unknown>;
+  const entity = c.entity as CardEntity;
+  if (!CARD_ENTITIES.includes(entity)) return null;
+
+  const fields = CARD_ENTITY_FIELDS[entity];
+  const fieldIds = new Set(fields.map((fd) => fd.id));
+
+  const columns = Array.isArray(c.columns)
+    ? (c.columns.filter((col): col is string => typeof col === "string" && fieldIds.has(col)).slice(0, 8))
+    : [];
+  if (columns.length === 0) return null;
+
+  const filters: CardFilter[] = [];
+  if (Array.isArray(c.filters)) {
+    for (const rawFilter of c.filters.slice(0, 8)) {
+      if (!rawFilter || typeof rawFilter !== "object") continue;
+      const { field, operator, value } = rawFilter as Record<string, unknown>;
+      if (typeof field !== "string" || typeof operator !== "string" || typeof value !== "string") continue;
+      const def = getField(entity, field);
+      if (!def || def.filterable === false) continue;
+      if (!OPERATORS_BY_TYPE[def.type].includes(operator)) continue;
+      if (def.type === "select" && !def.options?.includes(value)) continue;
+      if (value.length === 0 || value.length > 200) continue;
+      filters.push({ field, operator, value });
+    }
+  }
+
+  const limit = typeof c.limit === "number" && Number.isFinite(c.limit)
+    ? Math.max(1, Math.min(25, Math.round(c.limit)))
+    : 10;
+
+  return { entity, filters, columns, limit };
+}

+ 246 - 0
src/features/dashboard/custom-cards/server-registry.ts

@@ -0,0 +1,246 @@
+import { db } from "@/lib/db";
+import { toSafeDate } from "@/lib/invoice-utils";
+import { PermissionSubject } from "@/lib/permissions";
+import type { CardEntity, CardFilter, CustomCardConfig } from "./registry";
+import { getField } from "./registry";
+
+/**
+ * Server-side execution for custom cards. Every query is built exclusively
+ * from this file's mappings via Prisma's parameterized query builder — user
+ * input only ever appears as condition *values*, never as query structure.
+ * All queries are read-only findMany calls scoped to the caller's org.
+ */
+
+export interface CardRow {
+  id: string;
+  href: string;
+  cells: Record<string, string | number | null>;
+}
+
+export const ENTITY_PERMISSION_SUBJECT: Record<CardEntity, PermissionSubject> = {
+  vehicles: PermissionSubject.VEHICLES,
+  customers: PermissionSubject.CUSTOMERS,
+  workOrders: PermissionSubject.SERVICES,
+  quotes: PermissionSubject.QUOTES,
+  inventory: PermissionSubject.INVENTORY,
+};
+
+type Cond = Record<string, unknown>;
+
+function textCond(operator: string, value: string): Cond | null {
+  switch (operator) {
+    case "contains": return { contains: value, mode: "insensitive" };
+    case "equals": return { equals: value, mode: "insensitive" };
+    case "startsWith": return { startsWith: value, mode: "insensitive" };
+    default: return null;
+  }
+}
+
+function numberCond(operator: string, value: string): Cond | null {
+  const n = Number(value);
+  if (!Number.isFinite(n)) return null;
+  switch (operator) {
+    case "eq": return { equals: n };
+    case "gt": return { gt: n };
+    case "gte": return { gte: n };
+    case "lt": return { lt: n };
+    case "lte": return { lte: n };
+    default: return null;
+  }
+}
+
+function dateCond(operator: string, value: string): Cond | null {
+  const d = toSafeDate(value);
+  if (!d) return null;
+  return operator === "after" ? { gte: d } : operator === "before" ? { lte: d } : null;
+}
+
+function condFor(entity: CardEntity, filter: CardFilter): Cond | null {
+  const def = getField(entity, filter.field);
+  if (!def) return null;
+  switch (def.type) {
+    case "text": return textCond(filter.operator, filter.value);
+    case "number": return numberCond(filter.operator, filter.value);
+    case "date": return dateCond(filter.operator, filter.value);
+    case "select":
+      return filter.operator === "eq" && def.options?.includes(filter.value)
+        ? { equals: filter.value }
+        : null;
+  }
+}
+
+/** "vehicle" pseudo-field: matches make, model or plate with the same op */
+function vehicleTextWhere(operator: string, value: string): Cond | null {
+  const cond = textCond(operator, value);
+  if (!cond) return null;
+  return { OR: [{ make: cond }, { model: cond }, { licensePlate: cond }] };
+}
+
+/** Maps a validated filter to a Prisma where fragment, per entity. */
+function whereFor(entity: CardEntity, filter: CardFilter): Cond | null {
+  const cond = condFor(entity, filter);
+  if (!cond) return null;
+  const field = filter.field;
+
+  switch (entity) {
+    case "vehicles":
+      if (field === "customer") return { customer: { name: cond } };
+      return { [field]: cond };
+    case "customers":
+      return { [field]: cond };
+    case "workOrders":
+      if (field === "customer") return { vehicle: { customer: { name: cond } } };
+      if (field === "vehicle") {
+        const v = vehicleTextWhere(filter.operator, filter.value);
+        return v ? { vehicle: v } : null;
+      }
+      return { [field]: cond };
+    case "quotes":
+      if (field === "customer") return { customer: { name: cond } };
+      if (field === "vehicle") {
+        const v = vehicleTextWhere(filter.operator, filter.value);
+        return v ? { vehicle: v } : null;
+      }
+      return { [field]: cond };
+    case "inventory":
+      return { [field]: cond };
+  }
+}
+
+function buildWhere(config: CustomCardConfig, base: Cond): Cond {
+  const conds = config.filters
+    .map((filter) => whereFor(config.entity, filter))
+    .filter((c): c is Cond => c !== null);
+  return conds.length > 0 ? { AND: [base, ...conds] } : base;
+}
+
+const iso = (d: Date | null | undefined) => (d ? d.toISOString() : null);
+
+export async function runEntityQuery(
+  config: CustomCardConfig,
+  organizationId: string
+): Promise<CardRow[]> {
+  const limit = Math.max(1, Math.min(25, config.limit));
+
+  switch (config.entity) {
+    case "vehicles": {
+      const rows = await db.vehicle.findMany({
+        where: buildWhere(config, { organizationId, isArchived: false }),
+        select: {
+          id: true, licensePlate: true, make: true, model: true, year: true,
+          color: true, vin: true, mileage: true, fuelType: true,
+          transmission: true, createdAt: true, updatedAt: true,
+          customer: { select: { name: true } },
+        },
+        orderBy: { updatedAt: "desc" },
+        take: limit,
+      });
+      return rows.map((r) => ({
+        id: r.id,
+        href: `/vehicles/${r.id}`,
+        cells: {
+          licensePlate: r.licensePlate, make: r.make, model: r.model,
+          year: r.year, color: r.color, vin: r.vin, mileage: r.mileage,
+          fuelType: r.fuelType, transmission: r.transmission,
+          customer: r.customer?.name ?? null,
+          createdAt: iso(r.createdAt), updatedAt: iso(r.updatedAt),
+        },
+      }));
+    }
+    case "customers": {
+      const rows = await db.customer.findMany({
+        where: buildWhere(config, { organizationId }),
+        select: {
+          id: true, name: true, company: true, email: true, phone: true,
+          address: true, taxId: true, createdAt: true,
+        },
+        orderBy: { updatedAt: "desc" },
+        take: limit,
+      });
+      return rows.map((r) => ({
+        id: r.id,
+        href: `/customers/${r.id}`,
+        cells: {
+          name: r.name, company: r.company, email: r.email, phone: r.phone,
+          address: r.address, taxId: r.taxId, createdAt: iso(r.createdAt),
+        },
+      }));
+    }
+    case "workOrders": {
+      const rows = await db.serviceRecord.findMany({
+        where: buildWhere(config, { vehicle: { organizationId } }),
+        select: {
+          id: true, invoiceNumber: true, title: true, type: true, status: true,
+          techName: true, totalAmount: true, serviceDate: true,
+          invoiceDate: true, startDateTime: true,
+          vehicle: {
+            select: {
+              id: true, make: true, model: true, year: true,
+              customer: { select: { name: true } },
+            },
+          },
+        },
+        orderBy: { createdAt: "desc" },
+        take: limit,
+      });
+      return rows.map((r) => ({
+        id: r.id,
+        href: `/vehicles/${r.vehicle.id}/service/${r.id}`,
+        cells: {
+          invoiceNumber: r.invoiceNumber, title: r.title, type: r.type,
+          status: r.status, techName: r.techName, totalAmount: r.totalAmount,
+          serviceDate: iso(r.invoiceDate ?? r.startDateTime ?? r.serviceDate),
+          vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
+          customer: r.vehicle.customer?.name ?? null,
+        },
+      }));
+    }
+    case "quotes": {
+      const rows = await db.quote.findMany({
+        where: buildWhere(config, { organizationId }),
+        select: {
+          id: true, quoteNumber: true, title: true, status: true,
+          totalAmount: true, validUntil: true, createdAt: true,
+          customer: { select: { name: true } },
+          vehicle: { select: { make: true, model: true, year: true } },
+        },
+        orderBy: { createdAt: "desc" },
+        take: limit,
+      });
+      return rows.map((r) => ({
+        id: r.id,
+        href: `/quotes/${r.id}`,
+        cells: {
+          quoteNumber: r.quoteNumber, title: r.title, status: r.status,
+          totalAmount: r.totalAmount, validUntil: iso(r.validUntil),
+          createdAt: iso(r.createdAt),
+          customer: r.customer?.name ?? null,
+          vehicle: r.vehicle
+            ? `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`
+            : null,
+        },
+      }));
+    }
+    case "inventory": {
+      const rows = await db.inventoryPart.findMany({
+        where: buildWhere(config, { organizationId }),
+        select: {
+          id: true, name: true, partNumber: true, category: true,
+          quantity: true, minQuantity: true, unitCost: true, sellPrice: true,
+          supplier: true,
+        },
+        orderBy: { updatedAt: "desc" },
+        take: limit,
+      });
+      return rows.map((r) => ({
+        id: r.id,
+        href: `/inventory/${r.id}`,
+        cells: {
+          name: r.name, partNumber: r.partNumber, category: r.category,
+          quantity: r.quantity, minQuantity: r.minQuantity,
+          unitCost: r.unitCost, sellPrice: r.sellPrice, supplier: r.supplier,
+        },
+      }));
+    }
+  }
+}

+ 126 - 0
src/features/dashboard/dashboard-grid-config.ts

@@ -0,0 +1,126 @@
+/**
+ * Dashboard grid model: a 12-column grid where every card has a position and
+ * size, persisted per user (User.dashboardLayout). The default mirrors the
+ * pre-grid dashboard: two columns of cards with two full-width rows.
+ */
+
+export const DASHBOARD_CARD_IDS = [
+  "maintenance",
+  "reminders",
+  "sms",
+  "notifications",
+  "inspections",
+  "quoteRequests",
+  "quoteResponses",
+  "recentCompleted",
+  "activeJobs",
+  "recentActivity",
+  "recentObservations",
+] as const;
+
+export type DashboardCardId = (typeof DASHBOARD_CARD_IDS)[number];
+
+export interface CardLayout {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+}
+
+export interface DashboardLayout {
+  version: 1;
+  /** Hidden card ids: built-in ids or custom:<widgetId> */
+  hidden: string[];
+  /** Positions keyed by built-in id or custom:<widgetId> */
+  cards: Record<string, CardLayout>;
+}
+
+export const GRID_COLS = 12;
+export const GRID_ROW_HEIGHT = 84;
+export const GRID_MARGIN: [number, number] = [16, 16];
+export const CARD_MIN_W = 3;
+export const CARD_MIN_H = 3;
+
+const half = (x: 0 | 6, row: number, h = 5): CardLayout => ({ x, y: row, w: 6, h });
+const full = (row: number, h = 5): CardLayout => ({ x: 0, y: row, w: 12, h });
+
+/** Mirrors the original two-column dashboard order. Rows compact vertically,
+ *  so hidden cards (e.g. sms vs notifications) leave no holes. */
+export const DEFAULT_LAYOUT: DashboardLayout = {
+  version: 1,
+  hidden: [],
+  cards: {
+    maintenance: half(0, 0),
+    reminders: half(6, 0),
+    sms: half(0, 5),
+    notifications: half(6, 5),
+    inspections: half(0, 10),
+    quoteRequests: half(6, 10),
+    quoteResponses: half(0, 15),
+    recentCompleted: full(20),
+    activeJobs: full(25),
+    recentActivity: half(0, 30),
+    recentObservations: half(6, 30),
+  },
+};
+
+function isCardLayout(v: unknown): v is CardLayout {
+  if (!v || typeof v !== "object") return false;
+  const c = v as Record<string, unknown>;
+  return (["x", "y", "w", "h"] as const).every(
+    (k) => typeof c[k] === "number" && Number.isFinite(c[k] as number)
+  );
+}
+
+function clampCard(stored: CardLayout): CardLayout {
+  return {
+    x: Math.max(0, Math.min(GRID_COLS - CARD_MIN_W, Math.round(stored.x))),
+    y: Math.max(0, Math.round(stored.y)),
+    w: Math.max(CARD_MIN_W, Math.min(GRID_COLS, Math.round(stored.w))),
+    h: Math.max(CARD_MIN_H, Math.min(40, Math.round(stored.h))),
+  };
+}
+
+/** A default spot for a card with no stored position: full row at the bottom */
+export function placeAtBottom(cards: Record<string, CardLayout>): CardLayout {
+  const bottom = Object.values(cards).reduce((max, c) => Math.max(max, c.y + c.h), 0);
+  return { x: 0, y: bottom, w: 6, h: 5 };
+}
+
+/**
+ * Merges a stored layout with the defaults: unknown card ids are dropped,
+ * missing cards (added after the user saved) fall back to their default
+ * position, and malformed values reset. `customIds` are the currently
+ * existing custom:<widgetId> card ids; stored entries for deleted widgets
+ * are discarded, and new widgets get a spot at the bottom. Never throws.
+ */
+export function normalizeLayout(raw: unknown, customIds: string[] = []): DashboardLayout {
+  const result: DashboardLayout = {
+    version: 1,
+    hidden: [],
+    cards: { ...DEFAULT_LAYOUT.cards },
+  };
+  const knownIds = new Set<string>([...DASHBOARD_CARD_IDS, ...customIds]);
+  const data = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
+
+  if (Array.isArray(data.hidden)) {
+    result.hidden = data.hidden.filter(
+      (id): id is string => typeof id === "string" && knownIds.has(id)
+    );
+  }
+
+  const cards =
+    data.cards && typeof data.cards === "object"
+      ? (data.cards as Record<string, unknown>)
+      : {};
+  for (const id of DASHBOARD_CARD_IDS) {
+    const stored = cards[id];
+    if (isCardLayout(stored)) result.cards[id] = clampCard(stored);
+  }
+  for (const id of customIds) {
+    const stored = cards[id];
+    result.cards[id] = isCardLayout(stored) ? clampCard(stored) : placeAtBottom(result.cards);
+  }
+
+  return result;
+}

+ 0 - 57
src/hooks/use-dashboard-visibility.ts

@@ -1,57 +0,0 @@
-"use client";
-
-import { useState, useCallback } from "react";
-
-export const DASHBOARD_CARD_IDS = [
-  "maintenance",
-  "reminders",
-  "inspections",
-  "quoteRequests",
-  "quoteResponses",
-  "sms",
-  "notifications",
-  "recentCompleted",
-  "activeJobs",
-  "recentObservations",
-] as const;
-
-export type DashboardCardId = (typeof DASHBOARD_CARD_IDS)[number];
-
-const STORAGE_KEY = "torqvoice-dashboard-hidden";
-
-export function useDashboardVisibility(excludeId?: DashboardCardId) {
-  const [hidden, setHidden] = useState<Set<string>>(() => {
-    if (typeof window !== "undefined") {
-      try {
-        const stored = localStorage.getItem(STORAGE_KEY);
-        if (stored) return new Set(JSON.parse(stored));
-      } catch {
-        // ignore malformed data
-      }
-    }
-    return new Set();
-  });
-
-  const toggleCard = useCallback((id: string) => {
-    setHidden((prev) => {
-      const next = new Set(prev);
-      if (next.has(id)) {
-        next.delete(id);
-      } else {
-        next.add(id);
-      }
-      localStorage.setItem(STORAGE_KEY, JSON.stringify([...next]));
-      return next;
-    });
-  }, []);
-
-  const isVisible = useCallback((id: string) => !hidden.has(id), [hidden]);
-
-  const applicableIds = excludeId
-    ? DASHBOARD_CARD_IDS.filter((id) => id !== excludeId)
-    : DASHBOARD_CARD_IDS;
-  const visibleCount = applicableIds.filter((id) => !hidden.has(id)).length;
-  const totalCount = applicableIds.length;
-
-  return { toggleCard, isVisible, visibleCount, totalCount };
-}