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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
dd4ced1f93
commit
51539b93ed
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user