fix(suppliers): stop requiring standardkonto that was never meant to be required (#1636)

* fix(suppliers): stop requiring standardkonto that was never meant to be required

The supplier form initializes every optional field to '' and sent them
as-is, while CreateSupplierSchema validates default_expense_account
with the 4-digit account rule behind .optional(): an empty string is a
present string, so saving a supplier with the field untouched failed
with "Kontonummer måste vara 4 siffror" even though the field carries
no required mark (reported by Björn with a screen recording; the edit
page failed the same way for any supplier without a default account).

Schemas now own the normalization, split by verb: on create '' becomes
undefined (key dropped, column NULL), on update '' becomes null,
because update routes pass fields straight into .update() where
undefined means "leave unchanged" and clearing must actually write
NULL. Email gets the same treatment and the form's old client-side
email strip is removed; stripping empty strings client-side would
break exactly the clear path.

The free-text Standardkonto input is replaced with the shared
AccountCombobox (browsable list filtered to cost classes 4-7, the same
rule the agent-path expenseAccountField enforces), with the selected
account name shown under the field and a clear button when set.
Standardkonto itself stays optional: it only prefills supplier-invoice
lines and the ledger-context suggestion covers the empty case.

Verified end to end against the running app: saving a supplier without
a default account succeeds on the update path, and the combobox
search/select/clear cycle works inside the create dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api-spec): render preprocess pipes by output side, required-ness by undefined-acceptance

The minimal Zod-to-JSON-schema walker described every pipe by its input
side. For .transform() that is right (the caller sends the input), but
z.preprocess() is the mirror image: the callable sits on the input side,
so the supplier schemas' new empty-string normalization rendered email
and default_expense_account as required untyped fields in the OpenAPI
spec and the generated accounted-api skill. Describe the output side
when the input is a transform.

Required-ness now derives from schema.safeParse(undefined) instead of a
top-level discriminator check: a field may be omitted exactly when the
schema accepts undefined. Besides the preprocess pipes, this corrects
several fields the old check misrendered as required (z.unknown()
bodies, union-with-empty-string settings fields, preprocessed
personal_number), so the regenerated skill references only flip
required to optional where runtime validation already allowed omission.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-17 10:41:03 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 2eb3441244
commit 25524e1df4
12 changed files with 209 additions and 51 deletions
+65 -12
View File
@@ -1,6 +1,6 @@
'use client'
import { useMemo } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -10,9 +10,10 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Loader2, Lock } from 'lucide-react'
import { Loader2, Lock, X } from 'lucide-react'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { CreateSupplierInput } from '@/types'
import type { BASAccount, CreateSupplierInput } from '@/types'
interface SupplierFormProps {
onSubmit: (data: CreateSupplierInput) => Promise<void>
@@ -27,6 +28,37 @@ export default function SupplierForm({
}: SupplierFormProps) {
const { canWrite } = useCanWrite()
const t = useTranslations('form_supplier')
const [accounts, setAccounts] = useState<BASAccount[]>([])
useEffect(() => {
let cancelled = false
async function fetchAccounts() {
try {
const res = await fetch('/api/bookkeeping/accounts')
if (!res.ok) return
const { data } = await res.json()
if (!cancelled) setAccounts(data || [])
} catch {
// Without the chart the combobox still accepts a typed 4-digit number.
}
}
fetchAccounts()
return () => {
cancelled = true
}
}, [])
// The default account seeds expense lines on supplier invoices, so the
// browsable list is cost classes 4-7. Any other 4-digit number can still be
// typed in; the API only enforces the format.
const expenseAccounts = useMemo(
() => accounts.filter((a) => a.account_class >= 4 && a.account_class <= 7),
[accounts]
)
const accountNameByNumber = useMemo(
() => new Map(accounts.map((a) => [a.account_number, a.account_name])),
[accounts]
)
const schema = useMemo(() => z.object({
name: z.string().min(1, t('name_required')),
@@ -85,11 +117,10 @@ export default function SupplierForm({
},
})
// Empty strings go through as-is: the API schemas normalize them (dropped on
// create, null on update so a cleared field actually clears the column).
const onFormSubmit = (data: FormData) => {
onSubmit({
...data,
email: data.email || undefined,
})
onSubmit(data)
}
return (
@@ -241,11 +272,33 @@ export default function SupplierForm({
<div className="space-y-4 pt-4 border-t">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="default_expense_account">{t('default_account_label')}</Label>
<Input
id="default_expense_account"
placeholder={t('default_account_placeholder')}
{...register('default_expense_account')}
<Label>{t('default_account_label')}</Label>
<Controller
name="default_expense_account"
control={control}
render={({ field }) => (
<div className="flex items-start gap-1">
<div className="min-w-0 flex-1">
<AccountCombobox
value={field.value || ''}
accounts={expenseAccounts}
onChange={field.onChange}
selectedName={accountNameByNumber.get(field.value || '')}
/>
</div>
{field.value ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('default_account_clear')}
onClick={() => field.onChange('')}
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
)}
/>
</div>
<div className="space-y-2">