Files
accounted/components/settings/sections/SalarySettingsContent.tsx
T
Jakob WennbergandClaude Fable 5 da859d7236 refactor(ui): design-system consistency pass over dense pages + i18n de-bloat (#961)
* refactor(ui): normalize dense pages to the locked design system

Sweep of the info-dense surfaces against .claude/rules/design.md; no
behavior changes, classNames and primitive adoption only.

- Replace hand-rolled h1s with PageHeader (import, suppliers, kpi,
  salary/employees, skattekonto, settings layout) and drop the one
  double title (SalarySettingsContent under the settings h1)
- Replace hand-rolled empty states with EmptyState (skattekonto,
  banking/api-keys/oauth/counterparty settings) and hand-rolled
  pulse divs with Skeleton (deadlines, report view loaders)
- Remove semantic colors used as chrome: amber/emerald banners in
  AGIPanel and SkatteverketPanel, success/warning tints in
  kassaflodesanalys, arsredovisning and import become neutral
  surfaces with the tint kept on the icon only
- Full-opacity borders everywhere (border-border/30-60,
  border-destructive/20-40, border-foreground/30, text-destructive/80)
- Snap off-scale spacing (p-5 to p-6, p-2.5 to p-3, gap/mt-x.5 to
  scale values); KPI metric tiles p-6 to p-4 per the tile rule
- Remove the mobile Select that duplicated the invoices status Tabs
  (TabsList already scrolls horizontally); single Tabs now serves
  both breakpoints
- supplier-invoices: shared formatCurrency instead of a local
  formatAmount helper; skattekonto: formatDate/formatDateLong/
  formatDateTime instead of raw dates and toLocaleString
- arsredovisning flerarsoversikt converted to the Table primitive
  with right-aligned tabular-nums cells
- Settings: CardTitle text-base on section cards, one heading idiom
  in AccountSettingsContent, h3 to h2 in CompanyProfileView

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

* refactor(i18n): trim text bloat and fix an untranslated sv string

- Fix invoice_credit.create_failed_fallback: sv catalog carried the
  English "Failed to create credit note"; now "Kunde inte skapa
  kreditfaktura". Translate new_user_checklist.step3_title in en
- Drop descriptions that paraphrase their own title (design.md
  forbidden pattern): invoice_detail.credited_description,
  invoice_credit.original_card_description, invoice_editor
  customer/notes card descriptions (keys deleted from both
  catalogs, zero remaining usages); the transaction booking
  DialogDescription becomes sr-only so screen readers keep it
- Trim redundant sentences from settings_salary.info_payroll_scope,
  settings_backup.intro, ext_cloud_backup_long_description,
  settings.name_description, salary_payments.open_payments_note and
  shorten invoice_credit.reason_card_description; statutory BFL/tax
  prose untouched
- Normalize toast punctuation (dimensions/self_billing
  created_description lose the trailing period like their siblings)
- common.delete "Radera" to "Ta bort" (zero live call sites; Radera
  stays reserved for irreversible account/company deletion)

Catalogs verified key-identical (4795 keys each) and JSON-parseable.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:56:30 +02:00

207 lines
8.4 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { useSettings } from '@/components/settings/useSettings'
import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { AlertTriangle } from 'lucide-react'
import type { CompanySettings } from '@/types'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
const BANK_OPTIONS = ['swedbank', 'seb', 'handelsbanken', 'nordea'] as const
const BANK_LABEL: Record<(typeof BANK_OPTIONS)[number], string> = {
swedbank: 'Swedbank',
seb: 'SEB',
handelsbanken: 'Handelsbanken',
nordea: 'Nordea',
}
const selectClassName =
'flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
export function SalarySettingsContent() {
const t = useTranslations('settings_salary')
const { settings, isLoading, updateSettings, refetch } = useSettings()
// Controlled so the LB sunset note reacts to the selection before save.
const [format, setFormat] = useState<'bg_lb' | 'pain001' | null>(null)
if (isLoading) return <SettingsLoadingSkeleton />
if (!settings) return <SettingsLoadError onRetry={refetch} />
const effectiveFormat = format ?? settings.preferred_payment_format ?? 'pain001'
const currentSeries = resolveDefaultSeriesForSource(settings, 'salary_payment')
function handleSave(formData: FormData) {
const payDayRaw = parseInt((formData.get('salary_pay_day') as string) || '25', 10)
const payDay = Number.isFinite(payDayRaw) ? Math.min(28, Math.max(1, payDayRaw)) : 25
const paymentFormat = (formData.get('preferred_payment_format') as string) || 'pain001'
const bank = (formData.get('salary_default_bank') as string) || 'none'
const series = (formData.get('salary_voucher_series') as string) || 'A'
const updates: Record<string, unknown> = {
salary_pay_day: payDay,
preferred_payment_format: paymentFormat,
salary_default_bank: bank === 'none' ? null : bank,
}
// The booking engine resolves the series from the per-source-type map;
// salary entries pass run.voucher_series explicitly, seeded from this
// entry at run creation. Merge — never replace — the map so other
// source-type overrides survive.
if (series !== currentSeries) {
updates.default_voucher_series_per_source_type = {
...(settings?.default_voucher_series_per_source_type || {}),
salary_payment: series,
}
}
return {
updates,
onSuccess: (data: Record<string, unknown>) => {
updateSettings(data as Partial<CompanySettings>)
},
}
}
return (
<div className="space-y-8">
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
{/* Payment */}
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('payments_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="salary_pay_day">{t('pay_day_label')}</Label>
<Input
id="salary_pay_day"
name="salary_pay_day"
type="number"
min={1}
max={28}
defaultValue={settings.salary_pay_day ?? 25}
className="w-24 tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('pay_day_help')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="preferred_payment_format">{t('format_label')}</Label>
<select
id="preferred_payment_format"
name="preferred_payment_format"
value={effectiveFormat}
onChange={(e) => setFormat(e.target.value as 'bg_lb' | 'pain001')}
className={selectClassName}
>
<option value="pain001">{t('format_pain001')}</option>
<option value="bg_lb">{t('format_bg_lb')}</option>
</select>
<p className="text-xs text-muted-foreground">{t('format_help')}</p>
{effectiveFormat === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs max-w-xl">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{t('sunset_warning')}
</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="salary_default_bank">{t('bank_label')}</Label>
<select
id="salary_default_bank"
name="salary_default_bank"
defaultValue={settings.salary_default_bank ?? 'none'}
className={selectClassName}
>
<option value="none">{t('bank_none')}</option>
{BANK_OPTIONS.map((key) => (
<option key={key} value={key}>{BANK_LABEL[key]}</option>
))}
<option value="other">{t('bank_other')}</option>
</select>
<p className="text-xs text-muted-foreground">{t('bank_help')}</p>
</div>
</section>
{/* Accounting */}
<div className="border-t border-border pt-8">
<section className="space-y-4">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('accounting_heading')}
</h2>
<div className="space-y-2">
<Label htmlFor="salary_voucher_series">{t('voucher_series_label')}</Label>
<select
id="salary_voucher_series"
name="salary_voucher_series"
defaultValue={currentSeries}
className="flex h-10 w-16 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{SERIES_OPTIONS.map((letter) => (
<option key={letter} value={letter}>{letter}</option>
))}
</select>
<p className="text-xs text-muted-foreground">{t('voucher_series_help')}</p>
</div>
</section>
</div>
</SettingsFormWrapper>
{/* Tax tables (read-only status) */}
<div className="border-t border-border pt-8">
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('tax_tables_heading')}
</h2>
<TaxTableStatus />
<p className="text-xs text-muted-foreground">{t('tax_tables_help')}</p>
</section>
</div>
{/* Vacation (informational — the rule is per-employee) */}
<div className="border-t border-border pt-8">
<section className="space-y-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('vacation_heading')}
</h2>
<p className="text-sm text-muted-foreground">
{t('vacation_info')}{' '}
<Link href="/salary/employees" className="underline underline-offset-2 hover:text-foreground">
{t('vacation_info_link')}
</Link>
</p>
</section>
</div>
{/* Info */}
<div className="border-t border-border pt-8">
<section className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('info_heading')}
</h2>
<div className="text-sm text-muted-foreground space-y-2">
<p>{t('info_payroll_scope')}</p>
<p>
{t.rich('info_current_year', {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</p>
</div>
</section>
</div>
</div>
)
}