diff --git a/app/(dashboard)/__tests__/detail-loading-states.test.ts b/app/(dashboard)/__tests__/detail-loading-states.test.ts new file mode 100644 index 00000000..6ea7be02 --- /dev/null +++ b/app/(dashboard)/__tests__/detail-loading-states.test.ts @@ -0,0 +1,41 @@ +/** + * Every [id] detail segment ships a route-level loading.tsx, and its client + * page no longer collapses into the bare centred spinner while it fetches: + * the RSC fallback and the client fallback share one silhouette + * (components/common/DetailPageSkeleton), so opening a row never flashes + * three unrelated layouts. + */ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +const APP = path.resolve(__dirname, '..') +const DETAIL_SEGMENTS = [ + 'customers/[id]', + 'invoices/[id]', + 'invoices/[id]/edit', + 'invoices/[id]/credit', + 'bookkeeping/[id]', + 'supplier-invoices/[id]', + 'suppliers/[id]', + 'articles/[id]', + 'salary/runs/[id]', +] +const LIST_SEGMENTS = ['customers', 'invoices', 'suppliers', 'articles', 'supplier-invoices'] + +describe('detail and list segments have a route-level loading state', () => { + for (const segment of [...DETAIL_SEGMENTS, ...LIST_SEGMENTS]) { + it(`${segment}/loading.tsx exists`, () => { + expect(fs.existsSync(path.join(APP, segment, 'loading.tsx'))).toBe(true) + }) + } +}) + +describe('detail pages do not gate on the bare centred spinner', () => { + for (const segment of DETAIL_SEGMENTS) { + it(`${segment}/page.tsx uses the shared skeleton, not h-64 + Loader2`, () => { + const source = fs.readFileSync(path.join(APP, segment, 'page.tsx'), 'utf8') + expect(source).not.toMatch(/justify-center h-64">\s* +} diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx index b2eef731..f9ba77e3 100644 --- a/app/(dashboard)/articles/[id]/page.tsx +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -22,6 +22,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatCurrency } from '@/lib/utils' import { parseArticleHouseworkType, workTypeLabel } from '@/lib/invoices/rot-rut-rules' import type { Article, ArticleType, CreateArticleInput } from '@/types' +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' const ARTICLE_TYPE_KEY: Record = { vara: 'type_vara', @@ -198,11 +199,7 @@ export default function ArticleDetailPage({ } if (isLoading) { - return ( -
- -
- ) + return } if (!article) return null diff --git a/app/(dashboard)/articles/loading.tsx b/app/(dashboard)/articles/loading.tsx new file mode 100644 index 00000000..ec892421 --- /dev/null +++ b/app/(dashboard)/articles/loading.tsx @@ -0,0 +1,39 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function ArticlesLoading() { + return ( +
+ {/* Title (24px) + export menu + "Ny artikel" action (pills) */} +
+ +
+ + +
+
+ + {/* Search toolbar */} + + + {/* Borderless table: header row + single-line rows */} +
+
+ + +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/bookkeeping/[id]/loading.tsx b/app/(dashboard)/bookkeeping/[id]/loading.tsx new file mode 100644 index 00000000..d8a9a8e1 --- /dev/null +++ b/app/(dashboard)/bookkeeping/[id]/loading.tsx @@ -0,0 +1,6 @@ +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the verifikat detail segment: same silhouette the page renders while it fetches. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index c255b32d..5621a83b 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -60,6 +60,7 @@ import { listContextKey } from '@/lib/navigation/list-context' import { useCompanyOptional } from '@/contexts/CompanyContext' import type { JournalEntry, JournalEntryLine } from '@/types' import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references' +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' // Snapshot of a struck line, as stored in journal_entry_rattelse_log. type StruckLineSnapshot = { @@ -344,12 +345,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i }, [fetchData]) if (isLoading) { - return ( -
- -

{t('loading')}

-
- ) + return } if (error || !entry) { diff --git a/app/(dashboard)/customers/[id]/loading.tsx b/app/(dashboard)/customers/[id]/loading.tsx new file mode 100644 index 00000000..7273a145 --- /dev/null +++ b/app/(dashboard)/customers/[id]/loading.tsx @@ -0,0 +1,6 @@ +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the customer detail segment: same silhouette the page renders while it fetches. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index bd92d6a0..e4cd0fee 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -24,6 +24,7 @@ import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-messag import { cn, formatDate } from '@/lib/utils' import { invoiceNumberDisplay } from '@/lib/invoices/display' import type { Customer, CustomerType, CreateCustomerInput } from '@/types' +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' const CUSTOMER_TYPE_KEY: Record = { individual: 'type_individual', @@ -201,11 +202,7 @@ export default function CustomerDetailPage({ } if (isLoading) { - return ( -
- -
- ) + return } if (!customer) return null diff --git a/app/(dashboard)/invoices/[id]/credit/loading.tsx b/app/(dashboard)/invoices/[id]/credit/loading.tsx new file mode 100644 index 00000000..5e67db26 --- /dev/null +++ b/app/(dashboard)/invoices/[id]/credit/loading.tsx @@ -0,0 +1,6 @@ +import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the credit-note segment: the editor's own silhouette. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/invoices/[id]/credit/page.tsx b/app/(dashboard)/invoices/[id]/credit/page.tsx index 8cff6be7..42b5819b 100644 --- a/app/(dashboard)/invoices/[id]/credit/page.tsx +++ b/app/(dashboard)/invoices/[id]/credit/page.tsx @@ -24,6 +24,7 @@ import { getCreditNoteSendMode } from '@/lib/invoices/credit-note-send-mode' import { creditConfirmNumber } from '@/lib/invoices/display' import type { Invoice, InvoiceItem, Customer } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton' interface InvoiceWithRelations extends Invoice { customer: Customer @@ -160,11 +161,7 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id: } if (isLoading) { - return ( -
- -
- ) + return } if (!invoice) { diff --git a/app/(dashboard)/invoices/[id]/edit/loading.tsx b/app/(dashboard)/invoices/[id]/edit/loading.tsx new file mode 100644 index 00000000..521fed19 --- /dev/null +++ b/app/(dashboard)/invoices/[id]/edit/loading.tsx @@ -0,0 +1,6 @@ +import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the invoice edit segment: the editor's own silhouette. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/invoices/[id]/edit/page.tsx b/app/(dashboard)/invoices/[id]/edit/page.tsx index f55e6e6f..c845d6a3 100644 --- a/app/(dashboard)/invoices/[id]/edit/page.tsx +++ b/app/(dashboard)/invoices/[id]/edit/page.tsx @@ -5,10 +5,10 @@ import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { createClient } from '@/lib/supabase/client' import { useToast } from '@/components/ui/use-toast' -import { Loader2 } from 'lucide-react' import InvoiceEditor, { type InvoiceForEdit } from '@/components/invoices/InvoiceEditor' import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' import type { InvoiceItem } from '@/types' +import { InvoiceEditorSkeleton } from '@/components/common/DetailPageSkeleton' /** * Edit an existing DRAFT invoice. Loads the invoice + items, guards that it is @@ -76,11 +76,7 @@ export default function EditInvoicePage({ params }: { params: Promise<{ id: stri }, [id]) if (isLoading || !invoice) { - return ( -
- -
- ) + return } return diff --git a/app/(dashboard)/invoices/[id]/loading.tsx b/app/(dashboard)/invoices/[id]/loading.tsx new file mode 100644 index 00000000..bfa151f0 --- /dev/null +++ b/app/(dashboard)/invoices/[id]/loading.tsx @@ -0,0 +1,6 @@ +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the invoice detail segment: same silhouette the page renders while it fetches. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 926e0f2a..287954df 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -98,6 +98,7 @@ const PEPPOL_STATUS_KEYS = new Set([ ]) const PEPPOL_SENDABLE_STATUSES = new Set(['draft', 'sent', 'overdue']) import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' // Why the downloaded file is not the invoice the customer received. One key // per reason: "no archived copy exists" and "the archive could not be reached" @@ -1171,11 +1172,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st } if (isLoading) { - return ( -
- -
- ) + return } if (!invoice) { diff --git a/app/(dashboard)/salary/runs/[id]/loading.tsx b/app/(dashboard)/salary/runs/[id]/loading.tsx new file mode 100644 index 00000000..4f7b2e90 --- /dev/null +++ b/app/(dashboard)/salary/runs/[id]/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@/components/ui/skeleton' + +/** Route-level fallback for a salary run: mirrors the page's own loading state (wide two-column layout). */ +export default function Loading() { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/app/(dashboard)/supplier-invoices/[id]/loading.tsx b/app/(dashboard)/supplier-invoices/[id]/loading.tsx new file mode 100644 index 00000000..2a11d029 --- /dev/null +++ b/app/(dashboard)/supplier-invoices/[id]/loading.tsx @@ -0,0 +1,6 @@ +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the supplier-invoice detail segment: same silhouette the page renders while it fetches. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 41de5c9d..37012955 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -40,6 +40,7 @@ import { DetailPager } from '@/components/common/DetailPager' import { listContextKey } from '@/lib/navigation/list-context' import { useCompanyOptional } from '@/contexts/CompanyContext' import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types' +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' interface EditableLine { account_number: string @@ -510,12 +511,7 @@ export default function SupplierInvoiceDetailPage() { } if (isLoading) { - return ( -
- - -
- ) + return } if (!invoice) { diff --git a/app/(dashboard)/suppliers/[id]/loading.tsx b/app/(dashboard)/suppliers/[id]/loading.tsx new file mode 100644 index 00000000..0efbcaaf --- /dev/null +++ b/app/(dashboard)/suppliers/[id]/loading.tsx @@ -0,0 +1,6 @@ +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' + +/** Route-level fallback for the supplier detail segment: same silhouette the page renders while it fetches. */ +export default function Loading() { + return +} diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index ef92a7f7..679adacf 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -4,7 +4,6 @@ import { useState, useEffect, useMemo } from 'react' import { useParams, useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' -import { Skeleton } from "@/components/ui/skeleton" import { Badge } from '@/components/ui/badge' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' @@ -19,6 +18,7 @@ import SupplierForm from '@/components/suppliers/SupplierForm' import Link from 'next/link' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types' +import { DetailPageSkeleton } from '@/components/common/DetailPageSkeleton' function formatAmount(amount: number): string { return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) @@ -145,12 +145,7 @@ export default function SupplierDetailPage() { } if (isLoading) { - return ( -
- - -
- ) + return } if (!supplier) { diff --git a/app/(dashboard)/suppliers/loading.tsx b/app/(dashboard)/suppliers/loading.tsx new file mode 100644 index 00000000..e941290c --- /dev/null +++ b/app/(dashboard)/suppliers/loading.tsx @@ -0,0 +1,39 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function SuppliersLoading() { + return ( +
+ {/* Title (24px) + export menu + "Ny leverantör" action (pills) */} +
+ +
+ + +
+
+ + {/* Search toolbar */} + + + {/* Borderless table: header row + single-line rows */} +
+
+ + +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ +
+ + +
+
+ ))} +
+
+ ) +} diff --git a/components/common/DetailPageSkeleton.tsx b/components/common/DetailPageSkeleton.tsx new file mode 100644 index 00000000..541043cc --- /dev/null +++ b/components/common/DetailPageSkeleton.tsx @@ -0,0 +1,86 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { cn } from '@/lib/utils' + +interface DetailPageSkeletonProps { + /** Number of summary cards under the title row. */ + cards?: 2 | 3 + /** Full-width variant for MainContainer's wide routes (max-w-7xl). */ + wide?: boolean + className?: string +} + +/** + * Silhouette of a register/document detail page: back link, title row with + * action pills, then a card grid. Used both as the route-level loading.tsx of + * the [id] segments and as the client page's own loading state, so the + * handoff from the RSC fallback to the client fetch is a no-op visually + * instead of skeleton -> centred spinner -> content (three unrelated layouts + * on the most-travelled drill-down path). + */ +export function DetailPageSkeleton({ cards = 2, wide = false, className }: DetailPageSkeletonProps) { + return ( +
+ {/* Back link */} + + + {/* Title (24px) + status pill + primary actions (pills) */} +
+
+ + +
+
+ + +
+
+ + {/* Card grid */} +
+ {Array.from({ length: cards }, (_, i) => ( +
+ + + + +
+ ))} +
+ + {/* Line/table block */} +
+
+ + +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+
+ ) +} + +/** + * Silhouette of the invoice editor (single-column snabbflöde): heading, the + * customer/details block and the lines block. Matches the fallback the + * "Ny faktura" dialog shows while the editor chunk loads. + */ +export function InvoiceEditorSkeleton({ className }: { className?: string }) { + return ( +
+ + + +
+ ) +}