fix: article ordering, free-text rows, invoice back-nav, onboarding resilience (#1053) (#1056)

* 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:
Jakob Wennberg
2026-07-17 14:45:43 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 0e9cca2750
commit 97907a5a5c
15 changed files with 320 additions and 45 deletions
+16 -11
View File
@@ -23,6 +23,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { sortArticles } from '@/lib/articles/sort'
import { getAmountToPay } from '@/lib/invoices/rounding'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark, Lock, AlertTriangle, MoreVertical, CalendarClock, Tags, Copy } from 'lucide-react'
@@ -479,8 +480,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
.select('id, article_number, name, unit, price_excl_vat, vat_rate, revenue_account, currency')
.eq('company_id', company.id)
.eq('active', true)
.order('name')
setArticles((data ?? []) as ArticleOption[])
// Numeric-aware order by article number ('2' before '10', unnumbered last):
// the picker should follow the user's own numbering, not the alphabet.
setArticles(sortArticles((data ?? []) as ArticleOption[]))
}
async function fetchRevenueAccounts() {
@@ -566,7 +568,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
throw new Error(getErrorMessage(result, { context: 'article', statusCode: response.status }))
}
const created = result.data as ArticleOption
setArticles((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name, 'sv')))
setArticles((prev) => sortArticles([...prev, created]))
setValue(`items.${index}.article_id`, created.id, { shouldDirty: true })
toast({ title: t('article_saved_title'), description: created.name })
} catch (error) {
@@ -956,7 +958,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
title: ts('created_title'),
description: ts('created_description', { number: data.external_invoice_number ?? '' }),
})
router.push(`/invoices/${result.data.id}`)
router.replace(`/invoices/${result.data.id}`)
} catch (error) {
toast({
title: ts('create_failed_title'),
@@ -1033,7 +1035,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (selectedCustomer?.email && createdInvoiceId && hasEmailSend) {
setShowSendPrompt(true)
} else if (createdInvoiceId) {
router.push(`/invoices/${createdInvoiceId}`)
router.replace(`/invoices/${createdInvoiceId}`)
}
}
@@ -1103,7 +1105,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} else if (selectedCustomer?.email && hasEmailSend) {
setShowSendPrompt(true)
} else {
router.push(`/invoices/${result.data.id}`)
router.replace(`/invoices/${result.data.id}`)
}
} catch (error) {
toast({
@@ -1162,7 +1164,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
title: t('toast_draft_saved_title'),
description: t('toast_draft_saved_description'),
})
router.push(`/invoices/${result.data.id}`)
// replace (here and in every post-save navigation): the editor page must
// drop out of history, or the detail page's back arrow reopens a fresh
// editor instead of returning to the list (issue #1053).
router.replace(`/invoices/${result.data.id}`)
} catch (error) {
toast({
title: t('save_draft_failed_title'),
@@ -1220,7 +1225,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
title: t('toast_draft_updated_title'),
description: t('toast_draft_updated_description'),
})
router.push(`/invoices/${initial.id}`)
router.replace(`/invoices/${initial.id}`)
} catch (error) {
toast({
title: t('update_failed_title'),
@@ -1259,7 +1264,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
} finally {
setIsSending(false)
setShowSendPrompt(false)
router.push(`/invoices/${createdInvoiceId}`)
router.replace(`/invoices/${createdInvoiceId}`)
}
}
@@ -2504,7 +2509,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<Dialog open={showSendPrompt} onOpenChange={(open) => {
if (!open && createdInvoiceId) {
setShowSendPrompt(false)
router.push(`/invoices/${createdInvoiceId}`)
router.replace(`/invoices/${createdInvoiceId}`)
}
}}>
<DialogContent>
@@ -2519,7 +2524,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
variant="outline"
onClick={() => {
setShowSendPrompt(false)
if (createdInvoiceId) router.push(`/invoices/${createdInvoiceId}`)
if (createdInvoiceId) router.replace(`/invoices/${createdInvoiceId}`)
}}
disabled={isSending}
>
+6 -4
View File
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { isTextLikeLine } from '@/lib/invoices/display'
import { itemHasAccrual } from '@/lib/bookkeeping/accruals/account-suggestions'
import type { Customer, Currency } from '@/types'
@@ -73,10 +74,11 @@ export function InvoiceReviewContent({
non_eu_business: t('customer_type_non_eu_business'),
}
// Calculate per-rate VAT breakdown (free-text rows carry no amounts).
// Calculate per-rate VAT breakdown (free-text and amount-less rows carry
// no amounts and must not seed an empty rate group).
const vatByRate = new Map<number, number>()
for (const item of items) {
if (item.line_type === 'text') continue
if (isTextLikeLine(item)) continue
const rate = item.vat_rate ?? 0
const lineTotal = item.quantity * item.unit_price
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
@@ -131,7 +133,7 @@ export function InvoiceReviewContent({
</thead>
<tbody>
{items.map((item, index) =>
item.line_type === 'text' ? (
isTextLikeLine(item) ? (
<tr key={index} className="border-b last:border-0">
<td className="py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
{item.description || ' '}
@@ -170,7 +172,7 @@ export function InvoiceReviewContent({
</div>
<div className="sm:hidden space-y-2">
{items.map((item, index) =>
item.line_type === 'text' ? (
isTextLikeLine(item) ? (
<p key={index} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
) : (
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">