perf: cross-system snappiness batch (middleware, loading states, bundle) (#909)
* perf: cross-system snappiness batch (middleware, loading states, bundle)
Middleware: resolve the active company at most once per request and run
the user_preferences + first-membership queries in parallel, cutting 1-2
sequential DB round trips from every authenticated page load.
Loading states: add loading.tsx skeletons for the six highest-traffic
dashboard routes, render the real salary page header during load instead
of a full-page skeleton, replace the blank fallback={null} Suspense
flashes on customers/articles, and reshape the settings skeleton to
match the actual form layout.
Bundle: defer recharts chart components via next/dynamic on the KPI page
and report views, replace the import page's framer-motion marching-ants
border with a CSS keyframe, and enable optimizePackageImports for
recharts/date-fns/framer-motion.
Transactions: extract the potential-match lookups into a shared parallel
helper; a single-query PostgREST embed is blocked until the
potential_supplier_invoice_id FK exists in prod (see DECISIONS.md).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(transactions): log potential-match query failures instead of dropping them
A DB error in the invoice/supplier-invoice hint lookups previously
surfaced as "no potential match"; log it so failures are diagnosable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -20,3 +20,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-06] v1 dimension value DELETE mirrors internal semantics (hard-delete unreferenced, 409 DIMENSION_VALUE_REFERENCED with archive hint otherwise) rather than DELETE=archive: identical behavior across dashboard and API beats a simpler mental model that would surprise users comparing the two surfaces. Value dates (end_date for projects) ride the existing PATCH; whole-dimension DELETE stays unsupported.
|
||||
[2026-07-06] Fastigheter-on-customers (item 3 of #895) deferred to a follow-up issue instead of shipping a quick column: single-default-property vs multi-property registry changes the data model and the ROT prefill UX; needs its own design pass.
|
||||
[2026-07-06] v1 articles endpoint is read-only list (GET) under invoices:read: the #895 ask is "pick articles when composing invoices via API", not article CRUD; linking article_id does not auto-fill line fields (caller copies price/VAT), matching how invoice_items freeze article data at write time.
|
||||
[2026-07-06] Kept two-step potential-match fetch on /transactions instead of single PostgREST embed: prod schema cache has no FK relationship for transactions.potential_supplier_invoice_id (PGRST200; migration 20260225100248 ADD COLUMN IF NOT EXISTS likely skipped the REFERENCES clause because the column pre-existed). Revisit after adding the FK via a new migration.
|
||||
|
||||
@@ -445,7 +445,21 @@ function ArticlesPageInner() {
|
||||
|
||||
export default function ArticlesPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ArticlesPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function BookkeepingLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-44" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-40" />
|
||||
<Skeleton className="h-10 w-44" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-56" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -449,7 +449,21 @@ function CustomersPageInner() {
|
||||
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CustomersPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -1861,7 +1860,7 @@ function CSVDataImportWizard() {
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -inset-[3px] h-[calc(100%+6px)] w-[calc(100%+6px)] overflow-visible"
|
||||
>
|
||||
<motion.rect
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="calc(100% - 2px)"
|
||||
@@ -1872,9 +1871,7 @@ function CSVDataImportWizard() {
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeDasharray="3 4"
|
||||
className="text-foreground/45"
|
||||
animate={{ strokeDashoffset: [0, -14] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity, ease: 'linear' }}
|
||||
className="animate-marching-ants text-foreground/45"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function InvoicesLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,10 +5,24 @@ import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { KPIHeroCards } from '@/components/kpi/KPIHeroCards'
|
||||
import { KPITrendChart } from '@/components/kpi/KPITrendChart'
|
||||
import { KPIExpenseMixChart } from '@/components/kpi/KPIExpenseMixChart'
|
||||
import { KPITopSuppliersChart } from '@/components/kpi/KPITopSuppliersChart'
|
||||
|
||||
// Recharts is ~180KB: defer the chart components so the KPI page shell and
|
||||
// hero cards render without waiting for the charting bundle.
|
||||
const chartFallback = () => <Skeleton className="h-[300px] w-full" />
|
||||
const KPITrendChart = dynamic(
|
||||
() => import('@/components/kpi/KPITrendChart').then((m) => m.KPITrendChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const KPIExpenseMixChart = dynamic(
|
||||
() => import('@/components/kpi/KPIExpenseMixChart').then((m) => m.KPIExpenseMixChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const KPITopSuppliersChart = dynamic(
|
||||
() => import('@/components/kpi/KPITopSuppliersChart').then((m) => m.KPITopSuppliersChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
import { KPISettingsDialog } from '@/components/kpi/KPISettingsDialog'
|
||||
import { getDefaultPreferences } from '@/lib/reports/kpi-definitions'
|
||||
import type { KPIReport, KPIPreferences } from '@/types'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ReportsLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-10 w-44" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-6 space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function SalaryLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<Skeleton className="h-10 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-28 rounded-lg" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -149,12 +149,28 @@ export default function SalaryPage() {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
// Real header renders immediately; only the data surfaces are skeletons.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-40" />
|
||||
<Skeleton className="h-9 w-32" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/salary/employees">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
{t('employees')}
|
||||
</Link>
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button disabled>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('start_run')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Skeleton className="h-28 rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
|
||||
export default function SettingsLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-8 w-44" />
|
||||
<div className="grid gap-8 md:grid-cols-[220px_1fr]">
|
||||
<aside className="hidden space-y-2 md:block">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</aside>
|
||||
<div className="min-w-0">
|
||||
<SettingsLoadingSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function TransactionsLoading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-10 w-36" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import Link from 'next/link'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -86,6 +87,45 @@ function buildSupplierInvoiceMap(
|
||||
}, {})
|
||||
}
|
||||
|
||||
// Fetch the potential invoice/supplier-invoice matches referenced by a page
|
||||
// of transactions in one parallel round trip. A single-query PostgREST embed
|
||||
// on potential_supplier_invoice_id is blocked until that FK exists in the
|
||||
// prod schema cache (see DECISIONS.md 2026-07-06).
|
||||
async function fetchPotentialMatches(
|
||||
supabase: SupabaseClient,
|
||||
rows: { potential_invoice_id: string | null; potential_supplier_invoice_id: string | null }[],
|
||||
) {
|
||||
const potentialInvoiceIds = rows
|
||||
.filter((t) => t.potential_invoice_id)
|
||||
.map((t) => t.potential_invoice_id)
|
||||
const potentialSupplierInvoiceIds = rows
|
||||
.filter((t) => t.potential_supplier_invoice_id)
|
||||
.map((t) => t.potential_supplier_invoice_id)
|
||||
|
||||
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
|
||||
potentialInvoiceIds.length > 0
|
||||
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
||||
: Promise.resolve({ data: null, error: null }),
|
||||
potentialSupplierInvoiceIds.length > 0
|
||||
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
|
||||
: Promise.resolve({ data: null, error: null }),
|
||||
])
|
||||
|
||||
// Non-fatal: the transaction list still renders without match hints, but
|
||||
// log so a DB failure isn't mistaken for "no potential match".
|
||||
if (invoiceResult.error) {
|
||||
console.error('[fetchPotentialMatches] invoices query failed', invoiceResult.error)
|
||||
}
|
||||
if (supplierInvoiceResult.error) {
|
||||
console.error('[fetchPotentialMatches] supplier_invoices query failed', supplierInvoiceResult.error)
|
||||
}
|
||||
|
||||
return {
|
||||
invoiceMap: buildInvoiceMap(invoiceResult.data),
|
||||
supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResult.data),
|
||||
}
|
||||
}
|
||||
|
||||
interface QuickReviewState {
|
||||
transaction: TransactionWithInvoice
|
||||
category: TransactionCategory
|
||||
@@ -371,24 +411,7 @@ export default function TransactionsPage() {
|
||||
}
|
||||
|
||||
const rows = txData || []
|
||||
const potentialInvoiceIds = rows
|
||||
.filter((t) => t.potential_invoice_id)
|
||||
.map((t) => t.potential_invoice_id)
|
||||
const potentialSupplierInvoiceIds = rows
|
||||
.filter((t) => t.potential_supplier_invoice_id)
|
||||
.map((t) => t.potential_supplier_invoice_id)
|
||||
|
||||
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
|
||||
potentialInvoiceIds.length > 0
|
||||
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
||||
: Promise.resolve({ data: null }),
|
||||
potentialSupplierInvoiceIds.length > 0
|
||||
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
|
||||
: Promise.resolve({ data: null }),
|
||||
])
|
||||
|
||||
const invoiceMap = buildInvoiceMap(invoiceResult.data)
|
||||
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
|
||||
const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, rows)
|
||||
|
||||
const transactionsWithInvoices: TransactionWithInvoice[] = rows.map((t) => ({
|
||||
...t,
|
||||
@@ -451,24 +474,7 @@ export default function TransactionsPage() {
|
||||
|
||||
setHasMore(txData.length >= PAGE_SIZE)
|
||||
|
||||
const potentialInvoiceIds = txData
|
||||
.filter((t) => t.potential_invoice_id)
|
||||
.map((t) => t.potential_invoice_id)
|
||||
const potentialSupplierInvoiceIds = txData
|
||||
.filter((t) => t.potential_supplier_invoice_id)
|
||||
.map((t) => t.potential_supplier_invoice_id)
|
||||
|
||||
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
|
||||
potentialInvoiceIds.length > 0
|
||||
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
|
||||
: Promise.resolve({ data: null }),
|
||||
potentialSupplierInvoiceIds.length > 0
|
||||
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
|
||||
: Promise.resolve({ data: null }),
|
||||
])
|
||||
|
||||
const invoiceMap = buildInvoiceMap(invoiceResult.data)
|
||||
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
|
||||
const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, txData)
|
||||
|
||||
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
|
||||
...t,
|
||||
|
||||
@@ -262,11 +262,23 @@ h1, h2, h3 {
|
||||
animation: typingDot 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Marching-ants dashed border used by the import entity selector. */
|
||||
@keyframes marchingAnts {
|
||||
to { stroke-dashoffset: -14; }
|
||||
}
|
||||
|
||||
.animate-marching-ants {
|
||||
animation: marchingAnts 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-typing-dot {
|
||||
animation: gentlePulse 1.6s ease-in-out infinite;
|
||||
transform: none;
|
||||
}
|
||||
.animate-marching-ants {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered entrance animation */
|
||||
|
||||
@@ -28,10 +28,24 @@ import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||||
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
|
||||
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
|
||||
import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart'
|
||||
|
||||
// Recharts is ~180KB: defer the chart components so report tables (the
|
||||
// regulated content) render without waiting for the charting bundle.
|
||||
const chartFallback = () => <Skeleton className="h-64 w-full" />
|
||||
const TrialBalanceChart = dynamic(
|
||||
() => import('@/components/reports/TrialBalanceChart').then((m) => m.TrialBalanceChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const VatCompositionChart = dynamic(
|
||||
() => import('@/components/reports/VatCompositionChart').then((m) => m.VatCompositionChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const IncomeExpenseChart = dynamic(
|
||||
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion'
|
||||
import type {
|
||||
ReportSourceLine,
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
/**
|
||||
* Placeholder shown while a settings section's data loads. Mirrors the real shape
|
||||
* of the section forms: an uppercase section heading followed by stacked
|
||||
* label/field rows, with a hairline divider between blocks: so the swap to live
|
||||
* Placeholder shown while a settings section's data loads. Mirrors the real
|
||||
* shape of the section forms (see CompanyInfoForm and SettingsFormWrapper):
|
||||
* an uppercase section heading, a two-column grid of label/field pairs,
|
||||
* full-width rows, and a right-aligned save button, so the swap to live
|
||||
* content doesn't jump from a mismatched layout.
|
||||
*/
|
||||
export function SettingsLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-300" aria-busy="true">
|
||||
{[0, 1].map((block) => (
|
||||
<div
|
||||
key={block}
|
||||
className={block === 0 ? 'space-y-4' : 'space-y-4 border-t border-border pt-8'}
|
||||
>
|
||||
<Skeleton className="h-3.5 w-32" />
|
||||
{[0, 1, 2].map((row) => (
|
||||
<div key={row} className="space-y-2">
|
||||
<div className="space-y-8" aria-busy="true">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{[0, 1].map((cell) => (
|
||||
<div key={cell} className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{[0, 1].map((cell) => (
|
||||
<div key={cell} className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Skeleton className="h-10 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 border-t border-border pt-8">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+34
-18
@@ -171,6 +171,13 @@ export async function updateSession(request: NextRequest) {
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
// Resolve the active company at most once per request: both the MFA
|
||||
// enrollment gate and the company-context block below need it, and the
|
||||
// resolution costs DB round trips.
|
||||
let resolvedCompany: { companyId: string | null; locale: string | null } | null = null
|
||||
const resolveCompanyOnce = async () =>
|
||||
(resolvedCompany ??= await resolveCompanyForMiddleware(supabase, user.id, request))
|
||||
|
||||
// MFA enforcement (application-side only, not RLS)
|
||||
if (shouldEnforceMfa(user)) {
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
||||
@@ -182,7 +189,7 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
// MFA required but user has no factor enrolled yet → force enrollment
|
||||
// Skip for users with no companies (still setting up)
|
||||
const { companyId: companyIdForMfa } = await resolveCompanyForMiddleware(supabase, user.id, request)
|
||||
const { companyId: companyIdForMfa } = await resolveCompanyOnce()
|
||||
if (companyIdForMfa) {
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors()
|
||||
const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified')
|
||||
@@ -199,7 +206,7 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
// Company context resolution
|
||||
const cookieCompanyId = request.cookies.get('gnubok-company-id')?.value
|
||||
const { companyId, locale: dbLocale } = await resolveCompanyForMiddleware(supabase, user.id, request)
|
||||
const { companyId, locale: dbLocale } = await resolveCompanyOnce()
|
||||
|
||||
// If the cookie pointed at a company we can no longer resolve (e.g.
|
||||
// archived), clear it so the browser stops sending it.
|
||||
@@ -293,16 +300,34 @@ async function resolveCompanyForMiddleware(
|
||||
userId: string,
|
||||
_request: NextRequest
|
||||
): Promise<{ companyId: string | null; locale: string | null }> {
|
||||
// 1. user_preferences (authoritative)
|
||||
const { data: prefs } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id, locale')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
// 1. user_preferences (authoritative) + first membership, fetched in
|
||||
// parallel: the fallback query result doubles as validation when the
|
||||
// preferred company happens to be the first membership, which is the
|
||||
// common single-company case, so most requests pay one round trip
|
||||
// instead of two sequential ones.
|
||||
const [{ data: prefs }, { data: firstCompany }] = await Promise.all([
|
||||
supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id, locale')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
const locale = (prefs?.locale as string | undefined) ?? null
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
if (prefs.active_company_id === firstCompany?.company_id) {
|
||||
return { companyId: firstCompany.company_id, locale }
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
@@ -314,16 +339,7 @@ async function resolveCompanyForMiddleware(
|
||||
if (membership) return { companyId: membership.company_id, locale }
|
||||
}
|
||||
|
||||
// 2. Fallback: first non-archived membership by created_at
|
||||
const { data: firstCompany } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
// 2. Fallback: first non-archived membership (already fetched above)
|
||||
if (!firstCompany) return { companyId: null, locale }
|
||||
|
||||
// Write the fallback back to user_preferences so future RLS lookups
|
||||
|
||||
@@ -42,6 +42,9 @@ const nextConfig: NextConfig = {
|
||||
turbopack: {
|
||||
root: projectRoot,
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user