diff --git a/DECISIONS.md b/DECISIONS.md index 15740397..93d2e45d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1105,3 +1105,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected. [2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in `companyInformation`, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure. + +[2026-08-20] Detail pages (kundfaktura first, then the stale card-pile siblings: leverantörsfaktura, verifikat, kreditfaktura, avyttring, lön) adopt the register-detail document grammar from #1624 instead of card stacks: DetailSection/DefRow groups, one status element per the list pages' chips-mark-exceptions rule, one primary next step plus "Förhandsgranska" visible and everything else behind a ⋯ overflow menu, the line table on the dry-table idiom with the headline total in the serif. Considered keeping a two-column card sidebar with fewer cards; rejected because the card border carried no hierarchy the hairline kicker does not already carry, and a second column of stacked boxes is exactly what the founder called clutter. diff --git a/app/(dashboard)/assets/[id]/dispose/page.tsx b/app/(dashboard)/assets/[id]/dispose/page.tsx index 1ca20311..95f6f727 100644 --- a/app/(dashboard)/assets/[id]/dispose/page.tsx +++ b/app/(dashboard)/assets/[id]/dispose/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { use, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { use, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' import Link from 'next/link' @@ -8,20 +8,18 @@ import { ArrowLeft, Loader2, Lock } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Input } from '@/components/ui/input' +import { DetailSection, DefRow } from '@/components/ui/detail-section' import { Label } from '@/components/ui/label' import { PageHeader } from '@/components/ui/page-header' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { useToast } from '@/components/ui/use-toast' +import { + SettingsInput, + SettingsRow, + SettingsRowNote, + SettingsSelect, +} from '@/components/settings/SettingsRows' import { assessJamkning, assessJamkningEligibility } from '@/lib/bokslut/assets/jamkning' import { getErrorMessage } from '@/lib/errors/get-error-message' import { useCanWrite } from '@/lib/hooks/use-can-write' @@ -39,6 +37,10 @@ interface PeriodOption { const VAT_TREATMENTS = ['standard_25', 'reverse_charge', 'export', 'exempt'] as const +// Flat Fönster inputs are sized by the row; amounts, dates and account +// numbers get a fixed short width so the row does not stretch them. +const FIELD_CLASS = 'max-w-44 flex-none tabular-nums' + function round2(value: number): number { return Math.round(value * 100) / 100 } @@ -246,7 +248,10 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str return (
- +
+ + +
) } @@ -255,12 +260,10 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str return (
- - -

{!asset ? t('not_found') : t('already_disposed', { date: formatDate(asset.disposed_at!) })}

- -
-
+

+ {!asset ? t('not_found') : t('already_disposed', { date: formatDate(asset.disposed_at!) })} +

+
) } @@ -269,112 +272,222 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str const missingJamkningData = possibleInvestmentGood && (originalInputVat === '' || originalDeductionPercent === '') + // Which rows the adjustment section shows, so the last one can drop its + // hairline and the section never ends on a dangling rule. + const showJamkningInputs = Boolean(eligibility?.withinAdjustmentPeriod) + const showAssessment = jamkningAssessment !== null + const showTransferConfirm = disposalType === 'business_transfer' + const showDocumentConfirm = showTransferConfirm && transferNeedsDocument + const assessmentIsLast = showAssessment && !showTransferConfirm + const inputsAreLast = showJamkningInputs && !showAssessment && !showTransferConfirm + return ( -
- } - /> +
+ {/* Back link on its own quiet row, same as the register documents */} + + + {t('back')} + - - {/* data-ph-mask: the asset name is user data */} - {asset.name} - - - - - - + {/* data-ph-mask: the asset name is user data */} + {asset.name}} /> - - {t('details_title')} - - - - - - setDisposalDate(event.target.value)} className="tabular-nums" /> - - - - {periodLocked &&

{t('period_locked')}

} -
- - setProceeds(event.target.value)} disabled={disposalType === 'scrap'} className="tabular-nums" /> - - {disposalType !== 'scrap' && setProceedsAccount(event.target.value)} className="tabular-nums" />} -
-
+ {/* The asset being disposed: read-only context as plain rows */} + + + {formatCurrency(Number(asset.acquisition_cost))} + + + {formatDate(asset.acquisition_date)} + + + + {`${asset.bas_asset_account} / ${asset.bas_accumulated_account} / ${asset.bas_expense_account}`} + + + + + {/* The disposal itself: flat hairline rows, label left, control right */} + + + setDisposalType(event.target.value as AssetDisposalType)} + > + + + + + + + setDisposalDate(event.target.value)} + className={FIELD_CLASS} + /> + + + setPeriodId(event.target.value)}> + + {periods.map((period) => ( + + ))} + + {periodLocked &&

{t('period_locked')}

} +
+ + setProceeds(event.target.value)} + disabled={disposalType === 'scrap'} + className={FIELD_CLASS} + /> + + {disposalType !== 'scrap' && ( + + setProceedsAccount(event.target.value)} + className={FIELD_CLASS} + /> + + )} +
{disposalType === 'sale' && ( - - {t('vat_title')} - - - - -
- - - -
-
-
+ + + setVatTreatment(event.target.value as VatTreatment)} + > + {VAT_TREATMENTS.map((value) => ( + + ))} + + + + {formatCurrency(proceedsNumber)} + + + {formatCurrency(vatAmount)} + + + {formatCurrency(netProceeds)} + + )} - - {t('adjustment_title')} - - {eligibility?.withinAdjustmentPeriod - ? {t('within_adjustment_period', { years: eligibility.remainingYears, total: eligibility.totalYears })} - : {t('outside_adjustment_period')}} - {eligibility?.withinAdjustmentPeriod && ( -
- - setOriginalInputVat(event.target.value)} className="tabular-nums" /> - - - setOriginalDeductionPercent(event.target.value)} className="tabular-nums" /> - -
- )} - {jamkningAssessment && ( -
- - - {jamkningAssessment.capped &&

{t('adjustment_capped')}

} -
- )} - {disposalType === 'business_transfer' && ( -
- - -
- )} - {disposalType === 'business_transfer' && transferNeedsDocument && ( -
- - -
- )} - {missingJamkningData &&

{t('adjustment_data_required')}

} -
-
+ {/* Input VAT adjustment: the period status sits on the kicker line + (a chip only when the asset is still inside the period), the + underlag as rows, the assessment as read-only rows below. */} + + {t('within_adjustment_period', { years: eligibility.remainingYears, total: eligibility.totalYears })} + + ) : ( + {t('outside_adjustment_period')} + ) + } + > + {showJamkningInputs && eligibility && ( + <> + + setOriginalInputVat(event.target.value)} + className={FIELD_CLASS} + /> + + + setOriginalDeductionPercent(event.target.value)} + className={FIELD_CLASS} + /> + + + )} + {jamkningAssessment && ( + <> + + {t(`direction_${jamkningAssessment.direction}`)} + + + {formatCurrency(jamkningAssessment.amount)} + {jamkningAssessment.capped && ( + {t('adjustment_capped')} + )} + + + )} + {showTransferConfirm && ( + + + + + )} + {showDocumentConfirm && ( + + + + + )} + {missingJamkningData && ( +

{t('adjustment_data_required')}

+ )} +
-
+ {/* Actions: one footer row, so the flow ends on the button it arms */} +
+ ) : null + + // Title in the serif: the voucher label for posted entries, a plain + // "Utkast till verifikat" while the number is still unassigned. + const title = entry.status === 'draft' ? t('title_draft') : t('title', { label: formatVoucher(entry) }) + + const metaParts = [ + formatDate(entry.entry_date), + entry.description, + entry.committed_at ? t('posted_on', { date: formatDate(entry.committed_at) }) : null, + ].filter(Boolean) + + // Header actions (convention 9): the one next step as a filled button, at + // most a couple of quiet secondaries, everything else in the ⋯ menu. The + // old surface offered no actions at all on reversed/cancelled entries; keep + // that gate. + const showActions = entry.status === 'posted' || entry.status === 'draft' + const showDelete = entry.status === 'draft' || isLastInSeries + const showRattelseGroup = canCorrect && !isOpeningBalance + + const underlagAside = (() => { + if (attachmentCount === 0 && references.length === 0) { + return {t('no_attachments')} + } + const parts = [ + attachmentCount > 0 ? t('attachments_count', { count: attachmentCount }) : null, + references.length > 0 ? t('references_count', { count: references.length }) : null, + ].filter(Boolean) + return {parts.join(' · ')} + })() + return ( -
+
{/* Back link + prev/next record pager */}
- {/* Header */} + {/* Header: serif title, status chips only for deviations (a plain posted + verifikat carries none), a quiet meta line, the next step right. */}
-
-
-

- {formatVoucher(entry)} -

- +
+
+ {/* data-ph-mask: the title carries the voucher number */} +

{title}

+ {rattelseLog.length > 0 && ( - + Rättad )}
-

{entry.description}

+ {/* data-ph-mask: the meta line carries the entry description */} +

{metaParts.join(' · ')}

- {(entry.status === 'posted' || entry.status === 'draft') && ( -
+ {showActions && ( +
{entry.status === 'draft' && ( - )} - {canCorrect && !isOpeningBalance && ( - - - - - - {canInlineRattelse && ( - setShowStrikeLines(true)}> - - {t('strike_lines')} - - )} - setShowCorrectMetadata(true)}> - - {t('correct_metadata')} - - {canInlineRattelse && } - setShowCorrection(true)}> - - {t('correct_lines')} - - setShowRecordate(true)}> - - {t('correct_date')} - - - setShowReverseConfirm(true)}> - - {t('reverse_action')} - - - - )} {canCorrect && isOpeningBalance && ( )} - {/* Copy is not status-gated: it only prefills a fresh manual draft - (no voucher number, date or attachments carried over), so it is - offered on drafts too, matching the list surfaces. */} - + + + + + + + {/* Copy is not status-gated: it only prefills a fresh manual + draft (no voucher number, date or attachments carried over), + so it is offered on drafts too, matching the list surfaces. */} + + + + {t('copy_entry')} + + + {showRattelseGroup && ( + <> + + {t('correct_menu')} + {canInlineRattelse && ( + setShowStrikeLines(true)} disabled={!canWrite}> + + {t('strike_lines')} + + )} + setShowCorrectMetadata(true)} disabled={!canWrite}> + + {t('correct_metadata')} + + setShowCorrection(true)} disabled={!canWrite}> + + {t('correct_lines')} + + setShowRecordate(true)} disabled={!canWrite}> + + {t('correct_date')} + + + setShowReverseConfirm(true)} disabled={!canWrite}> + + {t('reverse_action')} + + + )} + {showDelete && ( + <> + + setShowDeleteConfirm(true)} + disabled={!canWrite} + className="text-destructive focus:text-destructive" + > + + {entry.status === 'draft' ? t('delete_draft') : t('delete_entry')} + + + )} + +
)}
- {/* Info cards */} -
- - - {t('details_title')} - - -
- {t('field_date')} - {formatDate(entry.entry_date)} -
- {entry.committed_at && ( -
- {t('field_posted_at')} - {formatDate(entry.committed_at)} -
- )} - {entry.committed_at && entry.committed_actor_type && ( -
- {t('field_committed_by')} - - {entry.committed_actor_type !== 'user' && } - {committedByLabel(entry.committed_actor_type, entry.committed_actor_label, t)} - -
- )} -
- {t('field_type')} - {sourceTypeLabels[entry.source_type] || entry.source_type} -
- {entry.source_voucher_series && entry.source_voucher_number != null && ( -
- {t('field_source_voucher')} - - {formatVoucher({ voucher_series: entry.source_voucher_series, voucher_number: entry.source_voucher_number })} - -
- )} - {/* Notes: always editable (internal metadata, not BFL verifikation content) */} -
-
- - - {t('field_note')} - - {!editingNotes && canWrite && ( - - )} -
- {editingNotes ? ( -
-