fix(salary): show the sysselsättningsgrad product next to the run salary input (#1702)

A Discord report had a 10 % employee: typing 4 531 in the run gave a
453,10 kr gross, so the user typed 45 310 to get it right. The engine was
correct (grundlön = månadslön × sysselsättningsgrad / 100) but nothing on
the row said so; the formula only lived in Beräkningsdetaljer. Below
100 % the row now prints "× 10 % = 4 531 kr" under the monthly salary
(input and read-only shapes), and both employee forms explain under
Sysselsättningsgrad that the base salary is monthly salary × degree.

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-19 20:07:51 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent f101bde6a8
commit eb0df1722b
5 changed files with 42 additions and 14 deletions
@@ -317,6 +317,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
<div className="space-y-2">
<Label htmlFor="employment_degree">{t('form_employment_degree')}</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_employment_degree_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="hours_per_week">{t('form_hours_per_week')}</Label>
+1
View File
@@ -294,6 +294,7 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
</Field>
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
<p className="text-xs text-muted-foreground">Under 100 % räknas grundlönen som månadslön × sysselsättningsgrad.</p>
</Field>
<Field label="Timmar per vecka" htmlFor="hours_per_week">
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue="40" min="1" max="80" step="0.5" />
+36 -14
View File
@@ -142,6 +142,22 @@ export function RunEmployeesTable({
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// right under the number instead of only in Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] leading-tight text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
return (
<div
@@ -176,21 +192,27 @@ export function RunEmployeesTable({
{/* Right side: editable monthly salary (draft) or the number */}
{editableSalary ? (
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="h-8 w-28 text-right tabular-nums shrink-0"
/>
<div className="flex flex-col items-end gap-0.5 shrink-0">
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="h-8 w-28 text-right tabular-nums"
/>
{degreeHint}
</div>
) : (
<span className="text-right tabular-nums font-medium shrink-0">
{formatCurrency(primaryNumber)}
</span>
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-right tabular-nums font-medium">
{formatCurrency(primaryNumber)}
</span>
{degreeHint}
</div>
)}
{/* Payslip PDF */}
+2
View File
@@ -6703,6 +6703,7 @@
"view_pdf": "View PDF",
"view_payslip_title": "View payslip",
"salary_input_aria": "Monthly salary for {name}",
"degree_hint": "× {degree} % = {amount}",
"remove_sr": "Remove",
"remove_employee_aria": "Remove {name} from the payroll run",
"remove_employee_title": "Remove from the payroll run",
@@ -6937,6 +6938,7 @@
"form_employment_start": "Employment start date",
"form_employment_end": "End date",
"form_employment_degree": "Degree of employment (%)",
"form_employment_degree_hint": "Below 100 % the base salary is monthly salary × degree of employment.",
"form_hours_per_week": "Hours per week",
"form_workdays_per_week": "Workdays per week",
"form_work_schedule_hint": "The work schedule drives daily rates for absence and hourly-rate derivation (40 h / 5 days = default).",
+2
View File
@@ -6703,6 +6703,7 @@
"view_pdf": "Visa PDF",
"view_payslip_title": "Visa lönespecifikation",
"salary_input_aria": "Månadslön för {name}",
"degree_hint": "× {degree} % = {amount}",
"remove_sr": "Ta bort",
"remove_employee_aria": "Ta bort {name} från lönekörningen",
"remove_employee_title": "Ta bort från lönekörningen",
@@ -6937,6 +6938,7 @@
"form_employment_start": "Anställningsdatum",
"form_employment_end": "Slutdatum",
"form_employment_degree": "Sysselsättningsgrad (%)",
"form_employment_degree_hint": "Under 100 % räknas grundlönen som månadslön × sysselsättningsgrad.",
"form_hours_per_week": "Timmar per vecka",
"form_workdays_per_week": "Arbetsdagar per vecka",
"form_work_schedule_hint": "Arbetsschemat styr dagslön vid frånvaro och timlöneberäkning (40 h/5 dagar = standard).",