Files
accounted/components/salary/AbsenceCalendar.tsx
T
Jakob WennbergandClaude Opus 4.7 f3fd4c0822 feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* feat(salary): per-day absence tracking with calendar UX

Replace aggregated-day absence counts with per-day records so payroll
calculations can correctly enforce Swedish legal rules that depend on
actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande
within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per
rolling 12 months, day-8 läkarintyg flag, day-15 transition to
Försäkringskassan.

Adds:
- salary_absence_days table (RLS, dedup unique on employee+date+type)
- /api/salary/employees/[id]/absence CRUD route
- deriveAbsenceLineItems helper that walks per-day records into
  sjuklöneperioder and emits correctly-classified line items, with the
  existing absence-calculator formulas reused for VAB / parental
- Per-employee pay-spec detail page with month-grid AbsenceCalendar
- Calculate route now derives line items from the calendar before
  running the salary engine, replacing the prior sumQuantity model
- Salary run GET surfaces the formatted Skatteverket arbetsgivare ID
  so downstream UI can build extension URLs without a second round-trip
- GET /salary/runs/[id]/employees/[employeeId] for the detail page

Tests: 15 new unit tests covering segment merge, återinsjuknande
within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag,
VAB/parental semesterlönegrundande ceilings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag

Three hardening fixes from the prior audit, plus a runtime extension
toggle for phased rollout.

api-client.ts:
- Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a
  Swedish user message. The 4 req/sec local rate limiter normally
  prevents this, but the per-consumer gateway quota can still hit.
- Extend the error union with TOKEN_CORRUPTED for the token-store fix
  below.

token-store.ts:
- Surface decryption failures instead of silently returning null. A
  rotated key or tampered ciphertext used to look like "not connected";
  callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID"
  message and a structured log line for ops.

Extension dispatcher (app/api/extensions/ext/[...path]/route.ts):
- Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED
  is not exactly "true", the dispatcher returns 503 with code
  EXTENSION_DISABLED, letting ops disable a single integration mid-
  rollout without redeploying or removing it from extensions.config.json.
  UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an
  empty state.

Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error
codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission

AGI XML upgrade:
- Emit <gem:Franvarouppgift> top-level blocks for VAB and parental
  leave events sourced from salary_absence_days, per SKV 4785 +
  technical doc. Element order matches the spec example file.
  TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental,
  with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for
  hours. Stable 1-based specifikationsnummer per (employee, period),
  date-sorted. Skipped entirely for periods before 202501.
- Sick days are NOT emitted (they go to Försäkringskassan).
- FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity
  × dailyRate × 0.80 instead of Math.abs(amount). The line-item
  amount is the net deduction (lostPay − sjuklon), not the cost,
  so the prior formula understated by a factor of four.

AGI submission UI:
- New AGIPanel mirroring SkatteverketPanel's validate → draft → lock
  → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED
  and renders a clear empty state. Replaces the bare "Skicka till
  Skatteverket" button on /salary/runs/[id], keeping the AGI XML
  download as a sibling for archival / manual upload fallback.
- Salary run rows now link to the per-employee detail page added in
  the previous commit.

Tests: 14 new agi-xml cases covering element order, type↔hour-field
mapping, specifikationsnummer ordering, fractional-hour formatting,
range clamping (0.01-24.00), period guard at 202501 boundary,
placement after Blankett blocks, multi-employee date ordering,
required-fields invariant, omission when no events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför

Adds read-only Skattekonto v2.1 access via the existing BankID OAuth
flow (extends the OAuth scope with `skattekonto`). Daily background
sync pulls saldo + transactions, dedupes on (company_id, dedup_key),
and surfaces the data in a /skattekonto dashboard plus a settings
panel for connection management.

Backend:
- skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and
  /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated
  SkatteverketSkattekontoError.
- skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT
  on (company_id, dedup_key) so kommande rows graduate to tidigare
  in place. Dedup key uses transaktionsidentitet when available, else
  sha256 of (date|amount|text). Caches saldo snapshot in extension_data.
  Emits skattekonto.synced / balance.changed (sign flip) /
  transaction.upcoming (first appearance) / connection.expired.
- skattekonto-booking.ts: keyword→counter-account rules with AB/EF
  differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650
  for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for
  kostnads-/intäktsränta). Creates a draft journal entry against
  BAS 1630, leaves it for the user to review and commit. Throws
  NO_COUNTER_ACCOUNT instead of guessing when no rule matches.
- Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by
  CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company
  cooldown of 1 hour, time budget 50s, distinct `expired` status
  for token-exhaustion separate from generic errors.

Database:
- skattekonto_transactions: company-scoped with RLS, unique
  (company_id, dedup_key), indexed on (company_id, date DESC) and
  (company_id, status). journal_entry_id FK with ON DELETE SET NULL
  so a row can be re-bokförd after entry deletion.

Frontend:
- /skattekonto/page.tsx: dashboard with saldo card, transactions list
  (booked + upcoming), per-row "Bokför" action.
- /settings/skatteverket: connection panel showing scope/expiry.
- Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS).

Tests: 9 booking-rule cases (counter-account guessing, AB/EF
divergence, no-match throw) + 7 mapper cases (dedup key stability,
sign convention, kommande→tidigare graduation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR review findings

Build:
- Fix Next.js build failure: Zod refuses .partial() on a refined schema.
  Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence
  DELETE handler with a fresh z.object that defines its own optional
  fields.

Greptile findings (PR #388):
- skattekonto_transactions UPDATE policy was missing WITH CHECK; without
  it a user could mutate company_id to one they don't belong to. Edit
  the original migration for fresh applies + add a follow-up migration
  that drops/recreates the policy with both clauses (already applied
  to prod via Supabase MCP).
- FK499 TotalSjuklonekostnad now reads sjuklonRate from
  run.calculation_params (snapshot taken at calc time) instead of a
  hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is
  honored. Falls back to 0.80 for older runs without the snapshot.
- Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the
  flag is server-side only. NEXT_PUBLIC_* vars are inlined into the
  client bundle at build time, which would create split-brain (server
  503 vs client still rendering enabled flow) on a flag flip without
  redeploy. UI panels detect 503 by response code, not by reading the
  env directly, so no client-visible change is needed.
- Add pg-real RLS smoke tests for both new tables (salary_absence_days
  and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH
  CHECK enforcement, unique-constraint enforcement, cross-tenant dedup
  key allowed.

Swedish compliance review:
- Document the högriskskydd cap interpretation in
  derive-absence-line-items.ts. We count *sjuklöneperioder* in the
  rolling 12-month window, matching the law's plain reading
  ("från och med den 11:e sjukperioden ... görs inget karensavdrag").
  An alternative reading counts only periods that actually had karens
  deducted; that requires persisting per-period karens-deduction state,
  which gnubok doesn't yet do. The period-count reading can over-
  suppress, never under-suppress, so it's the safer default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs

dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI
when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/.
Inline the saldoResponse + transaktionerResponse fixtures verbatim
from the spec; the test still verifies our mappers + dedup-key logic
against the same shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 19:01:21 +02:00

446 lines
15 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import {
addDays,
endOfMonth,
format,
isSameDay,
parseISO,
startOfMonth,
startOfWeek,
} from 'date-fns'
import { sv } from 'date-fns/locale'
import {
Activity,
Baby,
ChevronLeft,
ChevronRight,
Heart,
HeartPulse,
Loader2,
Trash2,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
// ─── Types ─────────────────────────────────────────────────────────
type AbsenceType =
| 'sick'
| 'vab'
| 'parental'
| 'pregnancy'
| 'care_relative'
| 'study'
| 'other_leave'
interface AbsenceDay {
id: string
absence_date: string
absence_type: AbsenceType
hours: number
notes: string | null
}
interface AbsenceTypeMeta {
label: string
shortLabel: string
icon: LucideIcon
// Background dot color tokens — paired with icons so color isn't sole indicator (WCAG AA).
dotClass: string
}
const TYPE_META: Record<AbsenceType, AbsenceTypeMeta> = {
sick: { label: 'Sjukfrånvaro', shortLabel: 'Sjuk', icon: HeartPulse, dotClass: 'bg-red-400' },
vab: { label: 'VAB', shortLabel: 'VAB', icon: Baby, dotClass: 'bg-amber-400' },
parental: { label: 'Föräldraledighet', shortLabel: 'Förä.', icon: Heart, dotClass: 'bg-emerald-400' },
pregnancy: { label: 'Graviditetspenning', shortLabel: 'Grav.', icon: Heart, dotClass: 'bg-pink-400' },
care_relative: { label: 'Närståendepenning', shortLabel: 'Närst.', icon: Heart, dotClass: 'bg-blue-400' },
study: { label: 'Studieledig', shortLabel: 'Studie', icon: Activity, dotClass: 'bg-indigo-400' },
other_leave: { label: 'Övrig ledighet', shortLabel: 'Övrigt', icon: Activity, dotClass: 'bg-zinc-400' },
}
const TYPE_ORDER: AbsenceType[] = ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'other_leave']
// ─── Component ─────────────────────────────────────────────────────
export interface AbsenceCalendarProps {
employeeId: string
/** Pay period start (YYYY-MM-DD). The calendar opens on this month. */
periodStart: string
/** Pay period end (YYYY-MM-DD). Days outside the period are still
* visible (and editable, since absence is per-employee not per-run)
* but visually muted. */
periodEnd: string
/** Optional: link new absence rows to a specific salary run. */
salaryRunEmployeeId?: string
/** When true, calendar is read-only (e.g. for booked runs). */
readOnly?: boolean
/** Called after a successful create/delete so the parent can refresh
* derived totals. */
onChange?: () => void
}
export function AbsenceCalendar({
employeeId,
periodStart,
periodEnd,
salaryRunEmployeeId,
readOnly = false,
onChange,
}: AbsenceCalendarProps) {
const periodStartDate = useMemo(() => parseISO(periodStart), [periodStart])
const periodEndDate = useMemo(() => parseISO(periodEnd), [periodEnd])
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(periodStartDate))
const [days, setDays] = useState<AbsenceDay[]>([])
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState<{ date: string; existing?: AbsenceDay } | null>(null)
const [error, setError] = useState<string | null>(null)
// Pad to a 6-week grid starting on Monday (Swedish week).
const gridStart = startOfWeek(startOfMonth(visibleMonth), { weekStartsOn: 1 })
const loadAbsences = async () => {
setLoading(true)
setError(null)
try {
const from = format(gridStart, 'yyyy-MM-dd')
const to = format(addDays(gridStart, 41), 'yyyy-MM-dd')
const res = await fetch(
`/api/salary/employees/${employeeId}/absence?from=${from}&to=${to}`,
)
const json = await res.json()
if (!res.ok) {
throw new Error(json.error || 'Kunde inte ladda frånvaro')
}
setDays(json.data ?? [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAbsences()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [employeeId, visibleMonth.getFullYear(), visibleMonth.getMonth()])
const dayMap = useMemo(() => {
const m = new Map<string, AbsenceDay[]>()
for (const d of days) {
const key = d.absence_date
const list = m.get(key) ?? []
list.push(d)
m.set(key, list)
}
return m
}, [days])
const cells = useMemo(() => {
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i))
}, [gridStart])
const handleCellClick = (date: Date) => {
if (readOnly) return
const key = format(date, 'yyyy-MM-dd')
const existing = dayMap.get(key)?.[0] // edit first if multiple types same day
setEditing({ date: key, existing })
}
return (
<div className="rounded-md border bg-card">
{/* Header */}
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(startOfMonth(prev), -1))}
aria-label="Föregående månad"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium tabular-nums">
{format(visibleMonth, 'MMMM yyyy', { locale: sv })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setVisibleMonth(prev => addDays(endOfMonth(prev), 1))}
aria-label="Nästa månad"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{loading && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
</div>
{/* Weekday header */}
<div className="grid grid-cols-7 border-b bg-muted/40 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => (
<div key={d} className="px-2 py-1.5 text-center">{d}</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
{cells.map((date, i) => {
const key = format(date, 'yyyy-MM-dd')
const inMonth = date.getMonth() === visibleMonth.getMonth()
const inPeriod = date >= periodStartDate && date <= periodEndDate
const today = isSameDay(date, new Date())
const dayAbsences = dayMap.get(key) ?? []
return (
<button
type="button"
key={i}
onClick={() => handleCellClick(date)}
disabled={readOnly}
className={cn(
'relative flex h-20 flex-col items-start gap-0.5 border-b border-r p-1.5 text-left text-xs transition-colors',
!readOnly && 'hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',
readOnly && 'cursor-default',
!inMonth && 'bg-muted/30 text-muted-foreground/60',
!inPeriod && inMonth && 'bg-muted/10',
today && 'ring-1 ring-inset ring-primary/40',
)}
>
<span className={cn('tabular-nums', today && 'font-semibold')}>
{format(date, 'd')}
</span>
{dayAbsences.length > 0 && (
<div className="mt-auto flex flex-wrap items-center gap-0.5">
{dayAbsences.map(a => {
const meta = TYPE_META[a.absence_type]
const Icon = meta.icon
return (
<span
key={a.id}
className={cn(
'inline-flex items-center gap-0.5 rounded-full px-1 py-px text-[10px] font-medium text-foreground',
meta.dotClass,
)}
title={`${meta.label} (${a.hours}h)`}
>
<Icon className="h-2.5 w-2.5" aria-hidden />
<span>{meta.shortLabel}</span>
</span>
)
})}
</div>
)}
</button>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-3 py-2 text-[11px] text-muted-foreground">
{TYPE_ORDER.map(t => {
const meta = TYPE_META[t]
const Icon = meta.icon
return (
<span key={t} className="inline-flex items-center gap-1">
<span className={cn('inline-flex h-3 w-3 items-center justify-center rounded-full', meta.dotClass)}>
<Icon className="h-2 w-2" aria-hidden />
</span>
<span>{meta.label}</span>
</span>
)
})}
</div>
{error && (
<div className="border-t bg-destructive/10 px-3 py-2 text-xs text-destructive">
{error}
</div>
)}
{/* Edit dialog */}
{editing && (
<AbsenceDayDialog
employeeId={employeeId}
salaryRunEmployeeId={salaryRunEmployeeId}
date={editing.date}
existing={editing.existing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
loadAbsences()
onChange?.()
}}
/>
)}
</div>
)
}
// ─── Dialog ────────────────────────────────────────────────────────
interface AbsenceDayDialogProps {
employeeId: string
salaryRunEmployeeId?: string
date: string
existing?: AbsenceDay
onClose: () => void
onSaved: () => void
}
function AbsenceDayDialog({
employeeId,
salaryRunEmployeeId,
date,
existing,
onClose,
onSaved,
}: AbsenceDayDialogProps) {
const [absenceType, setAbsenceType] = useState<AbsenceType>(existing?.absence_type ?? 'sick')
const [hours, setHours] = useState<string>(existing?.hours?.toString() ?? '8')
const [notes, setNotes] = useState<string>(existing?.notes ?? '')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSubmitting(true)
setError(null)
try {
const hoursNum = parseFloat(hours)
if (!isFinite(hoursNum) || hoursNum <= 0 || hoursNum > 24) {
throw new Error('Timmar måste vara mellan 0 och 24')
}
const res = await fetch(`/api/salary/employees/${employeeId}/absence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
absence_date: date,
absence_type: absenceType,
hours: hoursNum,
notes: notes.trim() || undefined,
salary_run_employee_id: salaryRunEmployeeId,
}),
})
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte spara frånvaro')
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
const handleDelete = async () => {
if (!existing) return
setSubmitting(true)
setError(null)
try {
const res = await fetch(
`/api/salary/employees/${employeeId}/absence?date=${date}&type=${existing.absence_type}`,
{ method: 'DELETE' },
)
const json = await res.json()
if (!res.ok) throw new Error(json.error || 'Kunde inte radera frånvaro')
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
Frånvaro {format(parseISO(date), 'd MMMM yyyy', { locale: sv })}
</DialogTitle>
<DialogDescription>
Välj typ av frånvaro. Sjuklöneberäkning, karensavdrag och AGI-rapportering härleds automatiskt.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-medium">Typ</label>
<Select value={absenceType} onValueChange={v => setAbsenceType(v as AbsenceType)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TYPE_ORDER.map(t => (
<SelectItem key={t} value={t}>{TYPE_META[t].label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="absence-hours">Timmar</label>
<input
id="absence-hours"
type="number"
min={0.5}
max={24}
step={0.5}
value={hours}
onChange={(e) => setHours(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium" htmlFor="absence-notes">Anteckning (valfri)</label>
<textarea
id="absence-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-2 text-xs text-destructive">{error}</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<div>
{existing && (
<Button variant="outline" size="sm" onClick={handleDelete} disabled={submitting}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Ta bort
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Avbryt
</Button>
<Button size="sm" onClick={handleSave} disabled={submitting}>
{submitting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{existing ? 'Uppdatera' : 'Lägg till'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}