From f3fd4c08222331cb4023b404399732758177b299 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 4 May 2026 19:01:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(salary,=20skatteverket):=20per-day=20absen?= =?UTF-8?q?ce=20+=20AGI=20Fr=C3=A5nvarouppgift=20+=20skattekonto=20+=20har?= =?UTF-8?q?dening=20(#388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission AGI XML upgrade: - Emit 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../runs/[id]/employees/[employeeId]/page.tsx | 210 ++++++++ app/(dashboard)/salary/runs/[id]/page.tsx | 172 +++--- .../settings/skatteverket/page.tsx | 40 ++ app/(dashboard)/skattekonto/page.tsx | 433 +++++++++++++++ app/api/extensions/ext/[...path]/route.ts | 33 ++ .../skattekonto/sync/cron/route.ts | 166 ++++++ .../salary/employees/[id]/absence/route.ts | 191 +++++++ app/api/salary/runs/[id]/agi/xml/route.ts | 60 ++- app/api/salary/runs/[id]/calculate/route.ts | 116 ++++- .../runs/[id]/employees/[employeeId]/route.ts | 30 ++ app/api/salary/runs/[id]/route.ts | 19 + components/salary/AGIPanel.tsx | 491 ++++++++++++++++++ components/salary/AbsenceCalendar.tsx | 445 ++++++++++++++++ components/settings/SettingsSidebar.tsx | 2 + .../settings/SkatteverketConnectPanel.tsx | 192 +++++++ .../skatteverket/__tests__/api-client.test.ts | 113 ++++ .../__tests__/skattekonto-booking.test.ts | 68 +++ .../__tests__/skattekonto-mappers.test.ts | 159 ++++++ .../skattekonto-transactions.pg.test.ts | 91 ++++ .../__tests__/token-store.test.ts | 79 +++ extensions/general/skatteverket/index.ts | 172 +++++- .../general/skatteverket/lib/api-client.ts | 36 +- extensions/general/skatteverket/lib/oauth.ts | 2 +- .../skatteverket/lib/skattekonto-booking.ts | 259 +++++++++ .../skatteverket/lib/skattekonto-client.ts | 157 ++++++ .../skatteverket/lib/skattekonto-sync.ts | 276 ++++++++++ .../general/skatteverket/lib/token-store.ts | 22 +- extensions/general/skatteverket/types.ts | 97 ++++ lib/api/schemas.ts | 33 ++ lib/events/types.ts | 5 + lib/salary/__tests__/agi-xml.test.ts | 162 ++++++ .../derive-absence-line-items.test.ts | 248 +++++++++ .../__tests__/salary-absence-days.pg.test.ts | 89 ++++ lib/salary/agi/xml-generator.ts | 115 +++- lib/salary/derive-absence-line-items.ts | 416 +++++++++++++++ .../20260504130000_salary_absence_days.sql | 88 ++++ ...0260504160000_skattekonto_transactions.sql | 78 +++ ...tekonto_transactions_update_with_check.sql | 18 + vercel.json | 4 + 39 files changed, 5226 insertions(+), 161 deletions(-) create mode 100644 app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx create mode 100644 app/(dashboard)/settings/skatteverket/page.tsx create mode 100644 app/(dashboard)/skattekonto/page.tsx create mode 100644 app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts create mode 100644 app/api/salary/employees/[id]/absence/route.ts create mode 100644 components/salary/AGIPanel.tsx create mode 100644 components/salary/AbsenceCalendar.tsx create mode 100644 components/settings/SkatteverketConnectPanel.tsx create mode 100644 extensions/general/skatteverket/__tests__/api-client.test.ts create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-booking.test.ts create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-mappers.test.ts create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-transactions.pg.test.ts create mode 100644 extensions/general/skatteverket/__tests__/token-store.test.ts create mode 100644 extensions/general/skatteverket/lib/skattekonto-booking.ts create mode 100644 extensions/general/skatteverket/lib/skattekonto-client.ts create mode 100644 extensions/general/skatteverket/lib/skattekonto-sync.ts create mode 100644 lib/salary/__tests__/derive-absence-line-items.test.ts create mode 100644 lib/salary/__tests__/salary-absence-days.pg.test.ts create mode 100644 lib/salary/derive-absence-line-items.ts create mode 100644 supabase/migrations/20260504130000_salary_absence_days.sql create mode 100644 supabase/migrations/20260504160000_skattekonto_transactions.sql create mode 100644 supabase/migrations/20260504170000_skattekonto_transactions_update_with_check.sql diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx new file mode 100644 index 00000000..34034c75 --- /dev/null +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -0,0 +1,210 @@ +'use client' + +import { use, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { ArrowLeft, Loader2 } from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { AbsenceCalendar } from '@/components/salary/AbsenceCalendar' +import { formatCurrency } from '@/lib/utils' +import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, Employee } from '@/types' + +interface DetailResponse { + run: SalaryRun + runEmployee: SalaryRunEmployee & { employee: Employee; line_items: SalaryLineItem[] } +} + +export default function SalaryRunEmployeeDetailPage({ + params, +}: { + params: Promise<{ id: string; employeeId: string }> +}) { + const { id: runId, employeeId } = use(params) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const load = async () => { + setLoading(true) + setError(null) + try { + const [runRes, sreRes] = await Promise.all([ + fetch(`/api/salary/runs/${runId}`), + fetch(`/api/salary/runs/${runId}/employees/${employeeId}`), + ]) + const runJson = await runRes.json() + const sreJson = await sreRes.json() + if (!runRes.ok) throw new Error(runJson.error || 'Kunde inte ladda lönekörning') + if (!sreRes.ok) throw new Error(sreJson.error || 'Kunde inte ladda anställd') + setData({ run: runJson.data, runEmployee: sreJson.data }) + } catch (e) { + setError(e instanceof Error ? e.message : 'Okänt fel') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runId, employeeId]) + + const periodStart = useMemo(() => { + if (!data) return '' + const y = data.run.period_year + const m = data.run.period_month + return `${y}-${String(m).padStart(2, '0')}-01` + }, [data]) + + const periodEnd = useMemo(() => { + if (!data) return '' + const y = data.run.period_year + const m = data.run.period_month + const last = new Date(Date.UTC(y, m, 0)).getUTCDate() + return `${y}-${String(m).padStart(2, '0')}-${String(last).padStart(2, '0')}` + }, [data]) + + if (loading) { + return ( +
+ Laddar... +
+ ) + } + + if (error || !data) { + return ( +
+ + Tillbaka till lönekörning + +
+ {error ?? 'Kunde inte ladda anställd'} +
+
+ ) + } + + const { run, runEmployee } = data + const employee = runEmployee.employee + const lineItems = runEmployee.line_items ?? [] + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const readOnly = run.status !== 'draft' && run.status !== 'review' + + return ( +
+ {/* Header */} +
+ + Tillbaka till lönekörning + +
+
+

+ {employee.first_name} {employee.last_name} +

+

+ {employee.personnummer} · Lönespecifikation {periodLabel} +

+
+ +
+
+ + {/* Summary */} +
+ + + + +
+ + {/* Absence calendar */} + + + Frånvaro +

+ Markera sjukdom, VAB, föräldraledighet och annan frånvaro per dag. + Karensavdrag, sjuklön och AGI-rapportering räknas ut automatiskt. +

+
+ + +
+ + + +
+
+
+ + {/* Line items */} + + + Lönerader ({lineItems.length}) + + + {lineItems.length === 0 ? ( +

+ Inga lönerader. Kör beräkning på lönekörningen för att skapa standardrader. +

+ ) : ( + + + + + + + + + + + {lineItems.map(li => ( + + + + + + + ))} + +
TypBeskrivningAntalBelopp
{li.item_type}{li.description}{li.quantity ?? '—'}{formatCurrency(li.amount)}
+ )} +
+
+
+ ) +} + +function SummaryCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) { + return ( +
+
{label}
+
{formatCurrency(value)}
+
+ ) +} + +function AbsenceCount({ label, days }: { label: string; days: number }) { + return ( +
+
{label}
+
{days} dagar
+
+ ) +} diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index a290daa3..7fb55c0f 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -7,14 +7,17 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { - ArrowLeft, Plus, Calculator, Eye, Check, CreditCard, BookOpen, - ArrowLeftCircle, Loader2, Download, Send, CheckCircle2, + ArrowLeft, Calculator, Eye, Check, CreditCard, BookOpen, + ArrowLeftCircle, Loader2, Download, } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { formatCurrency } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types' +import { AGIPanel } from '@/components/salary/AGIPanel' + +type SalaryRunWithArbetsgivare = SalaryRun & { arbetsgivare?: string | null } const STATUS_LABELS: Record = { draft: 'Utkast', @@ -172,45 +175,6 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: setActionLoading(null) } - async function handleSubmitAgi() { - setActionLoading('agi-submit') - - // Generate AGI XML first if it hasn't been generated yet. - if (!run?.agi_generated_at) { - const xmlRes = await fetch(`/api/salary/runs/${id}/agi/xml`) - if (!xmlRes.ok) { - const result = await xmlRes.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' })) - toast({ - title: 'AGI kunde inte genereras', - description: getErrorMessage(result, { context: 'salary', statusCode: xmlRes.status }), - variant: 'destructive', - }) - setActionLoading(null) - return - } - } - - const res = await fetch(`/api/salary/runs/${id}/agi/submit`, { method: 'POST' }) - const payload = await res.json().catch(() => ({})) - - if (res.ok) { - await loadRun() - toast({ - title: 'AGI skickad till Skatteverket', - description: - (payload?.data?.message as string | undefined) ?? - 'Signera med BankID hos Skatteverket för att slutföra inlämningen.', - }) - } else { - toast({ - title: 'Kunde inte skicka till Skatteverket', - description: getErrorMessage(payload, { context: 'salary', statusCode: res.status }), - variant: 'destructive', - }) - } - setActionLoading(null) - } - if (loading) { return (
@@ -310,20 +274,34 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: - {employees.map(sre => ( - - - {(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee - ? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}` - : `Anställd ${sre.employee_id.slice(0, 8)}...`} - - {formatCurrency(sre.gross_salary)} - {formatCurrency(sre.tax_withheld)} - {formatCurrency(sre.net_salary)} - {formatCurrency(sre.avgifter_amount)} - {formatCurrency(sre.vacation_accrual)} - - ))} + {employees.map(sre => { + const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee + const name = employee + ? `${employee.first_name} ${employee.last_name}` + : `Anställd ${sre.employee_id.slice(0, 8)}...` + return ( + router.push(`/salary/runs/${id}/employees/${sre.employee_id}`)} + > + + e.stopPropagation()} + > + {name} + + + {formatCurrency(sre.gross_salary)} + {formatCurrency(sre.tax_withheld)} + {formatCurrency(sre.net_salary)} + {formatCurrency(sre.avgifter_amount)} + {formatCurrency(sre.vacation_accrual)} + + ) + })} )} @@ -403,68 +381,34 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: {/* AGI (Arbetsgivardeklaration) — available once the run is booked */} {run.status === 'booked' && ( - - - Arbetsgivardeklaration (AGI) - - -
-
- {run.agi_generated_at ? ( - <> - - - AGI-fil genererad {new Date(run.agi_generated_at).toLocaleString('sv-SE')} - - +
+ + {canWrite && ( +
+
-
- {run.agi_submitted_at ? ( - <> - - - Skickad till Skatteverket {new Date(run.agi_submitted_at).toLocaleString('sv-SE')} - - - ) : ( - - Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning. - - )} -
+ Ladda ner AGI-fil (XML) +
- {canWrite && ( -
- - -
- )} - - + )} +
)} {/* Actions */} diff --git a/app/(dashboard)/settings/skatteverket/page.tsx b/app/(dashboard)/settings/skatteverket/page.tsx new file mode 100644 index 00000000..8ba6f21a --- /dev/null +++ b/app/(dashboard)/settings/skatteverket/page.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useEffect } from 'react' +import { useSearchParams, useRouter } from 'next/navigation' +import { useToast } from '@/components/ui/use-toast' +import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel' + +export default function SkatteverketSettingsPage() { + const searchParams = useSearchParams() + const router = useRouter() + const { toast } = useToast() + + useEffect(() => { + const connected = searchParams.get('skv_connected') + const error = searchParams.get('skv_error') + + if (connected === 'true') { + toast({ + title: 'Skatteverket anslutet', + description: 'Du kan nu skicka deklarationer och hämta skattekonto-saldot.', + }) + router.replace('/settings/skatteverket') + } else if (error) { + let msg: string + try { msg = decodeURIComponent(error) } catch { msg = error } + toast({ + title: 'Anslutning misslyckades', + description: msg, + variant: 'destructive', + }) + router.replace('/settings/skatteverket') + } + }, [searchParams, router, toast]) + + return ( +
+ +
+ ) +} diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx new file mode 100644 index 00000000..3a39faaf --- /dev/null +++ b/app/(dashboard)/skattekonto/page.tsx @@ -0,0 +1,433 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency } from '@/lib/utils' +import { + Copy, + ExternalLink, + FileCheck, + Landmark, + RefreshCw, +} from 'lucide-react' +import type { + SkatteverketSaldoResponse, + StoredSkattekontoTransaction, +} from '@/extensions/general/skatteverket/types' + +interface SaldoEnvelope { + data: SkatteverketSaldoResponse | null + fetchedAt: string | null + lastSyncedAt: string | null +} + +interface TransaktionerEnvelope { + data: { + booked: StoredSkattekontoTransaction[] + upcoming: StoredSkattekontoTransaction[] + } +} + +export default function SkattekontoPage() { + const { toast } = useToast() + const [saldo, setSaldo] = useState(null) + const [tx, setTx] = useState(null) + const [loading, setLoading] = useState(true) + const [syncing, setSyncing] = useState(false) + const [bookingId, setBookingId] = useState(null) + const [notConnected, setNotConnected] = useState(false) + + const reload = useCallback(async () => { + setLoading(true) + try { + const [saldoRes, txRes] = await Promise.all([ + fetch('/api/extensions/ext/skatteverket/skattekonto/saldo'), + fetch('/api/extensions/ext/skatteverket/skattekonto/transaktioner'), + ]) + + if (saldoRes.status === 401) { + setNotConnected(true) + return + } + + const saldoJson = (await saldoRes.json()) as SaldoEnvelope + setSaldo(saldoJson) + + if (txRes.ok) { + const txJson = (await txRes.json()) as TransaktionerEnvelope + setTx(txJson.data) + } + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + async function syncNow() { + setSyncing(true) + try { + const res = await fetch('/api/extensions/ext/skatteverket/skattekonto/sync', { + method: 'POST', + }) + const json = await res.json() + if (!res.ok) { + if (res.status === 401) { + setNotConnected(true) + return + } + throw new Error(json.error || 'Synk misslyckades') + } + toast({ + title: 'Skattekonto synkroniserat', + description: `${json.data.booked} bokförda, ${json.data.upcoming} kommande`, + }) + await reload() + } catch (err) { + toast({ + title: 'Synk misslyckades', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + } finally { + setSyncing(false) + } + } + + async function bokfor(id: string) { + setBookingId(id) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/skattekonto/transaktioner/${id}/bokfor`, + { method: 'POST' }, + ) + const json = await res.json() + if (!res.ok) { + throw new Error(json.error || 'Bokföring misslyckades') + } + toast({ + title: 'Utkast skapat', + description: 'Granska och bokför verifikatet i Bokföring.', + }) + // Take the user to the draft so they can review. + window.location.href = `/bookkeeping/${json.data.entry.id}` + } catch (err) { + toast({ + title: 'Kunde inte bokföra', + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }) + } finally { + setBookingId(null) + } + } + + function copyOcr(ocr: string) { + navigator.clipboard + .writeText(ocr) + .then(() => toast({ title: 'OCR kopierat' })) + .catch(() => {}) + } + + if (notConnected) { + return ( +
+ + + + +

Skatteverket är inte anslutet

+

+ För att se saldo och transaktioner på skattekontot behöver du + ansluta med BankID i inställningarna. +

+ +
+
+
+ ) + } + + return ( +
+ + + {syncing ? 'Synkroniserar…' : 'Synkronisera nu'} + + } + /> + + + + + + Transaktioner + + + + + + Bokförda {tx?.booked ? `(${tx.booked.length})` : ''} + + + Kommande {tx?.upcoming ? `(${tx.upcoming.length})` : ''} + + + + + + + + + + + +
+ ) +} + +function PageHeading({ right }: { right?: React.ReactNode }) { + return ( +
+
+

Skattekonto

+

+ Saldo och transaktioner från Skatteverket +

+
+ {right} +
+ ) +} + +function BalanceHero({ + saldo, + loading, + onCopyOcr, +}: { + saldo: SaldoEnvelope | null + loading: boolean + onCopyOcr: (ocr: string) => void +}) { + if (loading && !saldo?.data) { + return ( + + + Hämtar saldo… + + + ) + } + + if (!saldo?.data) { + return ( + + + Inget saldo hämtat ännu — klicka på "Synkronisera nu". + + + ) + } + + const { data } = saldo + const skvNegative = data.saldoSkatteverket < 0 + const kfmNegative = data.saldoKronofogden < 0 + + return ( + + +
+
+

+ Skatteverket +

+

+ {formatCurrency(data.saldoSkatteverket)} +

+ {data.rantaSkatteverket !== 0 && ( +

+ Preliminär ränta: {formatCurrency(data.rantaSkatteverket)} +

+ )} +
+
+

+ Kronofogden +

+

+ {formatCurrency(data.saldoKronofogden)} +

+ {data.rantaKronofogden !== 0 && ( +

+ Preliminär ränta: {formatCurrency(data.rantaKronofogden)} +

+ )} +
+
+ +
+
+

+ OCR +

+

+ {data.ocrNummer} + +

+
+
+

+ Nästa avstämning +

+

{data.nastaAvstamningsdatum}

+
+
+

+ Senast uppdaterad +

+

+ {new Date(data.senastUppdaterad).toLocaleString('sv-SE')} +

+
+
+ + {data.informationstext.length > 0 && ( +
+

+ Information från Skatteverket +

+
    + {data.informationstext.map((t, i) => ( +
  • {t}
  • + ))} +
+
+ )} +
+
+ ) +} + +function TransactionTable({ + rows, + onBokfor, + bookingId, + emptyText, + showForfallodatum = false, +}: { + rows: StoredSkattekontoTransaction[] + onBokfor: (id: string) => void + bookingId: string | null + emptyText: string + showForfallodatum?: boolean +}) { + if (rows.length === 0) { + return

{emptyText}

+ } + + return ( +
+ + + + + {showForfallodatum && } + + + + + + + + {rows.map(row => { + const negative = Number(row.belopp_skatteverket) < 0 + const isBooked = !!row.journal_entry_id + return ( + + + {showForfallodatum && ( + + )} + + + + + + ) + })} + +
DatumFörfallodatumBeskrivningBeloppStatus
{row.transaktionsdatum}{row.forfallodatum ?? '–'}{row.transaktionstext} + {formatCurrency(Number(row.belopp_skatteverket))} + + {isBooked ? ( + + + Bokförd + + ) : ( + Ej bokförd + )} + + {isBooked ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts index e06e716c..c1e430dc 100644 --- a/app/api/extensions/ext/[...path]/route.ts +++ b/app/api/extensions/ext/[...path]/route.ts @@ -11,6 +11,27 @@ ensureInitialized() // Heavy extension routes (SIE import, migration) need up to 5 minutes export const maxDuration = 300 +/** + * Per-extension runtime feature flags. Lets ops toggle an integration off + * without redeploying or removing it from extensions.config.json — useful + * for phased rollouts (dev tenants → design partners → general). + * + * The flag is checked on every request. If the env var is not exactly the + * string "true", the dispatcher returns 503 with `code: 'EXTENSION_DISABLED'`. + * + * Server-side env vars only — no NEXT_PUBLIC_ prefix. Next.js inlines + * NEXT_PUBLIC_* into the client bundle at build time, so a flip on Vercel + * without a redeploy would create split-brain (server returns 503, + * client still renders the enabled flow). UI panels detect the 503 by + * response code, not by reading the flag directly. + */ +const EXTENSION_FEATURE_FLAGS: Record = { + skatteverket: { + envVar: 'SKATTEVERKET_ENABLED', + disabledMessage: 'Skatteverket-integrationen är inte aktiverad i denna miljö.', + }, +} + /** * Match a request path against a route pattern. * Supports :param wildcards (e.g., /:id/confirm). @@ -70,6 +91,18 @@ async function handleRequest( return NextResponse.json({ error: 'Extension not found' }, { status: 404 }) } + // Per-extension feature flags. Lets us toggle a single integration off + // mid-rollout without redeploying or removing it from extensions.config.json. + // The frontend (SkatteverketPanel, AGIPanel) inspects the 503 + code to + // render an "extension disabled" empty state. + const flag = EXTENSION_FEATURE_FLAGS[extensionId] + if (flag && process.env[flag.envVar] !== 'true') { + return NextResponse.json( + { error: flag.disabledMessage, code: 'EXTENSION_DISABLED' }, + { status: 503 }, + ) + } + // Match route BEFORE auth so we can check skipAuth (e.g. OAuth callbacks) let matchedRoute: ApiRouteDefinition | null = null let extractedParams: Record = {} diff --git a/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts new file mode 100644 index 00000000..fe21181a --- /dev/null +++ b/app/api/extensions/skatteverket/skattekonto/sync/cron/route.ts @@ -0,0 +1,166 @@ +import { createClient } from '@supabase/supabase-js' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { verifyCronSecret } from '@/lib/auth/cron' +import { createExtensionContext } from '@/lib/extensions/context-factory' +import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync' +import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client' +import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client' + +ensureInitialized() + +export const maxDuration = 60 + +/** + * GET /api/extensions/skatteverket/skattekonto/sync/cron + * + * Daily skattekonto sync (cron 0 4 * * * — 04:00 UTC, 06:00 Swedish time). + * Pulls saldo + transactions for every company that has a connected + * Skatteverket token, and persists the results to skattekonto_transactions. + * + * Skips a company if it was synced within the last hour (cooldown), + * to keep manual + cron triggers from racing each other. + * + * Time budget: 50s (Vercel default 60s function timeout, 10s margin). + * + * Per-company errors are logged but do not abort the run — one expired + * token shouldn't block 49 other working syncs. + */ +export async function GET(request: Request) { + const authError = verifyCronSecret(request) + if (authError) return authError + + // Respect the runtime extension toggle. When the integration is disabled + // the cron should no-op rather than spam Skatteverket with stale tokens. + if (process.env.SKATTEVERKET_ENABLED !== 'true') { + return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 }) + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY + if (!supabaseUrl || !supabaseServiceKey) { + return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 }) + } + + const supabase = createClient(supabaseUrl, supabaseServiceKey) + + // Find all companies with a connected token. The token row is keyed by + // user_id but carries company_id (added in the multi-tenant refactor). + const { data: tokens, error: tokensError } = await supabase + .from('skatteverket_tokens') + .select('user_id, company_id, expires_at, refresh_count') + .order('expires_at', { ascending: true }) + .limit(50) + + if (tokensError) { + console.error('[skattekonto-sync-cron] Failed to fetch tokens', { + message: tokensError.message, + code: tokensError.code, + }) + return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 }) + } + + if (!tokens || tokens.length === 0) { + return NextResponse.json({ message: 'No connected tokens', processed: 0 }) + } + + const startTime = Date.now() + const TIME_BUDGET_MS = 50_000 + const SYNC_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour + + type Result = { + userId: string + companyId: string + status: 'synced' | 'skipped_cooldown' | 'expired' | 'error' + booked?: number + upcoming?: number + error?: string + } + const results: Result[] = [] + + for (const token of tokens) { + if (Date.now() - startTime > TIME_BUDGET_MS) { + console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} tokens`) + break + } + + const userId = token.user_id as string + const companyId = token.company_id as string | null + + if (!companyId) { + // Pre-multi-tenant tokens may lack company_id. Skip — cannot scope. + results.push({ userId, companyId: '(missing)', status: 'error', error: 'No company_id on token' }) + continue + } + + try { + // Cooldown: skip if synced within the last hour. + const { data: lastSyncRow } = await supabase + .from('extension_data') + .select('value, updated_at') + .eq('company_id', companyId) + .eq('extension_id', 'skatteverket') + .eq('key', SKATTEKONTO_LAST_SYNCED_AT_KEY) + .maybeSingle() + + const lastSyncedAt = lastSyncRow?.value as string | undefined + if (lastSyncedAt) { + const elapsed = Date.now() - new Date(lastSyncedAt).getTime() + if (elapsed < SYNC_COOLDOWN_MS) { + results.push({ userId, companyId, status: 'skipped_cooldown' }) + continue + } + } + + const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket') + const syncResult = await syncSkattekonto(ctx) + + results.push({ + userId, + companyId, + status: 'synced', + booked: syncResult.booked, + upcoming: syncResult.upcoming, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + + // Expired token / refresh exhausted is a known outcome — surface it + // distinctly so ops can dashboard "X companies need to reconnect". + if ( + err instanceof SkatteverketAuthError && + (err.code === 'REFRESH_EXHAUSTED' || err.code === 'SESSION_EXPIRED' || err.code === 'TOKEN_CORRUPTED') + ) { + results.push({ userId, companyId, status: 'expired', error: err.code }) + continue + } + + const felkod = err instanceof SkatteverketSkattekontoError ? err.felkod : null + console.error('[skattekonto-sync-cron] Sync failed', { + userId, + companyId, + message, + felkod, + }) + results.push({ userId, companyId, status: 'error', error: message }) + } + } + + const synced = results.filter(r => r.status === 'synced').length + const skipped = results.filter(r => r.status === 'skipped_cooldown').length + const expired = results.filter(r => r.status === 'expired').length + const errors = results.filter(r => r.status === 'error').length + + console.log( + `[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${errors} errors`, + ) + + return NextResponse.json({ + processed: results.length, + synced, + skipped, + expired, + errors, + results, + }) +} diff --git a/app/api/salary/employees/[id]/absence/route.ts b/app/api/salary/employees/[id]/absence/route.ts new file mode 100644 index 00000000..99c1cc59 --- /dev/null +++ b/app/api/salary/employees/[id]/absence/route.ts @@ -0,0 +1,191 @@ +import { z } from 'zod' +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody, validateQuery } from '@/lib/api/validate' +import { + UpsertAbsenceDaySchema, + AbsenceRangeQuerySchema, + AbsenceTypeSchema, +} from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) + +ensureInitialized() + +async function loadEmployee( + supabase: Awaited>, + employeeId: string, + companyId: string, +) { + const { data } = await supabase + .from('employees') + .select('id') + .eq('id', employeeId) + .eq('company_id', companyId) + .maybeSingle() + return data +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: employeeId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const query = validateQuery(request, AbsenceRangeQuerySchema) + if (!query.success) return query.response + + const { data, error } = await supabase + .from('salary_absence_days') + .select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at') + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .gte('absence_date', query.data.from) + .lte('absence_date', query.data.to) + .order('absence_date', { ascending: true }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: employeeId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const validation = await validateBody(request, UpsertAbsenceDaySchema) + if (!validation.success) return validation.response + const body = validation.data + + // Upsert via DELETE+INSERT on the natural key (employee, date, type) so the + // notes/hours/run-link can be replaced cleanly. The unique index makes ON + // CONFLICT viable too, but Supabase's typed client doesn't expose + // onConflict for our composite key without a named constraint name — + // delete-then-insert keeps the pattern consistent with token-store.ts. + const { error: deleteError } = await supabase + .from('salary_absence_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .eq('absence_date', body.absence_date) + .eq('absence_type', body.absence_type) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + const { data, error } = await supabase + .from('salary_absence_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + absence_date: body.absence_date, + absence_type: body.absence_type, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }, { status: 201 }) +} + +// Two modes: ?date=YYYY-MM-DD&type=... (single row) or ?from=…&to=… (range). +// We don't reuse AbsenceRangeQuerySchema.partial() because Zod refuses +// `.partial()` on a schema with refinements (the from<=to check). +const DeleteQuerySchema = z.object({ + from: isoDate.optional(), + to: isoDate.optional(), + date: isoDate.optional(), + type: AbsenceTypeSchema.optional(), +}) + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: employeeId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const query = validateQuery(request, DeleteQuerySchema) + if (!query.success) return query.response + const { date, type, from, to } = query.data + + // Two delete modes: a single (date, type) row, or a date range. + const hasSingle = !!date + const hasRange = !!from && !!to + if (!hasSingle && !hasRange) { + return NextResponse.json( + { error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' }, + { status: 400 }, + ) + } + + let q = supabase + .from('salary_absence_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + + if (hasSingle) { + q = q.eq('absence_date', date!) + if (type) q = q.eq('absence_type', type) + } else { + q = q.gte('absence_date', from!).lte('absence_date', to!) + if (type) q = q.eq('absence_type', type) + } + + const { error } = await q + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: { ok: true } }) +} diff --git a/app/api/salary/runs/[id]/agi/xml/route.ts b/app/api/salary/runs/[id]/agi/xml/route.ts index 0d63472d..5d727277 100644 --- a/app/api/salary/runs/[id]/agi/xml/route.ts +++ b/app/api/salary/runs/[id]/agi/xml/route.ts @@ -71,10 +71,12 @@ export async function GET( .eq('id', user.id) .single() - // Load employees with their data + // Load employees with their data. We need monthly_salary on the employee + // record to derive FK499 sjuklönekostnad from per-day records (the line + // item `amount` is the net deduction, not the sjuklön cost). const { data: runEmployees } = await supabase .from('salary_run_employees') - .select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)') + .select('*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)') .eq('salary_run_id', id) if (!runEmployees || runEmployees.length === 0) { @@ -94,6 +96,36 @@ export async function GET( contactEmail: (settings?.email || profile?.email || user.email || '').trim(), } + // Load per-day absence records for VAB + parental in the pay period. + // Sick days never reach AGI (they go to Försäkringskassan separately). + // The XML generator's Frånvarouppgift section consumes these per event. + const periodStart = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01` + const periodEndDate = new Date(Date.UTC(run.period_year, run.period_month, 0)) + const periodEnd = periodEndDate.toISOString().slice(0, 10) + const employeeIds = runEmployees + .map(sre => sre.employee_id as string) + .filter(Boolean) + const absenceByEmployee = new Map>() + if (employeeIds.length > 0) { + const { data: absenceRows } = await supabase + .from('salary_absence_days') + .select('employee_id, absence_date, absence_type, hours') + .eq('company_id', companyId) + .in('absence_type', ['vab', 'parental']) + .gte('absence_date', periodStart) + .lte('absence_date', periodEnd) + .in('employee_id', employeeIds) + for (const row of (absenceRows ?? [])) { + const list = absenceByEmployee.get(row.employee_id) ?? [] + list.push({ + date: row.absence_date as string, + type: row.absence_type as 'vab' | 'parental', + hours: Number(row.hours ?? 8), + }) + absenceByEmployee.set(row.employee_id, list) + } + } + const employeeData: AGIEmployeeData[] = runEmployees.map(sre => { const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null const lineItems = (sre.line_items || []) as Array> @@ -104,6 +136,8 @@ export async function GET( const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing']) const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other']) + const absenceEvents = absenceByEmployee.get(sre.employee_id as string) + return { personnummer: emp?.personnummer || '', specificationNumber: emp?.specification_number || 0, @@ -118,6 +152,7 @@ export async function GET( sickDays: sre.sick_days > 0 ? sre.sick_days : undefined, vabDays: sre.vab_days > 0 ? sre.vab_days : undefined, parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined, + absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined, } }) @@ -140,15 +175,32 @@ export async function GET( 0 ) - // FK499 TotalSjuklonekostnad — sum of sjuklön paid (days 2–14) across all + // FK499 TotalSjuklonekostnad — sum of *paid* sjuklön (days 2–14) across all // employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan, so // neither counts as an employer sjuklön cost. + // + // Sjuklön cost = dailyRate × sjuklonRate × day-2-14 count. + // The line item `amount` is the *net deduction* (lostPay − sjuklön), not + // the cost — using its quantity field plus the employee's monthly salary + // gives the correct sjuklön cost regardless of the line-item amount + // convention. + // + // sjuklonRate is read from the run's calculation_params snapshot (taken at + // calc time), so an operator override (e.g. for a CBA-specific rate) is + // honored. Falls back to 0.80 (Sjuklönelagen default) for older runs that + // don't have the snapshot. + const calcParams = (run.calculation_params ?? {}) as { sjuklonRate?: number; sjuklon_rate?: number } + const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.80 let totalSjuklonekostnad = 0 for (const sre of runEmployees) { + const monthly = (sre.employee as { monthly_salary?: number } | null)?.monthly_salary ?? 0 + if (!monthly) continue + const dailyRate = monthly / 21 const lineItems = (sre.line_items || []) as Array> for (const li of lineItems) { if (li.item_type === 'sick_day2_14') { - totalSjuklonekostnad += Math.abs((li.amount as number) || 0) + const days = (li.quantity as number) || 0 + totalSjuklonekostnad += dailyRate * sjuklonRate * days } } } diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index 048d831a..6b27b699 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -6,8 +6,18 @@ import { requireWritePermission } from '@/lib/auth/require-write' import { calculateSalary } from '@/lib/salary/calculation-engine' import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config' import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables' +import { loadAndDeriveAbsence } from '@/lib/salary/derive-absence-line-items' +import { getLineItemAccount } from '@/lib/salary/account-mapping' import type { SalaryLineItemType } from '@/types' +const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [ + 'sick_karens', + 'sick_day2_14', + 'sick_day15_plus', + 'vab', + 'parental_leave', +] + ensureInitialized() export async function POST( @@ -125,19 +135,91 @@ export async function POST( ytdByEmployee.set(prior.employee_id, current) } + // Pay period bounds — used to load per-day absence records. + const periodYear = run.period_year as number + const periodMonth = run.period_month as number + const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01` + const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month + const periodEnd = periodEndDate.toISOString().slice(0, 10) + for (const sre of runEmployees) { const emp = sre.employee if (!emp) continue - const lineItems = (sre.line_items || []).map((li: Record) => ({ + // ── Derive absence line items from per-day records ───────────────── + // Sjuklöneperiod boundaries, återinsjuknande, högriskskydd, day-15 + // FK transition all require dates — we can't compute them from + // aggregated quantities. Replace any existing derived absence rows on + // this sre with the freshly-computed ones, then merge into the + // in-memory lineItems array passed to calculateSalary. + const absenceResult = await loadAndDeriveAbsence({ + supabase, + companyId, + employeeId: emp.id, + monthlySalary: emp.monthly_salary || 0, + payrollConfig: config, + periodStart, + periodEnd, + }) + + const { error: delAbsErr } = await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .in('item_type', DERIVED_ABSENCE_TYPES) + if (delAbsErr) { + return NextResponse.json({ error: delAbsErr.message }, { status: 500 }) + } + + if (absenceResult.lineItems.length > 0) { + const rows = absenceResult.lineItems.map((li, idx) => ({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: li.item_type, + description: li.description, + quantity: li.quantity, + amount: Math.round(li.amount * 100) / 100, + is_taxable: li.is_taxable, + is_avgift_basis: li.is_avgift_basis, + is_vacation_basis: li.is_vacation_basis, + is_gross_deduction: li.is_gross_deduction, + is_net_deduction: false, + account_number: getLineItemAccount(li.item_type), + sort_order: 100 + idx, // sort derived items after manual ones + })) + const { error: insAbsErr } = await supabase + .from('salary_line_items') + .insert(rows) + if (insAbsErr) { + return NextResponse.json({ error: insAbsErr.message }, { status: 500 }) + } + } + + // Build the merged in-memory line items: keep non-derived items from + // the originally-loaded sre.line_items, then append the freshly-derived + // absence items. + const manualLineItems = (sre.line_items || []) + .filter((li: Record) => + !DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) + .map((li: Record) => ({ + itemType: li.item_type as SalaryLineItemType, + amount: li.amount as number, + isTaxable: li.is_taxable as boolean, + isAvgiftBasis: li.is_avgift_basis as boolean, + isVacationBasis: li.is_vacation_basis as boolean, + isGrossDeduction: li.is_gross_deduction as boolean, + isNetDeduction: li.is_net_deduction as boolean, + })) + const derivedLineItems = absenceResult.lineItems.map(li => ({ itemType: li.item_type as SalaryLineItemType, - amount: li.amount as number, - isTaxable: li.is_taxable as boolean, - isAvgiftBasis: li.is_avgift_basis as boolean, - isVacationBasis: li.is_vacation_basis as boolean, - isGrossDeduction: li.is_gross_deduction as boolean, - isNetDeduction: li.is_net_deduction as boolean, + amount: li.amount, + isTaxable: li.is_taxable, + isAvgiftBasis: li.is_avgift_basis, + isVacationBasis: li.is_vacation_basis, + isGrossDeduction: li.is_gross_deduction, + isNetDeduction: false, })) + const lineItems = [...manualLineItems, ...derivedLineItems] const result = calculateSalary( { @@ -175,17 +257,15 @@ export async function POST( })) ) - // Count absence days from line items - const rawLines = (sre.line_items || []) as Array> - function sumQuantity(types: string[]): number { - return rawLines - .filter(li => types.includes(li.item_type as string)) - .reduce((sum: number, li) => sum + ((li.quantity as number) || 0), 0) - } - const sickDays = sumQuantity(['sick_karens', 'sick_day2_14']) - const vabDays = sumQuantity(['vab']) - const parentalDays = sumQuantity(['parental_leave']) - const vacationDays = sumQuantity(['vacation']) + // Aggregated absence counts derived from per-day records (above). + // Vacation still comes from line items because it's user-entered, not + // calendar-tracked yet. + const sickDays = absenceResult.aggregated.sickDays + const vabDays = absenceResult.aggregated.vabDays + const parentalDays = absenceResult.aggregated.parentalDays + const vacationDays = (sre.line_items || []) + .filter((li: Record) => li.item_type === 'vacation') + .reduce((sum: number, li: Record) => sum + ((li.quantity as number) || 0), 0) // Update salary_run_employee with calculated results. If any individual // update fails we abort so run totals aren't written from partial data. diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts index 5d7a0d45..20bc8f07 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -6,6 +6,36 @@ import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() +/** Fetch one employee's pay spec within a salary run, with employee + line items. */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string; employeeId: string }> }, +) { + const { id, employeeId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { data, error } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(*), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + .maybeSingle() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + if (!data) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } + + return NextResponse.json({ data }) +} + /** Remove employee from a draft salary run. Cascades to delete their line items. */ export async function DELETE( request: Request, diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts index 6267956c..e49ca6ca 100644 --- a/app/api/salary/runs/[id]/route.ts +++ b/app/api/salary/runs/[id]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' +import { formatRedovisare } from '@/lib/skatteverket/format' ensureInitialized() @@ -35,9 +36,27 @@ export async function GET( .eq('salary_run_id', id) .order('created_at') + // Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this + // in the run payload so the client doesn't need a second round-trip just to + // build extension URLs. Quietly null when the org number isn't set yet. + let arbetsgivare: string | null = null + const { data: settings } = await supabase + .from('company_settings') + .select('org_number, entity_type') + .eq('company_id', companyId) + .maybeSingle() + if (settings?.org_number && settings?.entity_type) { + try { + arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type) + } catch { + arbetsgivare = null + } + } + return NextResponse.json({ data: { ...run, + arbetsgivare, employees: (employees || []).map(emp => ({ ...emp, employee: emp.employee ? { diff --git a/components/salary/AGIPanel.tsx b/components/salary/AGIPanel.tsx new file mode 100644 index 00000000..0b06cfe9 --- /dev/null +++ b/components/salary/AGIPanel.tsx @@ -0,0 +1,491 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { + AlertCircle, + CheckCircle2, + Download, + ExternalLink, + FileCheck, + Link2, + Link2Off, + Loader2, + Lock, + Send, + ShieldAlert, + Unlock, +} from 'lucide-react' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' + +interface AGIPanelProps { + salaryRunId: string + /** Skatteverket arbetsgivare ID (12-digit) — formatted by parent. */ + arbetsgivare: string + /** YYYYMM */ + period: string + /** Already-cached run-level signals for showing what step we're at. */ + agiGeneratedAt?: string | null + agiSubmittedAt?: string | null + /** When true, write actions are hidden. */ + readOnly?: boolean + /** Called after a state-changing action so parent can refresh. */ + onChange?: () => void +} + +interface ConnectionStatus { + connected: boolean + expired?: boolean + canRefresh?: boolean + scope?: string + expiresAt?: string +} + +interface KontrollResult { + kod: string + status: 'ERROR' | 'WARNING' + beskrivning: string +} + +interface SubmissionState { + status?: 'draft_saved' | 'draft_locked' | 'signed' + signeringslank?: string + kvittensnummer?: string + tidpunkt?: string + inlamningId?: string +} + +const ENABLED_KEY = 'EXTENSION_DISABLED' + +export function AGIPanel(props: AGIPanelProps) { + const { + salaryRunId, + arbetsgivare, + period, + agiGeneratedAt, + agiSubmittedAt, + readOnly, + onChange, + } = props + + const [extensionDisabled, setExtensionDisabled] = useState(false) + const [status, setStatus] = useState(null) + const [submission, setSubmission] = useState(null) + const [kontroller, setKontroller] = useState([]) + const [loading, setLoading] = useState(true) + const [actionLoading, setActionLoading] = useState(null) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const fetchStatus = useCallback(async () => { + setLoading(true) + try { + const res = await fetch('/api/extensions/ext/skatteverket/status') + if (res.status === 503) { + const data = await res.json().catch(() => ({})) + if (data?.code === ENABLED_KEY) { + setExtensionDisabled(true) + return + } + } + if (res.ok) { + setStatus(await res.json()) + } + } catch { + // ignore — UI shows the not-connected state + } finally { + setLoading(false) + } + }, []) + + const fetchSubmission = useCallback(async () => { + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/agi/status?period=${period}`, + ) + if (res.ok) { + const json = await res.json() + setSubmission(json.data ?? null) + } + } catch { + // ignore + } + }, [period]) + + useEffect(() => { + fetchStatus() + fetchSubmission() + }, [fetchStatus, fetchSubmission]) + + const handleConnect = () => { + window.location.href = '/api/extensions/ext/skatteverket/authorize' + } + + const handleValidate = async () => { + setActionLoading('validate') + setError(null) + setSuccess(null) + setKontroller([]) + try { + const res = await fetch('/api/extensions/ext/skatteverket/agi/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ salaryRunId }), + }) + const json = await res.json() + if (!res.ok || json.error) { + setError(json.error || `Validering misslyckades (${res.status})`) + return + } + const controls: KontrollResult[] = json.data?.kontrollresultat?.resultat ?? [] + setKontroller(controls) + const errs = controls.filter(c => c.status === 'ERROR') + if (errs.length === 0) setSuccess('Valideringen godkänd') + else setError(`${errs.length} valideringsfel hittades`) + } catch (e) { + setError(e instanceof Error ? e.message : 'Kunde inte validera AGI') + } finally { + setActionLoading(null) + } + } + + const handleSaveDraft = async () => { + setActionLoading('draft') + setError(null) + setSuccess(null) + try { + const res = await fetch('/api/extensions/ext/skatteverket/agi/draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ salaryRunId }), + }) + const json = await res.json() + if (!res.ok || json.error) { + setError(json.error || `Kunde inte spara utkast (${res.status})`) + return + } + setSuccess('AGI-utkast sparat hos Skatteverket') + await fetchSubmission() + onChange?.() + } catch (e) { + setError(e instanceof Error ? e.message : 'Kunde inte spara utkast') + } finally { + setActionLoading(null) + } + } + + const handleLock = async () => { + setActionLoading('lock') + setError(null) + setSuccess(null) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, + { method: 'PUT' }, + ) + const json = await res.json() + if (!res.ok || json.error) { + setError(json.error || `Kunde inte låsa AGI (${res.status})`) + return + } + setSuccess('AGI låst — öppna signeringslänken för att signera med BankID.') + await fetchSubmission() + } catch (e) { + setError(e instanceof Error ? e.message : 'Kunde inte låsa AGI') + } finally { + setActionLoading(null) + } + } + + const handleUnlock = async () => { + setActionLoading('unlock') + setError(null) + setSuccess(null) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, + { method: 'DELETE' }, + ) + const json = await res.json() + if (!res.ok || json.error) { + setError(json.error || `Kunde inte låsa upp (${res.status})`) + return + } + setSuccess('AGI har låsts upp') + await fetchSubmission() + } catch (e) { + setError(e instanceof Error ? e.message : 'Kunde inte låsa upp') + } finally { + setActionLoading(null) + } + } + + const handleCheckSubmitted = async () => { + setActionLoading('check') + setError(null) + setSuccess(null) + try { + const res = await fetch( + `/api/extensions/ext/skatteverket/agi/submitted?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, + ) + const json = await res.json() + if (!res.ok || json.error) { + setError(json.error || 'Kunde inte hämta inlämningsstatus') + return + } + if (json.data?.kvittensnummer) { + setSuccess('AGI har lämnats in') + } else { + setSuccess('Ingen inlämning hittades än för perioden') + } + await fetchSubmission() + onChange?.() + } catch (e) { + setError(e instanceof Error ? e.message : 'Kunde inte kontrollera status') + } finally { + setActionLoading(null) + } + } + + // ── Render branches ───────────────────────────────────────────── + + if (extensionDisabled) { + return ( + + + Arbetsgivardeklaration (AGI) + + +
+ +

+ Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera + SKATTEVERKET_ENABLED + för att skicka AGI direkt till Skatteverket. +

+
+
+
+ ) + } + + if (loading) { + return ( + + + Arbetsgivardeklaration (AGI) + + + Hämtar Skatteverket-status... + + + ) + } + + if (!status?.connected) { + return ( + + + Arbetsgivardeklaration (AGI) + + +

+ Anslut till Skatteverket med BankID för att skicka AGI direkt från {`gnubok`}. +

+ {!readOnly && ( + + )} +
+
+ ) + } + + const subState = submission?.status + const isLocked = subState === 'draft_locked' + const isSigned = subState === 'signed' || !!agiSubmittedAt + + return ( + + + + Arbetsgivardeklaration (AGI) + + + Ansluten + + + + + {/* Status summary */} +
+ + +
+ + {submission?.signeringslank && isLocked && ( +
+

Utkastet är låst och redo att signeras

+

+ Öppna länken nedan och signera med BankID på Skatteverkets sida. +

+ + Öppna signeringslänk + +
+ )} + + {kontroller.length > 0 && ( +
+ {kontroller.map((k, i) => ( +
+ + + {k.kod} — {k.beskrivning} + +
+ ))} +
+ )} + + {error && ( +
+ + {error} +
+ )} + {success && !error && ( +
+ + {success} +
+ )} + + {!readOnly && !isSigned && ( +
+ + + {!isLocked ? ( + + ) : ( + + )} + +
+ )} +
+
+ ) +} + +function StatusRow({ + ok, + okText, + pendingText, +}: { + ok: boolean + okText: string + pendingText: string +}) { + return ( +
+ {ok ? ( + + ) : ( + + )} + {ok ? okText : pendingText} +
+ ) +} diff --git a/components/salary/AbsenceCalendar.tsx b/components/salary/AbsenceCalendar.tsx new file mode 100644 index 00000000..3baf82f0 --- /dev/null +++ b/components/salary/AbsenceCalendar.tsx @@ -0,0 +1,445 @@ +'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 = { + 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(() => startOfMonth(periodStartDate)) + const [days, setDays] = useState([]) + const [loading, setLoading] = useState(false) + const [editing, setEditing] = useState<{ date: string; existing?: AbsenceDay } | null>(null) + const [error, setError] = useState(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() + 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 ( +
+ {/* Header */} +
+
+ + + {format(visibleMonth, 'MMMM yyyy', { locale: sv })} + + +
+ {loading && } +
+ + {/* Weekday header */} +
+ {['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön'].map(d => ( +
{d}
+ ))} +
+ + {/* Calendar grid */} +
+ {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 ( + + ) + })} +
+ + {/* Legend */} +
+ {TYPE_ORDER.map(t => { + const meta = TYPE_META[t] + const Icon = meta.icon + return ( + + + + + {meta.label} + + ) + })} +
+ + {error && ( +
+ {error} +
+ )} + + {/* Edit dialog */} + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null) + loadAbsences() + onChange?.() + }} + /> + )} +
+ ) +} + +// ─── 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(existing?.absence_type ?? 'sick') + const [hours, setHours] = useState(existing?.hours?.toString() ?? '8') + const [notes, setNotes] = useState(existing?.notes ?? '') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(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 ( + !o && onClose()}> + + + + Frånvaro {format(parseISO(date), 'd MMMM yyyy', { locale: sv })} + + + Välj typ av frånvaro. Sjuklöneberäkning, karensavdrag och AGI-rapportering härleds automatiskt. + + + +
+
+ + +
+ +
+ + 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" + /> +
+ +
+ +