fix(salary): one save per surface on the employee form (#1623)

* fix(salary): one save per surface on the employee form

The employee edit page stacked two competing saves: the opening-balances
Card ended in "Spara ingaende saldon" and the page ended in "Spara
andringar" 80px below, with no visible boundary between their scopes.
Worse, both self-saving panels lived INSIDE the page <form> and shadcn
Button sets no default type, so every panel button (save opening
balances, add/remove benefit) implicitly submitted the outer form too,
firing the full employee PATCH alongside the panel's own request.

Restructure so each surface owns exactly one save:

- The employee <form> now closes right after the Bank card, with
  Avbryt + "Spara andringar" directly under the fields it actually saves.
- Formaner and Ingaende saldon move below the form into a "Sparas
  separat" section (uppercase kicker + one-line scope hint) so the page
  save structurally cannot include them and their buttons can no longer
  leak submits into the employee form.
- OpeningBalancesPanel becomes its own <form>: Enter saves the panel,
  and the save button enables only when its fields are actually dirty
  (fingerprint of loaded values, reset on successful save).
- EmployeeBenefitsPanel buttons get explicit type="button".

New strings in both messages/sv.json and messages/en.json.

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

* fix(salary): release the loading skeleton when the balances fetch fails

CodeRabbit on #1623: a rejected fetch or JSON parse skipped the
setLoading(false) line, holding the skeleton forever. The load now
wraps in try/finally; a failed load falls back to the empty form.

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-16 17:04:57 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent dd4ced1f93
commit 51539b93ed
6 changed files with 129 additions and 30 deletions
+4 -4
View File
@@ -132,7 +132,7 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">{t('benefits_title')}</CardTitle>
{canWrite && !adding && (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Button type="button" size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('benefits_add')}
</Button>
@@ -169,7 +169,7 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
</TableCell>
<TableCell className="text-right">
{canWrite && (
<Button size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label={t('benefits_remove')}>
<Button type="button" size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label={t('benefits_remove')}>
<Trash2 className="h-4 w-4" />
</Button>
)}
@@ -257,8 +257,8 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button size="sm" onClick={handleAdd} disabled={submitting}>
<Button type="button" variant="outline" size="sm" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button type="button" size="sm" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_save')}
</Button>
+101 -20
View File
@@ -40,6 +40,31 @@ const currentYear = new Date().getFullYear()
/** Sparade dagar origin years: Semesterlagen allows saving max 5 years. */
const SAVED_YEARS = Array.from({ length: 5 }, (_, i) => String(currentYear - 1 - i))
interface PanelValues {
cutoverDate: string
ytdGross: string
ytdTax: string
ytdNet: string
daysRemaining: string
daysTaken: string
savedByYear: Record<string, string>
liability: string
liabilityAvgifter: string
karens: string
}
/**
* Stable serialization of the panel's field values, used to gate the save
* button on actual edits. Empty saved-days entries equal absent entries, so
* typing into a year and clearing it again returns the panel to clean.
*/
function fingerprint(v: PanelValues): string {
const saved = Object.entries(v.savedByYear)
.filter(([, days]) => days.trim() !== '')
.sort(([a], [b]) => a.localeCompare(b))
return JSON.stringify({ ...v, savedByYear: saved })
}
export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { toast } = useToast()
@@ -58,37 +83,83 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
const [liability, setLiability] = useState('')
const [liabilityAvgifter, setLiabilityAvgifter] = useState('')
const [karens, setKarens] = useState('')
// Fingerprint of the last loaded/saved values: the save button stays
// disabled until the fields actually differ from it.
const [baseline, setBaseline] = useState(() =>
fingerprint({
cutoverDate: `${currentYear}-01-01`,
ytdGross: '',
ytdTax: '',
ytdNet: '',
daysRemaining: '',
daysTaken: '',
savedByYear: {},
liability: '',
liabilityAvgifter: '',
karens: '',
}),
)
useEffect(() => {
async function load() {
setLoading(true)
const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`)
if (res.ok) {
try {
const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`)
if (!res.ok) return
const { data } = (await res.json()) as { data: OpeningBalancesData | null }
if (data) {
setHasRow(true)
setLocked(data.locked)
setCutoverDate(data.cutover_date)
setYtdGross(String(data.ytd_gross))
setYtdTax(String(data.ytd_tax))
setYtdNet(String(data.ytd_net))
setDaysRemaining(String(data.vacation_paid_days_remaining))
setDaysTaken(String(data.vacation_days_taken_this_year ?? 0))
setSavedByYear(
Object.fromEntries(
const values: PanelValues = {
cutoverDate: data.cutover_date,
ytdGross: String(data.ytd_gross),
ytdTax: String(data.ytd_tax),
ytdNet: String(data.ytd_net),
daysRemaining: String(data.vacation_paid_days_remaining),
daysTaken: String(data.vacation_days_taken_this_year ?? 0),
savedByYear: Object.fromEntries(
Object.entries(data.vacation_saved_days_by_year ?? {}).map(([y, d]) => [y, String(d)]),
),
)
setLiability(String(data.opening_semester_liability))
setLiabilityAvgifter(String(data.opening_semester_liability_avgifter))
setKarens(String(data.karens_periods_adjustment))
liability: String(data.opening_semester_liability),
liabilityAvgifter: String(data.opening_semester_liability_avgifter),
karens: String(data.karens_periods_adjustment),
}
setHasRow(true)
setLocked(data.locked)
setCutoverDate(values.cutoverDate)
setYtdGross(values.ytdGross)
setYtdTax(values.ytdTax)
setYtdNet(values.ytdNet)
setDaysRemaining(values.daysRemaining)
setDaysTaken(values.daysTaken)
setSavedByYear(values.savedByYear)
setLiability(values.liability)
setLiabilityAvgifter(values.liabilityAvgifter)
setKarens(values.karens)
setBaseline(fingerprint(values))
}
} catch {
// Network failure: the panel falls back to its empty form rather
// than holding the skeleton forever; a retry happens on remount.
} finally {
setLoading(false)
}
setLoading(false)
}
load()
}, [employeeId])
const currentValues: PanelValues = {
cutoverDate,
ytdGross,
ytdTax,
ytdNet,
daysRemaining,
daysTaken,
savedByYear,
liability,
liabilityAvgifter,
karens,
}
const dirty = fingerprint(currentValues) !== baseline
async function handleSave() {
setSaving(true)
const saved: Record<string, number> = {}
@@ -116,6 +187,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
if (res.ok) {
setHasRow(true)
setBaseline(fingerprint(currentValues))
toast({ title: t('opening_balances_saved') })
} else {
const result = await res.json()
@@ -150,7 +222,15 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('opening_balances_description')}</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Own form: this panel is its own save scope. It must never be nested
inside another form (invalid HTML, and its submit would leak). */}
<form
onSubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<CardContent className="space-y-6">
{locked && (
<p className="text-sm text-muted-foreground border border-border rounded-lg p-3">
{t('opening_balances_locked_notice')}
@@ -265,7 +345,7 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
{!readOnly && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Button type="submit" disabled={saving || !dirty}>
<Save className="h-4 w-4 mr-2" />
{saving
? t('opening_balances_saving')
@@ -275,7 +355,8 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
</Button>
</div>
)}
</CardContent>
</CardContent>
</form>
</Card>
)
}