diff --git a/DECISIONS.md b/DECISIONS.md index c80b493d..6025d5b0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -20,3 +20,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/articles/page.tsx b/app/(dashboard)/articles/page.tsx index 02210eb9..acfd241c 100644 --- a/app/(dashboard)/articles/page.tsx +++ b/app/(dashboard)/articles/page.tsx @@ -445,7 +445,21 @@ function ArticlesPageInner() { export default function ArticlesPage() { return ( - + +
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ + } + >
) diff --git a/app/(dashboard)/bookkeeping/loading.tsx b/app/(dashboard)/bookkeeping/loading.tsx new file mode 100644 index 00000000..9f3b1fca --- /dev/null +++ b/app/(dashboard)/bookkeeping/loading.tsx @@ -0,0 +1,26 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function BookkeepingLoading() { + return ( +
+
+ +
+ + +
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 7e880b10..976acf66 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -449,7 +449,21 @@ function CustomersPageInner() { export default function CustomersPage() { return ( - + +
+ + +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ + } + >
) diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 25b0ea78..d1097e71 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -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" > - )} diff --git a/app/(dashboard)/invoices/loading.tsx b/app/(dashboard)/invoices/loading.tsx new file mode 100644 index 00000000..79f60d2a --- /dev/null +++ b/app/(dashboard)/invoices/loading.tsx @@ -0,0 +1,31 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function InvoicesLoading() { + return ( +
+
+ +
+ + +
+
+ + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index 343beab8..e33ba4eb 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -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 = () => +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' diff --git a/app/(dashboard)/reports/loading.tsx b/app/(dashboard)/reports/loading.tsx new file mode 100644 index 00000000..d9312c8b --- /dev/null +++ b/app/(dashboard)/reports/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function ReportsLoading() { + return ( +
+
+ + +
+ +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ + + +
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/salary/loading.tsx b/app/(dashboard)/salary/loading.tsx new file mode 100644 index 00000000..4d5049e2 --- /dev/null +++ b/app/(dashboard)/salary/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function SalaryLoading() { + return ( +
+
+ +
+ + +
+
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ ) +} diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index eead113f..fb8c1cb5 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -149,12 +149,28 @@ export default function SalaryPage() { } if (loading) { + // Real header renders immediately; only the data surfaces are skeletons. return ( -
-
- - -
+
+ + + {canWrite && ( + + )} +
+ } + />
{[1, 2, 3].map(i => ( diff --git a/app/(dashboard)/settings/loading.tsx b/app/(dashboard)/settings/loading.tsx new file mode 100644 index 00000000..3edc8ae2 --- /dev/null +++ b/app/(dashboard)/settings/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton' + +export default function SettingsLoading() { + return ( +
+ +
+ +
+ +
+
+
+ ) +} diff --git a/app/(dashboard)/transactions/loading.tsx b/app/(dashboard)/transactions/loading.tsx new file mode 100644 index 00000000..4bd4c73e --- /dev/null +++ b/app/(dashboard)/transactions/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from '@/components/ui/skeleton' + +export default function TransactionsLoading() { + return ( +
+
+ + +
+
+ + +
+ +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+ ) +} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 40649262..eca527a9 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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, diff --git a/app/globals.css b/app/globals.css index c2ae627b..126fc4a6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 */ diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index f8314192..c7345ee8 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -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 = () => +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, diff --git a/components/settings/SettingsLoadingSkeleton.tsx b/components/settings/SettingsLoadingSkeleton.tsx index 8f24cd1c..270fa44e 100644 --- a/components/settings/SettingsLoadingSkeleton.tsx +++ b/components/settings/SettingsLoadingSkeleton.tsx @@ -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 ( -
- {[0, 1].map((block) => ( -
- - {[0, 1, 2].map((row) => ( -
+
+
+ +
+ {[0, 1].map((cell) => ( +
- +
))}
- ))} +
+ + +
+
+ {[0, 1].map((cell) => ( +
+ + +
+ ))} +
+
+ +
+
+
+ +
+ + +
+
) } diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 33c3fce3..ccb6bcdc 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -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 diff --git a/next.config.ts b/next.config.ts index 226f3ad6..e4cbcec0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,6 +42,9 @@ const nextConfig: NextConfig = { turbopack: { root: projectRoot, }, + experimental: { + optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'], + }, async redirects() { return [ {