Explorar o código

Make the dashboard worth looking at on a quiet day

An empty card kept its full grid tile, so a shop with nothing overdue got
several hundred pixels of nothing under a single line of grey text, three
times over. Cards now take only the rows their content needs, and an empty
one centres a muted icon, the line it already had, and — where there is an
obvious next step — one button for it. A height set by hand still wins:
pulling the resize handle pins that card.

The stat row is rebuilt on the house card's rules, with the number carrying
the weight and the arrow appearing on hover or keyboard focus so the tiles
read as the links they always were. Total Parts had been wearing a settings
gear; it gets Inventory's box.

New card: portal service requests. A customer asking for work reached the
shop as an alert on the way in and a tab on that one customer's page, so a
missed alert meant a request nobody was looking for. It lists them newest
first with the date they would like, and carries the same two actions the
customer page has, so a request becomes a draft job without leaving the
dashboard. Hidden unless the plan allows a portal and the shop turned it on.

Also: the maintenance tabs stop outshouting the overdue vehicle beneath
them, the Dismissed tab says something at zero, the skeleton takes its
height from the grid model, "Read more" becomes an icon button sized to the
header row, a lone breadcrumb is styled as the page title, the tire hotel
moves up beside the observations card, and toasts move to bottom centre,
clear of the mobile tab bar.

A moved default now reaches a saved layout: LAYOUT_VERSION discards the
stored position of the cards named in MOVED and only those, placing them
relative to where the user actually put things rather than at a row number
that means nothing once the dashboard has been rearranged.
Bernt Christian Egeland hai 1 mes
pai
achega
ea8ad33d07

+ 7 - 0
messages/de/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Voraussichtliche Wartung",
     "reminders": "Anstehende Erinnerungen",
     "inspections": "Inspektionen",
+    "serviceRequests": "Serviceanfragen",
     "quoteRequests": "Angebotsanfragen",
     "quoteResponses": "Angebotsantworten",
     "sms": "Letzte Nachrichten",
@@ -68,6 +69,7 @@
     "active": "Aktiv",
     "dismissedTab": "Verworfen",
     "noActive": "Derzeit sind keine Fahrzeuge wartungsfällig.",
+    "noDismissed": "Keine ausgeblendeten Fahrzeuge.",
     "dismissedOn": "Verworfen am {date}",
     "restore": "Wiederherstellen",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} Positionen",
     "noData": "Keine Inspektionen."
   },
+  "serviceRequests": {
+    "title": "Serviceanfragen",
+    "description": "Terminanfragen aus dem Kundenportal",
+    "noData": "Keine offenen Serviceanfragen."
+  },
   "quoteRequests": {
     "title": "Angebotsanfragen",
     "description": "Kunden, die Angebote aus geteilten Inspektionen anfordern",

+ 7 - 0
messages/en/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Predicted Maintenance",
     "reminders": "Upcoming Reminders",
     "inspections": "Inspections",
+    "serviceRequests": "Service Requests",
     "quoteRequests": "Quote Requests",
     "quoteResponses": "Quote Responses",
     "sms": "Recent Messages",
@@ -68,6 +69,7 @@
     "active": "Active",
     "dismissedTab": "Dismissed",
     "noActive": "No vehicles currently due for service.",
+    "noDismissed": "No dismissed vehicles.",
     "dismissedOn": "Dismissed {date}",
     "restore": "Restore",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} items",
     "noData": "No inspections."
   },
+  "serviceRequests": {
+    "title": "Service Requests",
+    "description": "Booking requests from the customer portal",
+    "noData": "No pending service requests."
+  },
   "quoteRequests": {
     "title": "Quote Requests",
     "description": "Customers requesting quotes from shared inspections",

+ 7 - 0
messages/es/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Mantenimiento previsto",
     "reminders": "Recordatorios pendientes",
     "inspections": "Inspecciones",
+    "serviceRequests": "Solicitudes de servicio",
     "quoteRequests": "Solicitudes de presupuesto",
     "quoteResponses": "Respuestas de presupuesto",
     "sms": "Mensajes recientes",
@@ -68,6 +69,7 @@
     "active": "Activos",
     "dismissedTab": "Descartados",
     "noActive": "No hay vehículos que requieran servicio actualmente.",
+    "noDismissed": "No hay vehículos descartados.",
     "dismissedOn": "Descartado el {date}",
     "restore": "Restaurar",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} elementos",
     "noData": "No hay inspecciones."
   },
+  "serviceRequests": {
+    "title": "Solicitudes de servicio",
+    "description": "Solicitudes de cita desde el portal del cliente",
+    "noData": "No hay solicitudes de servicio pendientes."
+  },
   "quoteRequests": {
     "title": "Solicitudes de presupuesto",
     "description": "Clientes que solicitan presupuestos a partir de inspecciones compartidas",

+ 7 - 0
messages/fr/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Maintenance prévue",
     "reminders": "Rappels à venir",
     "inspections": "Inspections",
+    "serviceRequests": "Demandes de service",
     "quoteRequests": "Demandes de devis",
     "quoteResponses": "Réponses aux devis",
     "sms": "Messages récents",
@@ -68,6 +69,7 @@
     "active": "Actifs",
     "dismissedTab": "Ignorés",
     "noActive": "Aucun véhicule n'a besoin d'entretien actuellement.",
+    "noDismissed": "Aucun véhicule ignoré.",
     "dismissedOn": "Ignoré le {date}",
     "restore": "Restaurer",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} éléments",
     "noData": "Aucune inspection."
   },
+  "serviceRequests": {
+    "title": "Demandes de service",
+    "description": "Demandes de rendez-vous depuis le portail client",
+    "noData": "Aucune demande de service en attente."
+  },
   "quoteRequests": {
     "title": "Demandes de devis",
     "description": "Clients demandant des devis à partir d’inspections partagées",

+ 7 - 0
messages/it/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Manutenzione prevista",
     "reminders": "Promemoria in scadenza",
     "inspections": "Ispezioni",
+    "serviceRequests": "Richieste di servizio",
     "quoteRequests": "Richieste di preventivo",
     "quoteResponses": "Risposte ai preventivi",
     "sms": "Messaggi recenti",
@@ -68,6 +69,7 @@
     "active": "Attivi",
     "dismissedTab": "Ignorati",
     "noActive": "Nessun veicolo attualmente in scadenza per la manutenzione.",
+    "noDismissed": "Nessun veicolo ignorato.",
     "dismissedOn": "Ignorato il {date}",
     "restore": "Ripristina",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} elementi",
     "noData": "Nessuna ispezione."
   },
+  "serviceRequests": {
+    "title": "Richieste di servizio",
+    "description": "Richieste di appuntamento dal portale clienti",
+    "noData": "Nessuna richiesta di servizio in sospeso."
+  },
   "quoteRequests": {
     "title": "Richieste di preventivo",
     "description": "Clienti che richiedono preventivi dalle ispezioni condivise",

+ 7 - 0
messages/lt/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Numatoma priežiūra",
     "reminders": "Artėjantys priminimai",
     "inspections": "Apžiūros",
+    "serviceRequests": "Paslaugų užklausos",
     "quoteRequests": "Pasiūlymų užklausos",
     "quoteResponses": "Pasiūlymų atsakymai",
     "sms": "Naujausios žinutės",
@@ -68,6 +69,7 @@
     "active": "Aktyvūs",
     "dismissedTab": "Atmesti",
     "noActive": "Šiuo metu nėra transporto priemonių, kurioms reikia aptarnavimo.",
+    "noDismissed": "Nėra atmestų transporto priemonių.",
     "dismissedOn": "Atmesta {date}",
     "restore": "Atkurti",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} elementų",
     "noData": "Apžiūrų nėra."
   },
+  "serviceRequests": {
+    "title": "Paslaugų užklausos",
+    "description": "Vizitų užklausos iš klientų portalo",
+    "noData": "Nėra laukiančių paslaugų užklausų."
+  },
   "quoteRequests": {
     "title": "Pasiūlymų užklausos",
     "description": "Klientai prašo pasiūlymų iš bendrinamų apžiūrų",

+ 7 - 0
messages/nb/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Forventet vedlikehold",
     "reminders": "Kommende påminnelser",
     "inspections": "Inspeksjoner",
+    "serviceRequests": "Serviceforespørsler",
     "quoteRequests": "Tilbudsforespørsler",
     "quoteResponses": "Tilbudssvar",
     "sms": "Siste meldinger",
@@ -68,6 +69,7 @@
     "active": "Aktive",
     "dismissedTab": "Avvist",
     "noActive": "Ingen kjøretøy trenger service for øyeblikket.",
+    "noDismissed": "Ingen avviste kjøretøy.",
     "dismissedOn": "Avvist {date}",
     "restore": "Gjenopprett",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} punkter",
     "noData": "Ingen inspeksjoner."
   },
+  "serviceRequests": {
+    "title": "Serviceforespørsler",
+    "description": "Timebestillinger fra kundeportalen",
+    "noData": "Ingen ventende serviceforespørsler."
+  },
   "quoteRequests": {
     "title": "Tilbudsforespørsler",
     "description": "Kunder som ber om tilbud fra delte inspeksjoner",

+ 7 - 0
messages/nl/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Verwacht onderhoud",
     "reminders": "Aankomende herinneringen",
     "inspections": "Inspecties",
+    "serviceRequests": "Serviceaanvragen",
     "quoteRequests": "Offerte-aanvragen",
     "quoteResponses": "Offerte-reacties",
     "sms": "Recente berichten",
@@ -68,6 +69,7 @@
     "active": "Actief",
     "dismissedTab": "Genegeerd",
     "noActive": "Geen voertuigen hebben momenteel onderhoud nodig.",
+    "noDismissed": "Geen genegeerde voertuigen.",
     "dismissedOn": "Genegeerd op {date}",
     "restore": "Herstellen",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} items",
     "noData": "Geen inspecties."
   },
+  "serviceRequests": {
+    "title": "Serviceaanvragen",
+    "description": "Afspraakaanvragen via het klantenportaal",
+    "noData": "Geen openstaande serviceaanvragen."
+  },
   "quoteRequests": {
     "title": "Offerte-aanvragen",
     "description": "Klanten die offertes aanvragen vanuit gedeelde inspecties",

+ 7 - 0
messages/pl/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Przewidywana konserwacja",
     "reminders": "Nadchodzące przypomnienia",
     "inspections": "Przeglądy",
+    "serviceRequests": "Zgłoszenia serwisowe",
     "quoteRequests": "Zapytania o wycenę",
     "quoteResponses": "Odpowiedzi na wyceny",
     "sms": "Ostatnie wiadomości",
@@ -68,6 +69,7 @@
     "active": "Aktywne",
     "dismissedTab": "Odrzucone",
     "noActive": "Żaden pojazd nie wymaga obecnie serwisu.",
+    "noDismissed": "Brak odrzuconych pojazdów.",
     "dismissedOn": "Odrzucono {date}",
     "restore": "Przywróć",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} pozycji",
     "noData": "Brak inspekcji."
   },
+  "serviceRequests": {
+    "title": "Zgłoszenia serwisowe",
+    "description": "Prośby o termin z portalu klienta",
+    "noData": "Brak oczekujących zgłoszeń serwisowych."
+  },
   "quoteRequests": {
     "title": "Zapytania o wycenę",
     "description": "Klienci wnioskujący o wycenę z udostępnionych przeglądów",

+ 7 - 0
messages/pt-BR/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Manutenção Prevista",
     "reminders": "Lembretes Próximos",
     "inspections": "Inspeções",
+    "serviceRequests": "Solicitações de serviço",
     "quoteRequests": "Solicitações de Orçamento",
     "quoteResponses": "Respostas de Orçamento",
     "sms": "Mensagens Recentes",
@@ -68,6 +69,7 @@
     "active": "Ativos",
     "dismissedTab": "Dispensados",
     "noActive": "Nenhum veículo precisa de manutenção no momento.",
+    "noDismissed": "Nenhum veículo dispensado.",
     "dismissedOn": "Dispensado em {date}",
     "restore": "Restaurar",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} itens",
     "noData": "Nenhuma inspeção."
   },
+  "serviceRequests": {
+    "title": "Solicitações de serviço",
+    "description": "Pedidos de agendamento pelo portal do cliente",
+    "noData": "Nenhuma solicitação de serviço pendente."
+  },
   "quoteRequests": {
     "title": "Solicitações de Orçamento",
     "description": "Clientes solicitando orçamentos a partir de inspeções compartilhadas",

+ 7 - 0
messages/ru/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Плановое ТО",
     "reminders": "Предстоящие напоминания",
     "inspections": "Осмотры",
+    "serviceRequests": "Заявки на обслуживание",
     "quoteRequests": "Запросы на предложения",
     "quoteResponses": "Ответы на предложения",
     "sms": "Последние сообщения",
@@ -68,6 +69,7 @@
     "active": "Активные",
     "dismissedTab": "Отклонённые",
     "noActive": "Нет транспортных средств, требующих обслуживания.",
+    "noDismissed": "Нет отклонённых транспортных средств.",
     "dismissedOn": "Отклонено {date}",
     "restore": "Восстановить",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} пунктов",
     "noData": "Нет осмотров."
   },
+  "serviceRequests": {
+    "title": "Заявки на обслуживание",
+    "description": "Запросы на запись из клиентского портала",
+    "noData": "Нет ожидающих заявок на обслуживание."
+  },
   "quoteRequests": {
     "title": "Запросы на предложения",
     "description": "Клиенты, запрашивающие предложения из общих осмотров",

+ 7 - 0
messages/tr/dashboard.json

@@ -46,6 +46,7 @@
     "maintenance": "Tahmini Bakım",
     "reminders": "Yaklaşan Hatırlatmalar",
     "inspections": "Muayeneler",
+    "serviceRequests": "Servis talepleri",
     "quoteRequests": "Teklif Talepleri",
     "quoteResponses": "Teklif Yanıtları",
     "sms": "Son Mesajlar",
@@ -68,6 +69,7 @@
     "active": "Aktif",
     "dismissedTab": "Kapatılan",
     "noActive": "Şu anda bakım gerektiren araç bulunmuyor.",
+    "noDismissed": "Yok sayılan araç yok.",
     "dismissedOn": "{date} tarihinde kapatıldı",
     "restore": "Geri Yükle",
     "settingsAriaLabel": "Maintenance settings",
@@ -96,6 +98,11 @@
     "itemCount": "{inspected}/{total} öğe",
     "noData": "İnceleme yok."
   },
+  "serviceRequests": {
+    "title": "Servis talepleri",
+    "description": "Müşteri portalından randevu talepleri",
+    "noData": "Bekleyen servis talebi yok."
+  },
   "quoteRequests": {
     "title": "Teklif Talepleri",
     "description": "Paylaşılan muayenelerden teklif talep eden müşteriler",

+ 105 - 0
src/__tests__/features/dashboard-layout.test.ts

@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
 import {
   DASHBOARD_CARD_IDS,
   DEFAULT_LAYOUT,
+  LAYOUT_VERSION,
+  normalizeLayout,
   type CardLayout,
   type DashboardCardId,
 } from '@/features/dashboard/dashboard-grid-config'
@@ -98,3 +100,106 @@ describe('default dashboard layout', () => {
     }
   })
 })
+
+/**
+ * Moving a default is the one thing that reaches past a saved layout, so it
+ * has to reach exactly as far as intended: the card that moved, and nothing
+ * else the user put where they wanted it.
+ */
+describe('moved defaults', () => {
+  const somewhereElse: CardLayout = { x: 0, y: 34, w: 6, h: 5 }
+
+  it('lands the moved card above the one it belongs above', () => {
+    // recentCompleted moved up by hand, well above the tire hotel's own
+    // default row: an absolute row would drop the card below it.
+    const layout = normalizeLayout({
+      version: 1,
+      hidden: [],
+      cards: {
+        tireHotel: somewhereElse,
+        recentCompleted: { x: 0, y: 8, w: 12, h: 5 },
+      },
+    })
+
+    const tireHotel = layout.cards.tireHotel
+    expect(tireHotel.x).toBe(6)
+    expect(tireHotel.y + tireHotel.h).toBeLessThanOrEqual(layout.cards.recentCompleted.y)
+  })
+
+  it('overlaps nothing after making room for the moved card', () => {
+    const layout = normalizeLayout({
+      version: 1,
+      hidden: [],
+      cards: { tireHotel: somewhereElse },
+    })
+
+    const ids = DASHBOARD_CARD_IDS.filter((id) => id !== 'notifications')
+    for (let i = 0; i < ids.length; i++) {
+      for (let j = i + 1; j < ids.length; j++) {
+        expect(
+          overlaps(layout.cards[ids[i]], layout.cards[ids[j]]),
+          `${ids[i]} overlaps ${ids[j]} after the migration`
+        ).toBe(false)
+      }
+    }
+  })
+
+  it('keeps a stored position saved since the card moved', () => {
+    const layout = normalizeLayout({
+      version: LAYOUT_VERSION,
+      hidden: [],
+      cards: { tireHotel: somewhereElse },
+    })
+
+    expect(layout.cards.tireHotel).toEqual(somewhereElse)
+  })
+
+  it('leaves every other card where the user put it', () => {
+    const layout = normalizeLayout({
+      version: 1,
+      hidden: [],
+      cards: { reminders: somewhereElse, activeJobs: { x: 0, y: 0, w: 12, h: 6 } },
+    })
+
+    expect(layout.cards.reminders).toEqual(somewhereElse)
+    expect(layout.cards.activeJobs).toEqual({ x: 0, y: 0, w: 12, h: 6 })
+  })
+
+  it('stamps the current version so the move happens once', () => {
+    expect(normalizeLayout({ version: 1, hidden: [], cards: {} }).version).toBe(LAYOUT_VERSION)
+  })
+})
+
+/**
+ * Cards are drawn only as tall as their content needs, so a height the user
+ * set by hand has to survive the round trip or the resize handle looks
+ * broken: it moves, and then the next fitting pass undoes it.
+ */
+describe('pinned heights', () => {
+  it('keeps the pin through a save and load', () => {
+    const layout = normalizeLayout({
+      version: LAYOUT_VERSION,
+      hidden: [],
+      cards: { tireHotel: { x: 6, y: 24, w: 6, h: 7, pinH: true } },
+    })
+
+    expect(layout.cards.tireHotel.pinH).toBe(true)
+    expect(layout.cards.tireHotel.h).toBe(7)
+  })
+
+  it('leaves an unpinned card unpinned', () => {
+    const layout = normalizeLayout({
+      version: LAYOUT_VERSION,
+      hidden: [],
+      cards: { tireHotel: { x: 6, y: 24, w: 6, h: 7 } },
+    })
+
+    expect(layout.cards.tireHotel.pinH).toBeUndefined()
+  })
+
+  it('drops the pin when the layout is reset', () => {
+    for (const id of DASHBOARD_CARD_IDS) {
+      expect(DEFAULT_LAYOUT.cards[id].pinH, `${id} ships pinned`).toBeUndefined()
+    }
+  })
+})

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 615 - 435
src/app/(authenticated)/dashboard-client.tsx


+ 20 - 1
src/app/(authenticated)/page.tsx

@@ -11,6 +11,7 @@ import {
 } from '@/features/vehicles/Actions/predictedMaintenanceActions'
 import { getInspectionsPaginated } from '@/features/inspections/Actions/inspectionActions'
 import { getQuoteRequests } from '@/features/inspections/Actions/quoteRequestActions'
+import { getPendingServiceRequests } from '@/features/customers/Actions/customerActions'
 import { getQuoteResponses } from '@/features/quotes/Actions/quoteResponseActions'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { getFeatures } from '@/lib/features'
@@ -31,6 +32,10 @@ export default async function DashboardPage() {
   const auth = await getAuthContext()
   const features = auth ? await getFeatures(auth.organizationId) : null
   const smsEnabled = features?.sms ?? false
+  // The plan decides whether the portal exists at all; the setting decides
+  // whether this shop turned it on. Gate the query on the first so a shop
+  // that cannot have a portal never runs it, and the card on the second.
+  const portalAllowed = features?.customerPortal ?? false
 
   const [
     result,
@@ -49,9 +54,14 @@ export default async function DashboardPage() {
     myJobsResult,
     checklistResult,
     tireHotelResult,
+    serviceRequestsResult,
   ] = await Promise.all([
     getDashboardStats(),
-    getSettings([SETTING_KEYS.CURRENCY_CODE, SETTING_KEYS.UNIT_SYSTEM]),
+    getSettings([
+      SETTING_KEYS.CURRENCY_CODE,
+      SETTING_KEYS.UNIT_SYSTEM,
+      SETTING_KEYS.PORTAL_ENABLED,
+    ]),
     getUpcomingReminders(),
     getVehiclesDueForService(),
     getDismissedMaintenanceVehicles(),
@@ -66,6 +76,7 @@ export default async function DashboardPage() {
     getMyActiveJobs(),
     getOnboardingChecklist(),
     getTireHotelSummary(),
+    portalAllowed ? getPendingServiceRequests() : Promise.resolve(null),
   ])
 
   const [layoutUser, widgetRows] = auth
@@ -114,6 +125,13 @@ export default async function DashboardPage() {
       ? recentObservationsResult.data
       : []
 
+  // Null rather than an empty list: no portal means no card at all, where an
+  // empty list would mean a portal nobody has used yet.
+  const serviceRequests =
+    portalAllowed && settings[SETTING_KEYS.PORTAL_ENABLED] === 'true'
+      ? (serviceRequestsResult?.success && serviceRequestsResult.data) || []
+      : null
+
   const myJobs = myJobsResult.success && myJobsResult.data ? myJobsResult.data : []
   const onboardingChecklist =
     checklistResult.success && checklistResult.data ? checklistResult.data : null
@@ -149,6 +167,7 @@ export default async function DashboardPage() {
           completedInspections={
             completedResult.success && completedResult.data ? completedResult.data.records : []
           }
+          serviceRequests={serviceRequests}
           quoteRequests={
             quoteRequestsResult.success && quoteRequestsResult.data ? quoteRequestsResult.data : []
           }

+ 7 - 1
src/app/layout.tsx

@@ -129,7 +129,13 @@ export default async function RootLayout({
                     {children}
                   </BannerSlotProvider>
                   <GlassModal />
-                  <Toaster richColors position="bottom-right" />
+                  {/* Centred at the bottom, and lifted clear of the mobile
+                      bottom nav so a toast never lands on the tab bar. */}
+                  <Toaster
+                    richColors
+                    position="bottom-center"
+                    mobileOffset={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom))' }}
+                  />
                   <PWAServiceWorker />
                 </TooltipProvider>
               </QueryProvider>

+ 3 - 3
src/components/app-card.tsx

@@ -63,15 +63,15 @@ export function AppCard({
         // pass fades in on hover — gradients can't transition, opacity can.
         // inset-0, not a fixed-height strip: the ellipse must finish its own
         // fade-out inside the layer, or its clip edge draws a hard line.
-        'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(8rem_5rem_at_2.75rem_2.5rem,color-mix(in_oklab,var(--primary)_9%,transparent),transparent_70%)]',
-        'after:pointer-events-none after:absolute after:inset-0 after:opacity-0 after:transition-opacity after:duration-300 after:bg-[radial-gradient(9.5rem_6rem_at_2.75rem_2.5rem,color-mix(in_oklab,var(--primary)_8%,transparent),transparent_70%)] hover:after:opacity-100',
+        'before:pointer-events-none before:absolute before:inset-0 before:bg-[radial-gradient(8rem_5rem_at_2.75rem_2.5rem,color-mix(in_oklab,var(--primary)_6%,transparent),transparent_70%)]',
+        'after:pointer-events-none after:absolute after:inset-0 after:opacity-0 after:transition-opacity after:duration-300 after:bg-[radial-gradient(9.5rem_6rem_at_2.75rem_2.5rem,color-mix(in_oklab,var(--primary)_6%,transparent),transparent_70%)] hover:after:opacity-100',
         className
       )}
     >
       <div className="relative shrink-0">
         <div className="flex items-start gap-3 px-5 pb-3 pt-4">
           {Icon && (
-            <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-linear-to-b from-primary/18 to-primary/6 text-primary ring-1 ring-inset ring-primary/25 shadow-[inset_0_1px_0_rgb(255_255_255/0.15)]">
+            <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-linear-to-b from-primary/12 to-primary/4 text-primary ring-1 ring-inset ring-primary/20 shadow-[inset_0_1px_0_rgb(255_255_255/0.15)]">
               <Icon className="h-4 w-4" />
             </div>
           )}

+ 45 - 0
src/components/card-empty.tsx

@@ -0,0 +1,45 @@
+import type { ComponentType, ReactNode } from 'react'
+import { cn } from '@/lib/utils'
+
+/**
+ * The house empty state for an AppCard body.
+ *
+ * A dashboard card keeps its grid height whether or not it has rows, so an
+ * empty one used to be several hundred pixels of nothing with a single grey
+ * sentence pinned to the top-left. This centres the message in whatever space
+ * the card has and gives it a muted icon and, where there is something useful
+ * to do next, one action.
+ *
+ * Sized to fit a collapsed card (3 grid rows) without scrolling: keep the icon
+ * chip at 2.5rem and the action at `size="sm"`.
+ */
+export function CardEmpty({
+  icon: Icon,
+  title,
+  action,
+  className,
+}: {
+  icon?: ComponentType<{ className?: string }>
+  /** One quiet line. Reuse the card's existing `noData` string. */
+  title: ReactNode
+  /** Optional single next step, e.g. a "New inspection" button. */
+  action?: ReactNode
+  className?: string
+}) {
+  return (
+    <div
+      className={cn(
+        'flex h-full min-h-0 flex-col items-center justify-center gap-2.5 px-5 py-6 text-center',
+        className
+      )}
+    >
+      {Icon && (
+        <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground/60">
+          <Icon className="h-5 w-5" />
+        </div>
+      )}
+      <p className="max-w-[28ch] text-xs text-muted-foreground">{title}</p>
+      {action}
+    </div>
+  )
+}

+ 26 - 3
src/components/docs-link.tsx

@@ -19,10 +19,33 @@ export function DocsLink({
 }: {
   href: string
   className?: string
-  /** `inline` sits beside a heading; `hint` sits under dialog text with an icon. */
-  variant?: 'inline' | 'hint'
+  /**
+   * `inline` sits beside a heading; `hint` sits under dialog text with an
+   * icon; `header` is the page header's icon button, sized to the row of
+   * controls it sits in rather than floating as loose text among them.
+   */
+  variant?: 'inline' | 'hint' | 'header'
 }) {
   const t = useTranslations('navigation')
+  const label = t('readMore')
+
+  if (variant === 'header') {
+    return (
+      <a
+        href={`https://torqvoice.com${href}`}
+        target="_blank"
+        rel="noopener noreferrer"
+        aria-label={label}
+        title={label}
+        className={cn(
+          'flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
+          className
+        )}
+      >
+        <BookOpen className="h-4 w-4" />
+      </a>
+    )
+  }
 
   return (
     <a
@@ -36,7 +59,7 @@ export function DocsLink({
       )}
     >
       {variant === 'hint' && <BookOpen className="h-3.5 w-3.5" />}
-      {t('readMore')} →
+      {label} →
     </a>
   )
 }

+ 8 - 2
src/components/page-header.tsx

@@ -221,7 +221,13 @@ export function PageHeader() {
               if (isLast) {
                 return (
                   <BreadcrumbItem key={i}>
-                    <BreadcrumbPage>{t(segment.key)}</BreadcrumbPage>
+                    {/* A trail of one is not a trail. Pages that sit at the
+                        top level get their crumb styled as the page title. */}
+                    <BreadcrumbPage
+                      className={segments.length === 1 ? 'text-base font-semibold' : undefined}
+                    >
+                      {t(segment.key)}
+                    </BreadcrumbPage>
                   </BreadcrumbItem>
                 )
               }
@@ -241,7 +247,7 @@ export function PageHeader() {
           </BreadcrumbList>
         </Breadcrumb>
         <div className="ml-auto flex items-center gap-2">
-          {docsHref && <DocsLink href={docsHref} className="hidden sm:inline" />}
+          {docsHref && <DocsLink href={docsHref} variant="header" className="hidden sm:flex" />}
           <SearchTrigger />
           <QuickCreateMenu />
           {showWhiteLabelCta && (

+ 83 - 0
src/components/stat-tile.tsx

@@ -0,0 +1,83 @@
+import type { ComponentType } from 'react'
+import Link from 'next/link'
+import { ArrowUpRight } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+/**
+ * A headline number that links somewhere. The dashboard's top row.
+ *
+ * Built to the same rules as {@link AppCard}: the icon chip is the light
+ * source, the border leans primary on hover, and the shadow is a crisp seat
+ * plus a soft drop rather than a flat grey smudge. The number carries the
+ * weight — it is the first thing read on the page — and the arrow only
+ * appears on hover or focus, so the tile stays quiet until pointed at.
+ *
+ * `tone="warning"` is for a count that means someone has to act (parts below
+ * their reorder point), not merely a large number.
+ */
+export function StatTile({
+  href,
+  label,
+  value,
+  icon: Icon,
+  tone = 'default',
+}: {
+  href: string
+  label: string
+  value: number | string
+  icon: ComponentType<{ className?: string }>
+  tone?: 'default' | 'warning'
+}) {
+  const warning = tone === 'warning'
+  return (
+    <Link
+      href={href}
+      className={cn(
+        'group/tile flex items-center gap-3 rounded-xl border bg-card px-4 py-3',
+        'shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_32px_-16px_rgb(0_0_0/0.18)]',
+        'transition-[border-color,box-shadow] duration-300',
+        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
+        warning
+          ? 'border-amber-500/30 bg-amber-50 hover:border-amber-500/50 dark:bg-amber-950/40'
+          : 'border-card-edge hover:border-primary/30 hover:shadow-[0_1px_2px_rgb(0_0_0/0.05),0_16px_40px_-16px_rgb(0_0_0/0.22)]'
+      )}
+    >
+      <div
+        className={cn(
+          'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-linear-to-b ring-1 ring-inset shadow-[inset_0_1px_0_rgb(255_255_255/0.15)]',
+          warning
+            ? 'from-amber-500/18 to-amber-500/6 text-amber-600 ring-amber-500/25 dark:text-amber-500'
+            : 'from-primary/12 to-primary/4 text-primary ring-primary/20'
+        )}
+      >
+        <Icon className="h-4 w-4" />
+      </div>
+      <div className="min-w-0">
+        <p
+          className={cn(
+            'truncate text-[11px] font-medium',
+            warning ? 'text-amber-700 dark:text-amber-500' : 'text-muted-foreground'
+          )}
+        >
+          {label}
+        </p>
+        <p
+          className={cn(
+            'text-2xl font-semibold leading-tight tabular-nums',
+            warning && 'text-amber-700 dark:text-amber-500'
+          )}
+        >
+          {value}
+        </p>
+      </div>
+      <ArrowUpRight
+        aria-hidden
+        className={cn(
+          'ml-auto h-4 w-4 shrink-0 opacity-0 transition-opacity duration-200',
+          'group-hover/tile:opacity-100 group-focus-visible/tile:opacity-100',
+          warning ? 'text-amber-600/70' : 'text-muted-foreground'
+        )}
+      />
+    </Link>
+  )
+}

+ 586 - 434
src/features/customers/Actions/customerActions.ts

@@ -1,177 +1,214 @@
-"use server";
+'use server'
 
-import { db } from "@/lib/db";
-import { withAuth } from "@/lib/with-auth";
-import { z } from "zod";
-import { createCustomerSchema, updateCustomerSchema } from "../Schema/customerSchema";
-import { revalidatePath } from "next/cache";
-import { PermissionAction, PermissionSubject } from "@/lib/permissions";
-import { getFeatures, FeatureGatedError } from "@/lib/features";
-import { createDraftServiceRecord } from "@/features/vehicles/Actions/createDraftServiceRecord";
+import { db } from '@/lib/db'
+import { withAuth } from '@/lib/with-auth'
+import { z } from 'zod'
+import { createCustomerSchema, updateCustomerSchema } from '../Schema/customerSchema'
+import { revalidatePath } from 'next/cache'
+import { PermissionAction, PermissionSubject } from '@/lib/permissions'
+import { getFeatures, FeatureGatedError } from '@/lib/features'
+import { createDraftServiceRecord } from '@/features/vehicles/Actions/createDraftServiceRecord'
 
 export async function getCustomers() {
-  return withAuth(async ({ userId, organizationId }) => {
-    return db.customer.findMany({
-      where: { organizationId },
-      include: {
-        _count: { select: { vehicles: true } },
-      },
-      orderBy: { updatedAt: "desc" },
-    });
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      return db.customer.findMany({
+        where: { organizationId },
+        include: {
+          _count: { select: { vehicles: true } },
+        },
+        orderBy: { updatedAt: 'desc' },
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function getCustomer(customerId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const customer = await db.customer.findFirst({
-      where: { id: customerId, organizationId },
-      include: {
-        vehicles: {
-          where: { isArchived: false },
-          include: {
-            _count: { select: { serviceRecords: true } },
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const customer = await db.customer.findFirst({
+        where: { id: customerId, organizationId },
+        include: {
+          vehicles: {
+            where: { isArchived: false },
+            include: {
+              _count: { select: { serviceRecords: true } },
+            },
+            orderBy: { updatedAt: 'desc' },
           },
-          orderBy: { updatedAt: "desc" },
-        },
-        serviceRequests: {
-          include: {
-            vehicle: { select: { id: true, make: true, model: true, year: true } },
+          serviceRequests: {
+            include: {
+              vehicle: { select: { id: true, make: true, model: true, year: true } },
+            },
+            orderBy: { createdAt: 'desc' },
           },
-          orderBy: { createdAt: "desc" },
         },
-      },
-    });
-
-    // Missing or foreign-org customer yields null rather than an error: the
-    // page renders its not-found state, and this also runs during the
-    // post-delete re-render of the customer route.
-    return customer;
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] });
+      })
+
+      // Missing or foreign-org customer yields null rather than an error: the
+      // page renders its not-found state, and this also runs during the
+      // post-delete re-render of the customer route.
+      return customer
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function createCustomer(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const features = await getFeatures(organizationId);
-    const count = await db.customer.count({ where: { organizationId } });
-    if (count >= features.maxCustomers) {
-      throw new FeatureGatedError("maxCustomers", "Customer limit reached. Upgrade your plan to add more customers.");
-    }
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const features = await getFeatures(organizationId)
+      const count = await db.customer.count({ where: { organizationId } })
+      if (count >= features.maxCustomers) {
+        throw new FeatureGatedError(
+          'maxCustomers',
+          'Customer limit reached. Upgrade your plan to add more customers.'
+        )
+      }
 
-    const data = createCustomerSchema.parse(input);
+      const data = createCustomerSchema.parse(input)
 
-    // Auto-assign the next sequential number when none was provided; the
-    // per-org unique index guards against races and manual duplicates.
-    let customerNumber = data.customerNumber?.trim() || null;
-    if (!customerNumber) {
-      const existing = await db.customer.findMany({
-        where: { organizationId, customerNumber: { not: null } },
-        select: { customerNumber: true },
-      });
-      const max = existing.reduce((acc, c) => {
-        const n = Number.parseInt(c.customerNumber ?? "", 10);
-        return Number.isFinite(n) && n > acc ? n : acc;
-      }, 1000);
-      customerNumber = String(max + 1);
-    }
+      // Auto-assign the next sequential number when none was provided; the
+      // per-org unique index guards against races and manual duplicates.
+      let customerNumber = data.customerNumber?.trim() || null
+      if (!customerNumber) {
+        const existing = await db.customer.findMany({
+          where: { organizationId, customerNumber: { not: null } },
+          select: { customerNumber: true },
+        })
+        const max = existing.reduce((acc, c) => {
+          const n = Number.parseInt(c.customerNumber ?? '', 10)
+          return Number.isFinite(n) && n > acc ? n : acc
+        }, 1000)
+        customerNumber = String(max + 1)
+      }
 
-    try {
-      const customer = await db.customer.create({
-        data: {
-          ...data,
-          customerNumber,
-          email: data.email || null,
-          userId,
-          organizationId,
-        },
-      });
-      revalidatePath("/customers");
-      return customer;
-    } catch (err: unknown) {
-      if (err && typeof err === "object" && (err as { code?: string }).code === "P2002") {
-        throw new Error("Customer number is already in use");
+      try {
+        const customer = await db.customer.create({
+          data: {
+            ...data,
+            customerNumber,
+            email: data.email || null,
+            userId,
+            organizationId,
+          },
+        })
+        revalidatePath('/customers')
+        return customer
+      } catch (err: unknown) {
+        if (err && typeof err === 'object' && (err as { code?: string }).code === 'P2002') {
+          throw new Error('Customer number is already in use')
+        }
+        throw err
       }
-      throw err;
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+      audit: ({ result }) => ({
+        action: 'customer.create',
+        entity: 'Customer',
+        entityId: result.id,
+        details: { key: 'customer_create', params: { name: result.name } },
+        metadata: { customerId: result.id },
+      }),
     }
-  }, {
-    requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS }],
-    audit: ({ result }) => ({
-      action: "customer.create",
-      entity: "Customer",
-      entityId: result.id,
-      details: { key: "customer_create", params: { name: result.name } },
-      metadata: { customerId: result.id },
-    }),
-  });
+  )
 }
 
 export async function updateCustomer(input: unknown) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const { id, ...data } = updateCustomerSchema.parse(input);
-    let result;
-    try {
-      result = await db.customer.updateMany({
-        where: { id, organizationId },
-        data: {
-          ...data,
-          customerNumber:
-            data.customerNumber !== undefined
-              ? data.customerNumber.trim() || null
-              : undefined,
-          email: data.email || null,
-          company: data.company || null,
-          phone: data.phone || null,
-          address: data.address || null,
-        },
-      });
-    } catch (err: unknown) {
-      if (err && typeof err === "object" && (err as { code?: string }).code === "P2002") {
-        throw new Error("Customer number is already in use");
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const { id, ...data } = updateCustomerSchema.parse(input)
+      let result
+      try {
+        result = await db.customer.updateMany({
+          where: { id, organizationId },
+          data: {
+            ...data,
+            customerNumber:
+              data.customerNumber !== undefined ? data.customerNumber.trim() || null : undefined,
+            email: data.email || null,
+            company: data.company || null,
+            phone: data.phone || null,
+            address: data.address || null,
+          },
+        })
+      } catch (err: unknown) {
+        if (err && typeof err === 'object' && (err as { code?: string }).code === 'P2002') {
+          throw new Error('Customer number is already in use')
+        }
+        throw err
       }
-      throw err;
+      if (result.count === 0) throw new Error('Customer not found')
+      revalidatePath('/customers')
+      revalidatePath(`/customers/${id}`)
+      return { id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+      audit: ({ result }) => ({
+        action: 'customer.update',
+        entity: 'Customer',
+        entityId: result.id,
+        details: { key: 'customer_update', params: { id: result.id } },
+        metadata: { customerId: result.id },
+      }),
     }
-    if (result.count === 0) throw new Error("Customer not found");
-    revalidatePath("/customers");
-    revalidatePath(`/customers/${id}`);
-    return { id };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }],
-    audit: ({ result }) => ({
-      action: "customer.update",
-      entity: "Customer",
-      entityId: result.id,
-      details: { key: "customer_update", params: { id: result.id } },
-      metadata: { customerId: result.id },
-    }),
-  });
+  )
 }
 
 export async function deleteCustomer(customerId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const result = await db.customer.deleteMany({ where: { id: customerId, organizationId } });
-    if (result.count === 0) throw new Error("Customer not found");
-    revalidatePath("/customers");
-    return { customerId };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS }],
-    audit: ({ result }) => ({
-      action: "customer.delete",
-      entity: "Customer",
-      entityId: result.customerId,
-      details: { key: "customer_delete", params: { id: result.customerId } },
-      metadata: { customerId: result.customerId },
-    }),
-  });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const result = await db.customer.deleteMany({ where: { id: customerId, organizationId } })
+      if (result.count === 0) throw new Error('Customer not found')
+      revalidatePath('/customers')
+      return { customerId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+      ],
+      audit: ({ result }) => ({
+        action: 'customer.delete',
+        entity: 'Customer',
+        entityId: result.customerId,
+        details: { key: 'customer_delete', params: { id: result.customerId } },
+        metadata: { customerId: result.customerId },
+      }),
+    }
+  )
 }
 
 export async function deleteCustomers(customerIds: string[]) {
-  return withAuth(async ({ userId, organizationId }) => {
-    if (customerIds.length === 0) throw new Error("No customers selected");
-    const result = await db.customer.deleteMany({
-      where: { id: { in: customerIds }, organizationId },
-    });
-    revalidatePath("/customers");
-    return { deleted: result.count };
-  }, { requiredPermissions: [{ action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      if (customerIds.length === 0) throw new Error('No customers selected')
+      const result = await db.customer.deleteMany({
+        where: { id: { in: customerIds }, organizationId },
+      })
+      revalidatePath('/customers')
+      return { deleted: result.count }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.DELETE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 /**
@@ -180,344 +217,459 @@ export async function deleteCustomers(customerIds: string[]) {
  * that already have a number — auto or manual — are never touched.
  */
 export async function backfillCustomerNumbers() {
-  return withAuth(async ({ organizationId }) => {
-    const [numbered, unnumbered] = await Promise.all([
-      db.customer.findMany({
-        where: { organizationId, customerNumber: { not: null } },
-        select: { customerNumber: true },
-      }),
-      db.customer.findMany({
-        where: { organizationId, customerNumber: null },
-        select: { id: true },
-        orderBy: { createdAt: "asc" },
-      }),
-    ]);
-
-    let next = numbered.reduce((acc, c) => {
-      const n = Number.parseInt(c.customerNumber ?? "", 10);
-      return Number.isFinite(n) && n > acc ? n : acc;
-    }, 1000) + 1;
-
-    await db.$transaction(
-      unnumbered.map((c) =>
-        db.customer.update({
-          where: { id: c.id },
-          data: { customerNumber: String(next++) },
-        })
+  return withAuth(
+    async ({ organizationId }) => {
+      const [numbered, unnumbered] = await Promise.all([
+        db.customer.findMany({
+          where: { organizationId, customerNumber: { not: null } },
+          select: { customerNumber: true },
+        }),
+        db.customer.findMany({
+          where: { organizationId, customerNumber: null },
+          select: { id: true },
+          orderBy: { createdAt: 'asc' },
+        }),
+      ])
+
+      let next =
+        numbered.reduce((acc, c) => {
+          const n = Number.parseInt(c.customerNumber ?? '', 10)
+          return Number.isFinite(n) && n > acc ? n : acc
+        }, 1000) + 1
+
+      await db.$transaction(
+        unnumbered.map((c) =>
+          db.customer.update({
+            where: { id: c.id },
+            data: { customerNumber: String(next++) },
+          })
+        )
       )
-    );
-
-    revalidatePath("/customers");
-    revalidatePath("/settings/invoice");
-    return { assigned: unnumbered.length };
-  }, {
-    requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }],
-    audit: ({ result }) => ({
-      action: "customer.backfillNumbers",
-      entity: "Customer",
-      details: { key: "customer_backfillNumbers", params: { count: result.assigned } },
-      metadata: { assigned: result.assigned },
-    }),
-  });
+
+      revalidatePath('/customers')
+      revalidatePath('/settings/invoice')
+      return { assigned: unnumbered.length }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+      audit: ({ result }) => ({
+        action: 'customer.backfillNumbers',
+        entity: 'Customer',
+        details: { key: 'customer_backfillNumbers', params: { count: result.assigned } },
+        metadata: { assigned: result.assigned },
+      }),
+    }
+  )
 }
 
 export async function getCustomersPaginated(params: {
-  page?: number;
-  pageSize?: number;
-  search?: string;
-  sortBy?: string;
-  sortOrder?: "asc" | "desc";
+  page?: number
+  pageSize?: number
+  search?: string
+  sortBy?: string
+  sortOrder?: 'asc' | 'desc'
 }) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const page = params.page || 1;
-    const pageSize = params.pageSize || 20;
-    const skip = (page - 1) * pageSize;
-
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const where: any = { organizationId };
-
-    if (params.search) {
-      const words = params.search.trim().split(/\s+/).filter(Boolean);
-      if (words.length > 1) {
-        where.AND = words.map((word: string) => ({
-          OR: [
-            { name: { contains: word, mode: "insensitive" } },
-            { email: { contains: word, mode: "insensitive" } },
-            { phone: { contains: word, mode: "insensitive" } },
-            { company: { contains: word, mode: "insensitive" } },
-            { customerNumber: { contains: word, mode: "insensitive" } },
-          ],
-        }));
-      } else {
-        where.OR = [
-          { name: { contains: params.search, mode: "insensitive" } },
-          { email: { contains: params.search, mode: "insensitive" } },
-          { phone: { contains: params.search, mode: "insensitive" } },
-          { company: { contains: params.search, mode: "insensitive" } },
-          { customerNumber: { contains: params.search, mode: "insensitive" } },
-        ];
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const page = params.page || 1
+      const pageSize = params.pageSize || 20
+      const skip = (page - 1) * pageSize
+
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const where: any = { organizationId }
+
+      if (params.search) {
+        const words = params.search.trim().split(/\s+/).filter(Boolean)
+        if (words.length > 1) {
+          where.AND = words.map((word: string) => ({
+            OR: [
+              { name: { contains: word, mode: 'insensitive' } },
+              { email: { contains: word, mode: 'insensitive' } },
+              { phone: { contains: word, mode: 'insensitive' } },
+              { company: { contains: word, mode: 'insensitive' } },
+              { customerNumber: { contains: word, mode: 'insensitive' } },
+            ],
+          }))
+        } else {
+          where.OR = [
+            { name: { contains: params.search, mode: 'insensitive' } },
+            { email: { contains: params.search, mode: 'insensitive' } },
+            { phone: { contains: params.search, mode: 'insensitive' } },
+            { company: { contains: params.search, mode: 'insensitive' } },
+            { customerNumber: { contains: params.search, mode: 'insensitive' } },
+          ]
+        }
       }
+
+      const [customers, total] = await Promise.all([
+        db.customer.findMany({
+          where,
+          include: {
+            _count: { select: { vehicles: true } },
+          },
+          orderBy: (() => {
+            const dir = params.sortOrder || 'desc'
+            const nullable = { sort: dir, nulls: 'last' } as const
+            switch (params.sortBy) {
+              case 'number':
+                return { customerNumber: { sort: dir, nulls: 'last' as const } }
+              case 'name':
+                return { name: dir }
+              case 'company':
+                return { company: nullable }
+              case 'phone':
+                return { phone: nullable }
+              case 'email':
+                return { email: nullable }
+              case 'vehicles':
+                return { vehicles: { _count: dir } }
+              default:
+                return { updatedAt: 'desc' as const }
+            }
+          })(),
+          skip,
+          take: pageSize,
+        }),
+        db.customer.count({ where }),
+      ])
+
+      return {
+        customers,
+        total,
+        page,
+        pageSize,
+        totalPages: Math.ceil(total / pageSize),
+      }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
     }
+  )
+}
 
-    const [customers, total] = await Promise.all([
-      db.customer.findMany({
-        where,
-        include: {
-          _count: { select: { vehicles: true } },
+/**
+ * Pending booking requests from the customer portal, newest first.
+ *
+ * These are customers asking for work. Until now they only reached the shop
+ * as an alert on the way in and a tab on one customer's page, so a missed
+ * alert meant a request nobody was looking for. The dashboard needs them in
+ * one place, which is what this feeds.
+ */
+export async function getPendingServiceRequests(limit = 5) {
+  return withAuth(
+    async ({ organizationId }) => {
+      return db.serviceRequest.findMany({
+        where: { organizationId, status: 'pending' },
+        select: {
+          id: true,
+          description: true,
+          preferredDate: true,
+          createdAt: true,
+          customer: { select: { id: true, name: true } },
+          vehicle: {
+            select: { id: true, make: true, model: true, year: true, licensePlate: true },
+          },
         },
-        orderBy: (() => {
-          const dir = params.sortOrder || "desc";
-          const nullable = { sort: dir, nulls: "last" } as const;
-          switch (params.sortBy) {
-            case "number": return { customerNumber: { sort: dir, nulls: "last" as const } };
-            case "name": return { name: dir };
-            case "company": return { company: nullable };
-            case "phone": return { phone: nullable };
-            case "email": return { email: nullable };
-            case "vehicles": return { vehicles: { _count: dir } };
-            default: return { updatedAt: "desc" as const };
-          }
-        })(),
-        skip,
-        take: pageSize,
-      }),
-      db.customer.count({ where }),
-    ]);
-
-    return {
-      customers,
-      total,
-      page,
-      pageSize,
-      totalPages: Math.ceil(total / pageSize),
-    };
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] });
+        orderBy: { createdAt: 'desc' },
+        take: limit,
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function updateServiceRequest(
   requestId: string,
-  data: { status?: string; adminNotes?: string },
+  data: { status?: string; adminNotes?: string }
 ) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const result = await db.serviceRequest.updateMany({
-      where: { id: requestId, organizationId },
-      data,
-    });
-    if (result.count === 0) throw new Error("Service request not found");
-    revalidatePath("/customers");
-    return { id: requestId };
-  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const result = await db.serviceRequest.updateMany({
+        where: { id: requestId, organizationId },
+        data,
+      })
+      if (result.count === 0) throw new Error('Service request not found')
+      revalidatePath('/customers')
+      return { id: requestId }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function createWorkOrderFromRequest(requestId: string) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const request = await db.serviceRequest.findFirst({
-      where: { id: requestId, organizationId },
-      include: { vehicle: { select: { id: true } } },
-    });
-    if (!request) throw new Error("Service request not found");
-    if (request.status === "converted") throw new Error("Work order already created for this request");
-
-    const vehicleId = request.vehicleId;
-    const serviceDate = request.preferredDate ?? undefined;
-
-    const result = await createDraftServiceRecord(vehicleId, serviceDate);
-    if (!result.success || !result.data) {
-      throw new Error(result.error ?? "Failed to create work order");
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const request = await db.serviceRequest.findFirst({
+        where: { id: requestId, organizationId },
+        include: { vehicle: { select: { id: true } } },
+      })
+      if (!request) throw new Error('Service request not found')
+      if (request.status === 'converted')
+        throw new Error('Work order already created for this request')
+
+      const vehicleId = request.vehicleId
+      const serviceDate = request.preferredDate ?? undefined
+
+      const result = await createDraftServiceRecord(vehicleId, serviceDate)
+      if (!result.success || !result.data) {
+        throw new Error(result.error ?? 'Failed to create work order')
+      }
+      const record = result.data
+
+      const truncatedTitle =
+        request.description.length > 60
+          ? request.description.slice(0, 57) + '...'
+          : request.description
+
+      await db.serviceRecord.update({
+        where: { id: record.id },
+        data: {
+          title: truncatedTitle,
+          description: request.description,
+        },
+      })
+
+      const existingNotes = request.adminNotes ? `${request.adminNotes}\n` : ''
+      await db.serviceRequest.update({
+        where: { id: requestId },
+        data: {
+          status: 'converted',
+          adminNotes: `${existingNotes}Work Order: ${record.id}`,
+        },
+      })
+
+      revalidatePath('/customers')
+      return { vehicleId, serviceRecordId: record.id }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS },
+      ],
     }
-    const record = result.data;
-
-    const truncatedTitle = request.description.length > 60
-      ? request.description.slice(0, 57) + "..."
-      : request.description;
-
-    await db.serviceRecord.update({
-      where: { id: record.id },
-      data: {
-        title: truncatedTitle,
-        description: request.description,
-      },
-    });
-
-    const existingNotes = request.adminNotes ? `${request.adminNotes}\n` : "";
-    await db.serviceRequest.update({
-      where: { id: requestId },
-      data: {
-        status: "converted",
-        adminNotes: `${existingNotes}Work Order: ${record.id}`,
-      },
-    });
-
-    revalidatePath("/customers");
-    return { vehicleId, serviceRecordId: record.id };
-  }, { requiredPermissions: [{ action: PermissionAction.UPDATE, subject: PermissionSubject.CUSTOMERS }] });
+  )
 }
 
 const importCustomerRowSchema = z.object({
   name: z.string().min(1),
-  email: z.string().email().optional().or(z.literal("")),
+  email: z.string().email().optional().or(z.literal('')),
   phone: z.string().optional(),
   company: z.string().optional(),
   address: z.string().optional(),
-});
+})
 
 export async function checkDuplicateCustomers(
   rows: { name: string; email?: string; phone?: string }[]
 ) {
-  return withAuth(async ({ organizationId }) => {
-    const existing = await db.customer.findMany({
-      where: { organizationId },
-      select: { id: true, name: true, email: true, phone: true, company: true, address: true },
-    });
-
-    const duplicates: Record<number, { id: string; name: string; matchedOn: string; isExact: boolean }> = {};
-
-    for (let i = 0; i < rows.length; i++) {
-      const row = rows[i];
-      for (const ex of existing) {
-        // Match by email (non-empty)
-        if (row.email && ex.email && row.email.toLowerCase() === ex.email.toLowerCase()) {
-          const isExact = ex.name.toLowerCase() === row.name.toLowerCase()
-            && (ex.phone || "") === (row.phone || "");
-          duplicates[i] = { id: ex.id, name: ex.name, matchedOn: "email", isExact };
-          break;
-        }
-        // Match by phone (non-empty)
-        if (row.phone && ex.phone && row.phone.replace(/\D/g, "") === ex.phone.replace(/\D/g, "")) {
-          const isExact = ex.name.toLowerCase() === row.name.toLowerCase()
-            && (ex.email || "").toLowerCase() === (row.email || "").toLowerCase();
-          duplicates[i] = { id: ex.id, name: ex.name, matchedOn: "phone", isExact };
-          break;
-        }
-        // Match by name (case-insensitive)
-        if (ex.name.toLowerCase() === row.name.toLowerCase()) {
-          const isExact = (ex.email || "").toLowerCase() === (row.email || "").toLowerCase()
-            && (ex.phone || "") === (row.phone || "");
-          duplicates[i] = { id: ex.id, name: ex.name, matchedOn: "name", isExact };
-          break;
+  return withAuth(
+    async ({ organizationId }) => {
+      const existing = await db.customer.findMany({
+        where: { organizationId },
+        select: { id: true, name: true, email: true, phone: true, company: true, address: true },
+      })
+
+      const duplicates: Record<
+        number,
+        { id: string; name: string; matchedOn: string; isExact: boolean }
+      > = {}
+
+      for (let i = 0; i < rows.length; i++) {
+        const row = rows[i]
+        for (const ex of existing) {
+          // Match by email (non-empty)
+          if (row.email && ex.email && row.email.toLowerCase() === ex.email.toLowerCase()) {
+            const isExact =
+              ex.name.toLowerCase() === row.name.toLowerCase() &&
+              (ex.phone || '') === (row.phone || '')
+            duplicates[i] = { id: ex.id, name: ex.name, matchedOn: 'email', isExact }
+            break
+          }
+          // Match by phone (non-empty)
+          if (
+            row.phone &&
+            ex.phone &&
+            row.phone.replace(/\D/g, '') === ex.phone.replace(/\D/g, '')
+          ) {
+            const isExact =
+              ex.name.toLowerCase() === row.name.toLowerCase() &&
+              (ex.email || '').toLowerCase() === (row.email || '').toLowerCase()
+            duplicates[i] = { id: ex.id, name: ex.name, matchedOn: 'phone', isExact }
+            break
+          }
+          // Match by name (case-insensitive)
+          if (ex.name.toLowerCase() === row.name.toLowerCase()) {
+            const isExact =
+              (ex.email || '').toLowerCase() === (row.email || '').toLowerCase() &&
+              (ex.phone || '') === (row.phone || '')
+            duplicates[i] = { id: ex.id, name: ex.name, matchedOn: 'name', isExact }
+            break
+          }
         }
       }
-    }
 
-    return duplicates;
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] });
+      return duplicates
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function importCustomers(
   rows: { name: string; email?: string; phone?: string; company?: string; address?: string }[],
-  mergeMap?: Record<number, string>, // rowIndex → existing customer ID to update
+  mergeMap?: Record<number, string> // rowIndex → existing customer ID to update
 ) {
-  return withAuth(async ({ userId, organizationId }) => {
-    const features = await getFeatures(organizationId);
-    const currentCount = await db.customer.count({ where: { organizationId } });
-
-    type ValidRow = { name: string; email: string | null; phone: string | null; company: string | null; address: string | null };
-    const toCreate: ValidRow[] = [];
-    const toMerge: { id: string; data: ValidRow }[] = [];
-    const errors: { row: number; error: string }[] = [];
-    let skipped = 0;
-
-    for (let i = 0; i < rows.length; i++) {
-      const result = importCustomerRowSchema.safeParse(rows[i]);
-      if (!result.success) {
-        errors.push({ row: i + 1, error: result.error.issues[0]?.message || "Invalid data" });
-        continue;
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      const features = await getFeatures(organizationId)
+      const currentCount = await db.customer.count({ where: { organizationId } })
+
+      type ValidRow = {
+        name: string
+        email: string | null
+        phone: string | null
+        company: string | null
+        address: string | null
       }
+      const toCreate: ValidRow[] = []
+      const toMerge: { id: string; data: ValidRow }[] = []
+      const errors: { row: number; error: string }[] = []
+      let skipped = 0
+
+      for (let i = 0; i < rows.length; i++) {
+        const result = importCustomerRowSchema.safeParse(rows[i])
+        if (!result.success) {
+          errors.push({ row: i + 1, error: result.error.issues[0]?.message || 'Invalid data' })
+          continue
+        }
 
-      const parsed: ValidRow = {
-        name: result.data.name,
-        email: result.data.email || null,
-        phone: result.data.phone || null,
-        company: result.data.company || null,
-        address: result.data.address || null,
-      };
-
-      const mergeId = mergeMap?.[i];
-      if (mergeId === "__skip__") {
-        skipped++;
-      } else if (mergeId) {
-        toMerge.push({ id: mergeId, data: parsed });
-      } else {
-        toCreate.push(parsed);
+        const parsed: ValidRow = {
+          name: result.data.name,
+          email: result.data.email || null,
+          phone: result.data.phone || null,
+          company: result.data.company || null,
+          address: result.data.address || null,
+        }
+
+        const mergeId = mergeMap?.[i]
+        if (mergeId === '__skip__') {
+          skipped++
+        } else if (mergeId) {
+          toMerge.push({ id: mergeId, data: parsed })
+        } else {
+          toCreate.push(parsed)
+        }
       }
-    }
 
-    const remaining = features.maxCustomers - currentCount;
-    if (toCreate.length > remaining) {
-      throw new FeatureGatedError(
-        "maxCustomers",
-        `Customer limit reached. You can import ${remaining} more customer(s). Upgrade your plan for more.`
-      );
-    }
+      const remaining = features.maxCustomers - currentCount
+      if (toCreate.length > remaining) {
+        throw new FeatureGatedError(
+          'maxCustomers',
+          `Customer limit reached. You can import ${remaining} more customer(s). Upgrade your plan for more.`
+        )
+      }
 
-    if (toCreate.length > 0) {
-      await db.customer.createMany({
-        data: toCreate.map((c) => ({ ...c, userId, organizationId })),
-      });
-    }
+      if (toCreate.length > 0) {
+        await db.customer.createMany({
+          data: toCreate.map((c) => ({ ...c, userId, organizationId })),
+        })
+      }
 
-    let merged = 0;
-    for (const { id, data } of toMerge) {
-      const res = await db.customer.updateMany({
-        where: { id, organizationId },
-        data: {
-          name: data.name,
-          email: data.email,
-          phone: data.phone,
-          company: data.company,
-          address: data.address,
-        },
-      });
-      if (res.count > 0) merged++;
-    }
+      let merged = 0
+      for (const { id, data } of toMerge) {
+        const res = await db.customer.updateMany({
+          where: { id, organizationId },
+          data: {
+            name: data.name,
+            email: data.email,
+            phone: data.phone,
+            company: data.company,
+            address: data.address,
+          },
+        })
+        if (res.count > 0) merged++
+      }
 
-    revalidatePath("/customers");
-    return { imported: toCreate.length, merged, skipped, errors };
-  }, { requiredPermissions: [{ action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS }] });
+      revalidatePath('/customers')
+      return { imported: toCreate.length, merged, skipped, errors }
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.CREATE, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function getCustomersList() {
-  return withAuth(async ({ userId, organizationId }) => {
-    return db.customer.findMany({
-      where: { organizationId },
-      select: { id: true, name: true, company: true },
-      orderBy: { name: "asc" },
-    });
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] });
+  return withAuth(
+    async ({ userId, organizationId }) => {
+      return db.customer.findMany({
+        where: { organizationId },
+        select: { id: true, name: true, company: true },
+        orderBy: { name: 'asc' },
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
+    }
+  )
 }
 
 export async function searchCustomers(search?: string, limit = 20, offset = 0) {
-  return withAuth(async ({ organizationId }) => {
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    const where: any = { organizationId };
-    if (search) {
-      const words = search.trim().split(/\s+/).filter(Boolean);
-      if (words.length > 1) {
-        // Every word must match in at least one field
-        where.AND = words.map((word) => ({
-          OR: [
-            { name: { contains: word, mode: "insensitive" } },
-            { email: { contains: word, mode: "insensitive" } },
-            { phone: { contains: word, mode: "insensitive" } },
-            { company: { contains: word, mode: "insensitive" } },
-            { customerNumber: { contains: word, mode: "insensitive" } },
-          ],
-        }));
-      } else {
-        where.OR = [
-          { name: { contains: search, mode: "insensitive" } },
-          { email: { contains: search, mode: "insensitive" } },
-          { phone: { contains: search, mode: "insensitive" } },
-          { company: { contains: search, mode: "insensitive" } },
-        ];
+  return withAuth(
+    async ({ organizationId }) => {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const where: any = { organizationId }
+      if (search) {
+        const words = search.trim().split(/\s+/).filter(Boolean)
+        if (words.length > 1) {
+          // Every word must match in at least one field
+          where.AND = words.map((word) => ({
+            OR: [
+              { name: { contains: word, mode: 'insensitive' } },
+              { email: { contains: word, mode: 'insensitive' } },
+              { phone: { contains: word, mode: 'insensitive' } },
+              { company: { contains: word, mode: 'insensitive' } },
+              { customerNumber: { contains: word, mode: 'insensitive' } },
+            ],
+          }))
+        } else {
+          where.OR = [
+            { name: { contains: search, mode: 'insensitive' } },
+            { email: { contains: search, mode: 'insensitive' } },
+            { phone: { contains: search, mode: 'insensitive' } },
+            { company: { contains: search, mode: 'insensitive' } },
+          ]
+        }
       }
+      return db.customer.findMany({
+        where,
+        select: { id: true, name: true, company: true },
+        orderBy: { name: 'asc' },
+        skip: offset,
+        take: limit,
+      })
+    },
+    {
+      requiredPermissions: [
+        { action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS },
+      ],
     }
-    return db.customer.findMany({
-      where,
-      select: { id: true, name: true, company: true },
-      orderBy: { name: "asc" },
-      skip: offset,
-      take: limit,
-    });
-  }, { requiredPermissions: [{ action: PermissionAction.READ, subject: PermissionSubject.CUSTOMERS }] });
+  )
 }

+ 148 - 5
src/features/dashboard/Components/DashboardGrid.tsx

@@ -8,18 +8,40 @@ import { Skeleton } from '@/components/ui/skeleton'
 import {
   CARD_MIN_H,
   CARD_MIN_W,
+  COLLAPSED_CARD_H,
+  DEFAULT_LAYOUT,
   GRID_COLS,
   GRID_MARGIN,
   GRID_ROW_HEIGHT,
+  gridHeightPx,
   type CardLayout,
 } from '../dashboard-grid-config'
 
+/** Pixels gained or lost by changing a card's height by one grid row. */
+const ROW_STEP = GRID_ROW_HEIGHT + GRID_MARGIN[1]
+
+/** Long enough for the grid's 200ms height transition to finish first. */
+const SETTLE_MS = 260
+
 /**
  * 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.
+ *
+ * Cards are drawn no taller than they need to be. A stored height is a
+ * ceiling, not a target: once the grid has settled, a card takes the rows
+ * its content actually needs, and takes more back if that content grows.
+ * `collapsedIds` covers the one case measurement cannot see — an empty
+ * state fills its box by design, so it measures as exactly full — by
+ * dropping those cards straight to COLLAPSED_CARD_H.
+ *
+ * Both are display-only and neither pauses in edit mode, so opening it
+ * never makes the page jump. Both also stop the moment the user pulls a
+ * card's resize handle: that card is pinned from then on and keeps the
+ * height they gave it, since a fitting pass that quietly undoes a resize
+ * reads as a broken handle.
  */
 export function DashboardGrid({
   cards,
@@ -27,12 +49,15 @@ export function DashboardGrid({
   editing,
   onCardsCommit,
   cardNodes,
+  collapsedIds = [],
 }: {
   cards: Record<string, CardLayout>
   visibleIds: string[]
   editing: boolean
   onCardsCommit: (cards: Record<string, CardLayout>) => void
   cardNodes: Partial<Record<string, ReactNode>>
+  /** Ids of cards that currently have no rows to show. */
+  collapsedIds?: string[]
 }) {
   const { width, containerRef, mounted } = useContainerWidth()
   const [breakpoint, setBreakpoint] = useState('lg')
@@ -49,9 +74,59 @@ export function DashboardGrid({
   }, [mounted, width, ready])
   const interactive = editing && breakpoint === 'lg'
 
+  const collapsed = new Set(collapsedIds)
+  /** Heights measurement has settled on, keyed by card id. */
+  const [fitted, setFitted] = useState<Record<string, number>>({})
+
+  /** The height a card is actually drawn at, which is rarely its stored one. */
+  const drawnHeight = (id: string) => {
+    const card = cards[id]
+    if (card.pinH) return card.h
+    if (collapsed.has(id)) return Math.min(card.h, COLLAPSED_CARD_H)
+    return Math.min(card.h, fitted[id] ?? card.h)
+  }
+
+  // Measure once the grid has stopped moving. The answer is absolute rather
+  // than a nudge in one direction, so a card lands on its height in a single
+  // pass; returning the previous state unchanged ends the loop, since
+  // `fitted` is what re-runs this.
+  const idsKey = visibleIds.join('|')
+  const collapsedKey = collapsedIds.join('|')
+  useEffect(() => {
+    if (!ready || editing) return
+    const timer = setTimeout(() => {
+      const root = containerRef.current
+      if (!root) return
+      setFitted((prev) => {
+        const next = { ...prev }
+        let changed = false
+        for (const id of visibleIds) {
+          const stored = cards[id]
+          if (!stored || stored.pinH || collapsed.has(id)) continue
+          const wrap = root.querySelector<HTMLElement>(`[data-card-id="${CSS.escape(id)}"]`)
+          const scroller = wrap && contentRegion(wrap)
+          if (!scroller) continue
+          const current = prev[id] ?? stored.h
+          const needed = neededRows(wrap, scroller)
+          if (needed === null) continue
+          const want = Math.max(CARD_MIN_H, Math.min(stored.h, needed))
+          if (want !== current) {
+            next[id] = want
+            changed = true
+          }
+        }
+        return changed ? next : prev
+      })
+    }, SETTLE_MS)
+    return () => clearTimeout(timer)
+    // Keyed on joined ids rather than the arrays themselves, so a parent
+    // re-render does not restart the timer before it can fire.
+  }, [ready, editing, idsKey, collapsedKey, cards, fitted])
+
   const layout: Layout = visibleIds.map((id) => ({
     i: id,
     ...cards[id],
+    h: drawnHeight(id),
     minW: CARD_MIN_W,
     minH: CARD_MIN_H,
   }))
@@ -66,8 +141,21 @@ export function DashboardGrid({
       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 }
+      // A card is drawn at its fitted height, not its stored one. Coming
+      // back at exactly that height means nothing was resized, so keep what
+      // is stored; any other value is the user having pulled the handle,
+      // which both sets the height and pins it against further fitting.
+      const resized = item.h !== drawnHeight(id)
+      const h = resized ? item.h : prev.h
+      const pinH = prev.pinH || resized || undefined
+      if (
+        prev.x !== item.x ||
+        prev.y !== item.y ||
+        prev.w !== item.w ||
+        prev.h !== h ||
+        prev.pinH !== pinH
+      ) {
+        merged[id] = { x: item.x, y: item.y, w: item.w, h, ...(pinH ? { pinH } : {}) }
         changed = true
       }
     }
@@ -78,10 +166,16 @@ export function DashboardGrid({
     <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
+        // container width settles; the real grid mounts only at stable width.
+        // Height comes from the grid model, so the skeleton and the card that
+        // replaces it are the same size and nothing reflows on mount.
         <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" />
+            <Skeleton
+              key={i}
+              className="rounded-xl"
+              style={{ height: gridHeightPx(DEFAULT_LAYOUT.cards.maintenance.h) }}
+            />
           ))}
         </div>
       )}
@@ -104,7 +198,7 @@ export function DashboardGrid({
           onLayoutChange={handleLayoutChange}
         >
           {visibleIds.map((id) => (
-            <div key={id} className="dashboard-card-wrap relative">
+            <div key={id} data-card-id={id} className="dashboard-card-wrap relative">
               {cardNodes[id]}
               {editing && (
                 // Wiggle-mode overlay: makes the whole tile the drag surface
@@ -118,3 +212,52 @@ export function DashboardGrid({
     </div>
   )
 }
+
+/**
+ * Grid rows this card needs to show everything it has, or null if that
+ * cannot be worked out.
+ *
+ * Deliberately not `scrollHeight`: that never reports less than the box it
+ * is in, so a card with room to spare looks exactly like one filled to the
+ * brim. The content is measured from the top of its first child to the
+ * bottom of its last instead, which also picks up the margins between them,
+ * and the card's fixed furniture — header, tabs, footer — is added on top.
+ *
+ * An empty state is the case this still cannot see: it fills its box on
+ * purpose, so it measures as exactly full. Those cards come through
+ * `collapsedIds` instead.
+ */
+function neededRows(wrap: HTMLElement, scroller: HTMLElement): number | null {
+  const card = wrap.firstElementChild
+  if (!card) return null
+
+  let furniturePx = 0
+  for (const part of card.children) {
+    if (part !== scroller) furniturePx += part.getBoundingClientRect().height
+  }
+
+  const style = getComputedStyle(scroller)
+  const paddingPx = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom)
+  const kids = scroller.children
+  const contentPx =
+    kids.length === 0
+      ? 0
+      : kids[kids.length - 1].getBoundingClientRect().bottom - kids[0].getBoundingClientRect().top
+
+  // h rows measure h * GRID_ROW_HEIGHT + (h - 1) * marginY pixels.
+  const needed = furniturePx + paddingPx + contentPx + GRID_MARGIN[1]
+  return Math.ceil(needed / ROW_STEP)
+}
+
+/**
+ * The scrolling region of whichever card markup this tile holds: AppCard
+ * names its own, and legacy Card markup leaves it as the last child that is
+ * not a footer — the same rule the grid's CSS uses to pick the scroll area.
+ */
+function contentRegion(wrap: HTMLElement): HTMLElement | null {
+  const named = wrap.querySelector<HTMLElement>('[data-slot="app-card-content"]')
+  if (named) return named
+  const last = wrap.firstElementChild?.lastElementChild
+  if (!(last instanceof HTMLElement)) return null
+  return last.dataset.slot === 'app-card-footer' ? null : last
+}

+ 114 - 17
src/features/dashboard/dashboard-grid-config.ts

@@ -11,6 +11,7 @@ export const DASHBOARD_CARD_IDS = [
   'sms',
   'notifications',
   'inspections',
+  'serviceRequests',
   'quoteRequests',
   'quoteResponses',
   'recentCompleted',
@@ -27,10 +28,25 @@ export interface CardLayout {
   y: number
   w: number
   h: number
+  /**
+   * Set once the user has pulled this card's resize handle. Cards are
+   * otherwise drawn only as tall as their content needs, which would
+   * silently undo the height they just chose; a pinned card keeps `h`
+   * exactly, empty or not.
+   */
+  pinH?: boolean
 }
 
+/**
+ * Bumped when a card's default position moves and the new one should reach
+ * people who already have a layout saved. `normalizeLayout` discards the
+ * stored spot for the cards named in MOVED_AT and only for those, so
+ * everything else the user arranged survives the migration.
+ */
+export const LAYOUT_VERSION = 2
+
 export interface DashboardLayout {
-  version: 1
+  version: number
   /** Hidden card ids: built-in ids or custom:<widgetId> */
   hidden: string[]
   /** Positions keyed by built-in id or custom:<widgetId> */
@@ -43,6 +59,20 @@ export const GRID_MARGIN: [number, number] = [16, 16]
 export const CARD_MIN_W = 3
 export const CARD_MIN_H = 3
 
+/**
+ * Height a card is drawn at while it has nothing to show. A card keeps its
+ * grid tile whether or not it has rows, so an empty one used to reserve its
+ * full configured height for a single line of grey text. Collapsing to the
+ * minimum keeps the dashboard from being mostly blank on a quiet day; the
+ * user's stored height is untouched and comes back with the first row.
+ */
+export const COLLAPSED_CARD_H = CARD_MIN_H
+
+/** Pixel height of `h` grid rows, margins between them included. */
+export function gridHeightPx(h: number): number {
+  return h * GRID_ROW_HEIGHT + (h - 1) * GRID_MARGIN[1]
+}
+
 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 })
 
@@ -62,31 +92,79 @@ const full = (row: number, h = 5): CardLayout => ({ x: 0, y: row, w: 12, h })
  * cannot collide, and `inspections` takes the partner position.
  */
 export const DEFAULT_LAYOUT: DashboardLayout = {
-  version: 1,
+  version: LAYOUT_VERSION,
   hidden: [],
   cards: {
     // First-run checklist leads the grid; rows compact upward once it is
     // gone (dismissed or not offered), so established users see no hole.
     gettingStarted: full(0, 4),
-    maintenance: half(0, 4),
-    reminders: half(6, 4),
+    // Both are short lists by nature, so the row starts a notch under the
+    // five-row default. They move together: a half card whose partner is a
+    // different height leaves the two columns out of step for every row
+    // below it.
+    maintenance: half(0, 4, 4),
+    reminders: half(6, 4, 4),
     // One slot, two candidates.
     sms: half(0, 9),
     notifications: half(0, 9),
     inspections: half(6, 9),
-    quoteRequests: half(0, 14),
-    quoteResponses: half(6, 14),
-    recentActivity: half(0, 19),
-    recentObservations: half(6, 19),
-    recentCompleted: full(24),
-    activeJobs: full(29),
-    // Opt-in, and the odd card out when it is on: last, where a lone half is
-    // least disruptive. Hidden entirely when the module is off, so a shop
-    // without it never reserves the slot.
-    tireHotel: half(0, 34),
+    // The two things customers ask for, side by side.
+    serviceRequests: half(0, 14),
+    quoteRequests: half(6, 14),
+    quoteResponses: half(0, 19),
+    recentActivity: half(6, 19),
+    recentObservations: half(0, 24),
+    // Opt-in, and the odd card out when it is on. It pairs with the
+    // observations card here and sits above the full-width tables; with the
+    // module off, observations is the lone half on the lowest half row,
+    // which is the one place a hole is allowed. Hidden entirely when the
+    // module is off, so a shop without it never reserves the slot.
+    tireHotel: half(6, 24),
+    recentCompleted: full(29),
+    activeJobs: full(34),
+  },
+}
+
+/**
+ * Cards whose default position moved, with the version that moved them and
+ * where they should land in a layout the user has already arranged.
+ *
+ * The spot is worked out from that layout rather than copied from
+ * DEFAULT_LAYOUT: an absolute row means nothing once the user has moved
+ * things, and a card meant to sit above another can easily land below it.
+ */
+const MOVED: Partial<
+  Record<
+    DashboardCardId,
+    { version: number; place: (cards: Record<string, CardLayout>) => CardLayout }
+  >
+> = {
+  // Was bottom-left, under both full-width tables. Now the right column,
+  // directly above the completed-work table wherever that has ended up.
+  tireHotel: {
+    version: 2,
+    place: (cards) => ({
+      x: 6,
+      y: cards.recentCompleted.y,
+      w: 6,
+      h: DEFAULT_LAYOUT.cards.tireHotel.h,
+    }),
   },
 }
 
+/**
+ * Puts `card` at its spot in `cards`, pushing whatever starts at or below
+ * that row down far enough to make room. Compaction closes the gap this
+ * leaves beside it.
+ */
+function insertAt(cards: Record<string, CardLayout>, id: string, spot: CardLayout): void {
+  for (const [otherId, other] of Object.entries(cards)) {
+    if (otherId === id) continue
+    if (other.y >= spot.y) cards[otherId] = { ...other, y: other.y + spot.h }
+  }
+  cards[id] = spot
+}
+
 function isCardLayout(v: unknown): v is CardLayout {
   if (!v || typeof v !== 'object') return false
   const c = v as Record<string, unknown>
@@ -101,6 +179,7 @@ function clampCard(stored: CardLayout): CardLayout {
     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))),
+    ...(stored.pinH === true ? { pinH: true as const } : {}),
   }
 }
 
@@ -113,18 +192,20 @@ export function placeAtBottom(cards: Record<string, CardLayout>): CardLayout {
 /**
  * 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
+ * position, cards whose default has moved since the layout was saved take
+ * the new one, 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,
+    version: LAYOUT_VERSION,
     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>) : {}
+  const storedVersion = typeof data.version === 'number' ? data.version : 1
 
   if (Array.isArray(data.hidden)) {
     result.hidden = data.hidden.filter(
@@ -134,9 +215,25 @@ export function normalizeLayout(raw: unknown, customIds: string[] = []): Dashboa
 
   const cards =
     data.cards && typeof data.cards === 'object' ? (data.cards as Record<string, unknown>) : {}
+  const migrating: DashboardCardId[] = []
   for (const id of DASHBOARD_CARD_IDS) {
     const stored = cards[id]
-    if (isCardLayout(stored)) result.cards[id] = clampCard(stored)
+    if (!isCardLayout(stored)) continue
+    const moved = MOVED[id]
+    // A layout saved before this card moved gives up the spot it was stored
+    // in; one saved since is the user's own choice and is left alone. A
+    // layout with no position for the card at all just takes the default.
+    if (moved && storedVersion < moved.version) {
+      migrating.push(id)
+      continue
+    }
+    result.cards[id] = clampCard(stored)
+  }
+  // After every other card is back where the user left it, so a moved card
+  // can be placed relative to them.
+  for (const id of migrating) {
+    const moved = MOVED[id]
+    if (moved) insertAt(result.cards, id, moved.place(result.cards))
   }
   for (const id of customIds) {
     const stored = cards[id]

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio