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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -369,3 +369,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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 ? <Edit2 className="h-4 w-4 mr-1" /> : <Lock className="h-4 w-4 mr-1" />}
|
||||
{t('edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleToggleActive}
|
||||
disabled={isTogglingActive || !canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
>
|
||||
{isTogglingActive ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : !canWrite ? (
|
||||
<Lock className="h-4 w-4 mr-1" />
|
||||
) : article.active ? (
|
||||
<Archive className="h-4 w-4 mr-1" />
|
||||
) : (
|
||||
<ArchiveRestore className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{article.active ? t('deactivate') : t('activate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -247,7 +308,7 @@ export default function ArticleDetailPage({
|
||||
<CardContent className="space-y-3">
|
||||
<div className="text-sm flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t('label_price')}</span>
|
||||
<span className="tabular-nums">{formatCurrency(article.price_excl_vat)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(article.price_excl_vat, article.currency)}</span>
|
||||
</div>
|
||||
<div className="text-sm flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t('label_vat')}</span>
|
||||
@@ -260,7 +321,7 @@ export default function ArticleDetailPage({
|
||||
{article.cost_price != null && (
|
||||
<div className="text-sm flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t('label_cost_price')}</span>
|
||||
<span className="tabular-nums">{formatCurrency(article.cost_price)}</span>
|
||||
<span className="tabular-nums">{formatCurrency(article.cost_price, article.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -351,6 +412,7 @@ export default function ArticleDetailPage({
|
||||
unit: article.unit,
|
||||
price_excl_vat: article.price_excl_vat,
|
||||
vat_rate: article.vat_rate,
|
||||
currency: article.currency,
|
||||
revenue_account: article.revenue_account || undefined,
|
||||
cost_price: article.cost_price ?? undefined,
|
||||
ean: article.ean || undefined,
|
||||
|
||||
@@ -364,7 +364,7 @@ function ArticlesPageInner() {
|
||||
{article.unit}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
|
||||
{formatCurrency(article.price_excl_vat)}
|
||||
{formatCurrency(article.price_excl_vat, article.currency)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden whitespace-nowrap text-right tabular-nums text-muted-foreground sm:table-cell')}>
|
||||
{article.vat_rate} %
|
||||
|
||||
@@ -419,6 +419,9 @@ export default function CustomerDetailPage({
|
||||
org_number: customer.org_number || undefined,
|
||||
vat_number: customer.vat_number || undefined,
|
||||
personal_number: customer.personal_number || undefined,
|
||||
// Must round-trip: the form defaults omitted values ('sv') and
|
||||
// submits every field, so leaving language out resets it on save.
|
||||
language: customer.language,
|
||||
default_payment_terms: customer.default_payment_terms || undefined,
|
||||
notes: customer.notes || undefined,
|
||||
}}
|
||||
|
||||
@@ -32,6 +32,7 @@ const ARTICLE = {
|
||||
unit: 'st',
|
||||
price_excl_vat: 1200,
|
||||
vat_rate: 25,
|
||||
currency: 'EUR',
|
||||
revenue_account: '3001',
|
||||
cost_price: 400,
|
||||
ean: '7350000000001',
|
||||
@@ -80,6 +81,13 @@ describe('GET /api/export/articles', () => {
|
||||
expect(detected.price_col).not.toBeNull()
|
||||
expect(detected.vat_rate_col).not.toBeNull()
|
||||
expect(detected.revenue_account_col).not.toBeNull()
|
||||
|
||||
// Non-SEK prices export their currency; the price column must still map
|
||||
// to Försäljningspris, not be swallowed by the new Valuta column.
|
||||
const valutaIdx = headers.indexOf('Valuta')
|
||||
expect(valutaIdx).toBeGreaterThanOrEqual(0)
|
||||
expect((rows[1] as string[])[valutaIdx]).toBe('EUR')
|
||||
expect(headers[detected.price_col as number]).toBe('Försäljningspris')
|
||||
})
|
||||
|
||||
it('returns a UTF-8 BOM CSV when format=csv', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { textColumn, currencyColumn, integerColumn } from '@/lib/reports/xlsx-export'
|
||||
import { textColumn, decimalColumn, integerColumn } from '@/lib/reports/xlsx-export'
|
||||
import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export'
|
||||
import type { Article } from '@/types'
|
||||
|
||||
@@ -11,7 +11,8 @@ import type { Article } from '@/types'
|
||||
*
|
||||
* Downloads the article register as xlsx (default) or csv. Read-only: viewers
|
||||
* may export. Column headers match the article importer's detector keywords so
|
||||
* the file round-trips (export → edit → re-import).
|
||||
* the file round-trips (export → edit → re-import). Exception: the importer
|
||||
* does not yet detect Valuta, so re-imported articles default to SEK.
|
||||
*/
|
||||
export const GET = withRouteContext(
|
||||
'article.export',
|
||||
@@ -48,10 +49,13 @@ export const GET = withRouteContext(
|
||||
textColumn('Benämning (engelska)'),
|
||||
textColumn('Typ'),
|
||||
textColumn('Enhet'),
|
||||
currencyColumn('Försäljningspris'),
|
||||
// Prices are decimal (not the kr-suffixed currency format):
|
||||
// articles can carry a non-SEK currency, exported in Valuta.
|
||||
decimalColumn('Försäljningspris'),
|
||||
textColumn('Valuta'),
|
||||
integerColumn('Moms %'),
|
||||
textColumn('Försäljningskonto'),
|
||||
currencyColumn('Inköpspris'),
|
||||
decimalColumn('Inköpspris'),
|
||||
textColumn('EAN'),
|
||||
textColumn('ROT/RUT'),
|
||||
textColumn('Anteckning'),
|
||||
@@ -64,6 +68,7 @@ export const GET = withRouteContext(
|
||||
a.type,
|
||||
a.unit,
|
||||
a.price_excl_vat,
|
||||
a.currency,
|
||||
a.vat_rate,
|
||||
a.revenue_account,
|
||||
a.cost_price,
|
||||
|
||||
@@ -584,6 +584,9 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
|
||||
unit: item.unit || 'st',
|
||||
price_excl_vat: Number(item.unit_price) || 0,
|
||||
vat_rate: item.vat_rate ?? 25,
|
||||
// The typed unit price is in the invoice's currency: without this an
|
||||
// EUR invoice line becomes an SEK article with the EUR number.
|
||||
currency: getValues('currency'),
|
||||
}),
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
reportToWorkbook,
|
||||
textColumn,
|
||||
currencyColumn,
|
||||
decimalColumn,
|
||||
dateColumn,
|
||||
integerColumn,
|
||||
percentColumn,
|
||||
@@ -70,6 +71,24 @@ describe('reportToWorkbook', () => {
|
||||
expect(sheet['A2'].z).not.toBe('#,##0.00 " kr"')
|
||||
})
|
||||
|
||||
it('applies the suffix-free decimal format to decimal columns', () => {
|
||||
type Row = { name: string; price: number }
|
||||
const buffer = reportToWorkbook<Row>([
|
||||
{
|
||||
name: 'Decimal',
|
||||
columns: [textColumn('Name'), decimalColumn('Pris')],
|
||||
rows: [{ name: 'Foo', price: 1234.56 }],
|
||||
mapRow: (r) => [r.name, r.price],
|
||||
},
|
||||
])
|
||||
|
||||
const wb = parseBuffer(buffer)
|
||||
const sheet = wb.Sheets['Decimal']
|
||||
// No " kr" suffix: decimal columns pair with a per-row currency column.
|
||||
expect(sheet['B2'].z).toBe('#,##0.00')
|
||||
expect(sheet['B2'].v).toBe(1234.56)
|
||||
})
|
||||
|
||||
it('applies date format to date columns', () => {
|
||||
type Row = { date: Date }
|
||||
const buffer = reportToWorkbook<Row>([
|
||||
|
||||
@@ -23,7 +23,7 @@ import * as XLSX from 'xlsx'
|
||||
*/
|
||||
|
||||
export type CellValue = string | number | Date | null | undefined
|
||||
export type ColumnFormat = 'text' | 'currency' | 'date' | 'integer' | 'percent'
|
||||
export type ColumnFormat = 'text' | 'currency' | 'decimal' | 'date' | 'integer' | 'percent'
|
||||
|
||||
export interface ColumnSpec {
|
||||
/** Human-readable header label rendered in row 1. */
|
||||
@@ -47,6 +47,9 @@ export interface SheetSpec<TRow> {
|
||||
}
|
||||
|
||||
const CURRENCY_FORMAT = '#,##0.00 " kr"'
|
||||
// Money amount WITHOUT the " kr" suffix: for columns whose currency varies per
|
||||
// row (e.g. article prices with a separate Valuta column).
|
||||
const DECIMAL_FORMAT = '#,##0.00'
|
||||
const DATE_FORMAT = 'yyyy-mm-dd'
|
||||
const INTEGER_FORMAT = '#,##0'
|
||||
const PERCENT_FORMAT = '0.00%'
|
||||
@@ -55,6 +58,8 @@ function formatToZ(format: ColumnFormat): string | undefined {
|
||||
switch (format) {
|
||||
case 'currency':
|
||||
return CURRENCY_FORMAT
|
||||
case 'decimal':
|
||||
return DECIMAL_FORMAT
|
||||
case 'date':
|
||||
return DATE_FORMAT
|
||||
case 'integer':
|
||||
@@ -84,6 +89,12 @@ function displayLength(value: CellValue, format: ColumnFormat): number {
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return formatted.length + 3 + (value < 0 ? 1 : 0) // +3 for " kr"
|
||||
}
|
||||
case 'decimal': {
|
||||
const formatted = Math.abs(value)
|
||||
.toFixed(2)
|
||||
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return formatted.length + (value < 0 ? 1 : 0)
|
||||
}
|
||||
case 'integer': {
|
||||
const formatted = Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
|
||||
return formatted.length + (value < 0 ? 1 : 0)
|
||||
@@ -199,6 +210,11 @@ export function currencyColumn(header: string): ColumnSpec {
|
||||
return { header, format: 'currency' }
|
||||
}
|
||||
|
||||
/** Two-decimal amount without the " kr" suffix: pair with a per-row currency column. */
|
||||
export function decimalColumn(header: string): ColumnSpec {
|
||||
return { header, format: 'decimal' }
|
||||
}
|
||||
|
||||
export function dateColumn(header: string): ColumnSpec {
|
||||
return { header, format: 'date' }
|
||||
}
|
||||
|
||||
@@ -4711,6 +4711,7 @@
|
||||
"back": "Back to articles",
|
||||
"edit": "Edit",
|
||||
"deactivate": "Deactivate",
|
||||
"activate": "Activate",
|
||||
"delete": "Delete",
|
||||
"edit_dialog_title": "Edit article",
|
||||
"viewer_disabled_tooltip": "You only have viewer access in this company",
|
||||
@@ -4741,7 +4742,9 @@
|
||||
"deactivate_confirm_description": "The article is hidden from lists and invoice pickers but its history is kept. You can reactivate it later.",
|
||||
"deactivate_confirm_label": "Deactivate",
|
||||
"deactivated_title": "Article deactivated",
|
||||
"activated_title": "Article activated",
|
||||
"deactivate_failed_title": "Could not deactivate article",
|
||||
"activate_failed_title": "Could not activate article",
|
||||
"delete_confirm_title": "Delete {name}",
|
||||
"delete_confirm_description": "The article and its article number are permanently deleted. This is only possible if the article has never been used on an invoice.",
|
||||
"delete_confirm_label": "Delete",
|
||||
|
||||
@@ -4711,6 +4711,7 @@
|
||||
"back": "Tillbaka till artiklar",
|
||||
"edit": "Redigera",
|
||||
"deactivate": "Inaktivera",
|
||||
"activate": "Aktivera",
|
||||
"delete": "Ta bort",
|
||||
"edit_dialog_title": "Redigera artikel",
|
||||
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
@@ -4741,7 +4742,9 @@
|
||||
"deactivate_confirm_description": "Artikeln döljs i listor och fakturaval men historiken bevaras. Du kan aktivera den igen senare.",
|
||||
"deactivate_confirm_label": "Inaktivera",
|
||||
"deactivated_title": "Artikel inaktiverad",
|
||||
"activated_title": "Artikel aktiverad",
|
||||
"deactivate_failed_title": "Kunde inte inaktivera artikel",
|
||||
"activate_failed_title": "Kunde inte aktivera artikel",
|
||||
"delete_confirm_title": "Ta bort {name}",
|
||||
"delete_confirm_description": "Artikeln och dess artikelnummer tas bort permanent. Det går bara om artikeln aldrig har använts på en faktura.",
|
||||
"delete_confirm_label": "Ta bort",
|
||||
|
||||
Reference in New Issue
Block a user