* fix: article number ordering, free-text rows, invoice back-nav, onboarding resilience (#1053) Four fixes from Discord feedback in issue #1053: - Articles now order by article number with numeric-aware comparison ('2' before '10', unnumbered last, name tiebreak) in the invoice editor's article picker and as the register's default sort, via a shared lib/articles/sort.ts. Name order put article "1" last. - Invoice rows with no amounts (quantity 0, unit price 0) render as pure text rows on the PDF, the invoice detail page, and the review step via shared isTextLikeLine(), instead of printing "0 / 0,00 SEK / 0,00 SEK". Display-only; booking untouched. - The invoice editor navigates with router.replace after saving, so the detail page's back arrow returns to the list instead of reopening a fresh editor from history. - A transient query failure no longer reads as "no companies" / "onboarding not done": getActiveCompanyId throws CompanyContextError('resolution_failed') instead of returning null, the Edge middleware fails open on a degraded resolution (no onboarding redirect, no cookie clearing, no locale overwrite), and the dashboard page only redirects to /onboarding on a positively read incomplete/missing settings row. This is the likely cause of the completed onboarding wizard reappearing. Fixes #1053 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: CLAUDE.md tenancy line matches actual resolution order (prefs-first, cookie not read) The middleware stopped reading the gnubok-company-id cookie when user_preferences became authoritative (RLS parity); the stale doc line still described cookie-first order and misled review tooling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0e9cca2750
commit
97907a5a5c
@@ -31,6 +31,7 @@ import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { compareArticles } from '@/lib/articles/sort'
|
||||
import Link from 'next/link'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
@@ -76,9 +77,10 @@ function ArticlesPageInner() {
|
||||
|
||||
const sortParam = searchParams.get('sort')
|
||||
const dirParam = searchParams.get('dir')
|
||||
// Default to the user's own article numbering (numeric-aware), issue #1053.
|
||||
const sortColumn: SortColumn = (SORTABLE_COLUMNS as ReadonlyArray<string>).includes(sortParam ?? '')
|
||||
? (sortParam as SortColumn)
|
||||
: 'name'
|
||||
: 'article_number'
|
||||
const sortDir: SortDir = dirParam === 'desc' ? 'desc' : 'asc'
|
||||
|
||||
const updateSort = useCallback(
|
||||
@@ -193,7 +195,7 @@ function ArticlesPageInner() {
|
||||
cmp = compareStrings(a.name || '', b.name || '')
|
||||
break
|
||||
case 'article_number':
|
||||
cmp = compareStrings(a.article_number || '', b.article_number || '')
|
||||
cmp = compareArticles(a, b)
|
||||
break
|
||||
case 'type':
|
||||
cmp = compareStrings(a.type || '', b.type || '')
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate, cn } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { invoiceDisplayNumber } from '@/lib/invoices/display'
|
||||
import { invoiceDisplayNumber, isTextLikeLine } from '@/lib/invoices/display'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft'
|
||||
import { creditNoteNeedsJournalEntry } from '@/lib/invoices/issue-credit-note'
|
||||
@@ -820,7 +820,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
numeric columns; a blank one renders as a spacer. */}
|
||||
<div className="hidden sm:block space-y-4">
|
||||
{invoice.items.map((item) =>
|
||||
item.line_type === 'text' ? (
|
||||
isTextLikeLine(item) ? (
|
||||
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
|
||||
<div className="col-span-12 text-muted-foreground">{item.description || ' '}</div>
|
||||
</div>
|
||||
@@ -857,7 +857,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
{/* Items, mobile cards */}
|
||||
<div className="sm:hidden space-y-2">
|
||||
{invoice.items.map((item) =>
|
||||
item.line_type === 'text' ? (
|
||||
isTextLikeLine(item) ? (
|
||||
<p key={item.id} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
|
||||
) : (
|
||||
<div key={item.id} className="border rounded-lg p-3 text-sm space-y-1.5">
|
||||
|
||||
@@ -29,16 +29,20 @@ export default async function DashboardPage() {
|
||||
const rawCompanyId = cookieStore.get('gnubok-company-id')?.value
|
||||
?? await getActiveCompanyId(supabase, user.id)
|
||||
|
||||
// Validate the cookie/preference points to a company the user can access
|
||||
// Validate the cookie/preference points to a company the user can access.
|
||||
// Only a positive "no membership row" clears it: a FAILED query means the
|
||||
// membership is unknown, and treating that as absent bounced onboarded
|
||||
// users to the wizard on transient failures (issue #1053). RLS still
|
||||
// guards every downstream query if the cookie is stale.
|
||||
let companyId = rawCompanyId
|
||||
if (companyId) {
|
||||
const { data: membership } = await supabase
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (!membership) companyId = null
|
||||
if (!membership && !membershipError) companyId = null
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
@@ -54,7 +58,7 @@ export default async function DashboardPage() {
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [
|
||||
{ data: settings },
|
||||
settingsRes,
|
||||
{ count: customerCount },
|
||||
{ count: invoiceCount },
|
||||
{ count: receiptCount },
|
||||
@@ -71,7 +75,7 @@ export default async function DashboardPage() {
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
] = await Promise.all([
|
||||
supabase.from('company_settings').select('*').eq('company_id', companyId).single(),
|
||||
supabase.from('company_settings').select('*').eq('company_id', companyId).maybeSingle(),
|
||||
supabase.from('customers').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('invoices').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
supabase.from('receipts').select('*', { count: 'exact', head: true }).eq('company_id', companyId),
|
||||
@@ -100,6 +104,15 @@ export default async function DashboardPage() {
|
||||
listSuggestedMatches(supabase, companyId, 5),
|
||||
])
|
||||
|
||||
// A FAILED settings read must not masquerade as "onboarding not done":
|
||||
// that sent fully onboarded users back to the wizard on a transient query
|
||||
// failure (issue #1053). Throw to the error boundary (retryable) and only
|
||||
// redirect on a genuinely incomplete or missing settings row.
|
||||
const { data: settings, error: settingsError } = settingsRes
|
||||
if (settingsError) {
|
||||
throw new Error(`company_settings fetch failed: ${settingsError.message}`)
|
||||
}
|
||||
|
||||
// If onboarding is not complete, redirect to onboarding
|
||||
if (!settings?.onboarding_complete) {
|
||||
redirect('/onboarding')
|
||||
|
||||
Reference in New Issue
Block a user