From 17dc5f12f6440f34fe13ff66f6eb095ab8dc8393 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:39:49 +0200 Subject: [PATCH] fix(articles): non-SEK price support + reinstated deactivate (support: odinaero.se) (#1166) * fix(articles): stop losing and mislabeling non-SEK article prices Support report (odinaero.se): EUR article prices did not stick and the register showed every price in kr. Three concrete defects, one cause: articles.currency existed in the DB and API but the UI dropped it. - Edit dialog omitted currency from initialData, so ArticleForm fell back to SEK and every save silently reset an EUR article to SEK. - Register list and detail page formatted prices without the article's currency, rendering EUR amounts as "kr". - "Spara som artikel" in the invoice editor posted the line price without the invoice's currency, so lines from EUR invoices became SEK articles. - The xlsx/csv register export stamped the kr-suffixed currency format on every price; prices now use a new suffix-free decimalColumn and a Valuta column carries the per-article code. Follow-ups (not in this diff): the article importer does not detect a Valuta column yet, and the MCP create/update_article staged schemas have no currency param (agent-created articles stay SEK). Co-Authored-By: Claude Fable 5 * fix(articles): reinstate deactivate/activate on the article detail page Support report (odinaero.se): no button to set an article inactive. Commit 8a9a930f turned DELETE into a hard delete and removed the deactivate action, but hard delete is refused for articles referenced by invoice lines (ARTICLE_IN_USE), leaving used articles with no retire path even though the API, the list badge and the i18n keys for deactivation all still exist. Adds an Inaktivera/Aktivera button next to Redigera that PATCHes the active flag (confirm dialog on deactivate, none on reactivate) and stays on the page so the status badge reflects the change. Reuses the orphaned deactivate_* keys; adds the three missing activate_* keys in both locales. Co-Authored-By: Claude Fable 5 * fix(customers): stop resetting customer language to Swedish on every edit Same defect class as the article currency reset in this branch: the customer edit dialog's initialData omits language, CustomerForm defaults it to 'sv' and submits every field, and the PATCH route applies it. Editing any detail on an English-language customer silently flipped their invoice PDFs and emails back to Swedish. Found by a repo-wide sweep for hand-picked initialData edit dialogs; customers, suppliers and articles are the only three such call sites, and suppliers passes every form field already. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + app/(dashboard)/articles/[id]/page.tsx | 66 ++++++++++++++++++- app/(dashboard)/articles/page.tsx | 2 +- app/(dashboard)/customers/[id]/page.tsx | 3 + .../export/articles/__tests__/route.test.ts | 8 +++ app/api/export/articles/route.ts | 13 ++-- components/invoices/InvoiceEditor.tsx | 3 + lib/reports/__tests__/xlsx-export.test.ts | 19 ++++++ lib/reports/xlsx-export.ts | 18 ++++- messages/en.json | 3 + messages/sv.json | 3 + 11 files changed, 132 insertions(+), 8 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index b426d744..55345b76 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -369,3 +369,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-24] Declined compliance-bot ask to re-box the bolagsskattMissing warning (#1161): text-attn IS the locked house attention idiom (one ochre sentence, banners forbidden by design.md); PreviewStep keeps the inline action to the dispositions step, so salience + remediation path both remain. [2026-07-24] VAT RC checks proportional (0.5% + 1 kr tolerance) + latched stepper landing: the binary present/absent RC_BASIS_MISSING check cleared after one korrigering and hid a 38-voucher worklist behind "klart"; tolerance absorbs per-voucher basis rounding (moms/sats vs invoiced amount) without hiding a missing voucher; landing step latches once per period so a mid-work refetch cannot navigate the user off Kontrollera. [2026-07-25] Removed invented 6-month minimum for first räkenskapsår: BFL 3 kap 3 § sets no floor (Bolagsverket: "hur kort som helst", max 18 months); the check only existed for isFirstPeriod, exactly the case the law exempts, and blocked a customer shortening an autumn-registered first year to Dec 31. +[2026-07-25] Article EUR-price support bug: root cause was the edit dialog omitting currency from initialData (form defaulted SEK and PATCHed it back) plus kr-hardcoded formatCurrency calls; export gets a Valuta column + suffix-free decimalColumn instead of extending CURRENCY_FORMAT, importer Valuta detection deferred as follow-up to keep the diff scoped. +[2026-07-25] Reinstated article deactivation as an explicit PATCH active-toggle button on the detail page (support: odinaero.se) instead of reverting DELETE to soft-delete: 8a9a930f intentionally made DELETE hard-delete for unused articles, but that left invoice-referenced articles (ARTICLE_IN_USE) with no retire path; the old deactivate i18n keys were still in messages/ and are reused. diff --git a/app/(dashboard)/articles/[id]/page.tsx b/app/(dashboard)/articles/[id]/page.tsx index 9cf1d5fc..b52d91f0 100644 --- a/app/(dashboard)/articles/[id]/page.tsx +++ b/app/(dashboard)/articles/[id]/page.tsx @@ -18,6 +18,8 @@ import { import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import { + Archive, + ArchiveRestore, ArrowLeft, Package, Wrench, @@ -56,6 +58,7 @@ export default function ArticleDetailPage({ const [isEditOpen, setIsEditOpen] = useState(false) const [isUpdating, setIsUpdating] = useState(false) const [isDeleting, setIsDeleting] = useState(false) + const [isTogglingActive, setIsTogglingActive] = useState(false) const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() useEffect(() => { @@ -128,6 +131,46 @@ export default function ArticleDetailPage({ } } + // Soft retire/restore: the only path for articles already used on invoices, + // where hard delete is refused (ARTICLE_IN_USE) to keep invoice history. + async function handleToggleActive() { + if (!article) return + const deactivating = article.active + if (deactivating) { + const ok = await confirmAction({ + title: t('deactivate_confirm_title', { name: article.name }), + description: t('deactivate_confirm_description'), + confirmLabel: t('deactivate_confirm_label'), + variant: 'destructive', + }) + if (!ok) return + } + + setIsTogglingActive(true) + try { + const response = await fetch(`/api/articles/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ active: !article.active }), + }) + await throwOnStructuredError(response) + toast({ + title: deactivating ? t('deactivated_title') : t('activated_title'), + description: article.name, + }) + fetchArticle() + } catch (err) { + const body = (err as { body?: unknown }).body + toast({ + title: deactivating ? t('deactivate_failed_title') : t('activate_failed_title'), + description: getErrorMessage(body ?? err, { context: 'article', locale: errorLocale }), + variant: 'destructive', + }) + } finally { + setIsTogglingActive(false) + } + } + async function handleDelete() { if (!article) return const ok = await confirmAction({ @@ -217,6 +260,24 @@ export default function ArticleDetailPage({ {canWrite ? : } {t('edit')} +