Fix/skv connection flow (#1015)

* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

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

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

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

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

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

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

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

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

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

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

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

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

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

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

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

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-13 22:54:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e7e3c35f9e
commit b6332e9ff4
154 changed files with 18364 additions and 1867 deletions
+12 -3
View File
@@ -11,8 +11,7 @@ import {
Check,
Brain,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
@@ -20,6 +19,16 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
// Markdown parser loads on the first assistant message instead of with the
// chat surface itself; react-markdown + remark-gfm pull in the whole
// unified/remark tree. The chunk starts fetching as soon as a reply begins
// rendering, well before a human can read it, so a null fallback is invisible
// in practice.
const MarkdownMessage = dynamic(() => import('./MarkdownMessage'), {
ssr: false,
loading: () => null,
})
// Reusable chat surface: used both inside the right-hand AgentSheet and on
// the full-page /chat route. Owns:
// * Message state (rendered list)
@@ -684,7 +693,7 @@ function MessageBubble({
message.text || (streamingTail ? <Cursor /> : '')
) : message.text ? (
<div className="prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown>
<MarkdownMessage text={message.text} />
</div>
) : streamingTail ? (
<Cursor />
+14
View File
@@ -0,0 +1,14 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
/**
* Isolated so AgentChat can load the markdown parser (react-markdown +
* remark-gfm and their unified/remark dependency tree) via next/dynamic:
* the chunk is fetched when the first assistant message renders instead of
* being parsed eagerly whenever the chat surface mounts.
*/
export default function MarkdownMessage({ text }: { text: string }) {
return <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
}
+5 -2
View File
@@ -75,9 +75,12 @@ function matches(entry: Entry, q: string): boolean {
return q.split(/\s+/).filter(Boolean).every(t => hay.includes(t))
}
export default function CommandPalette() {
export default function CommandPalette({ initialOpen = false }: { initialOpen?: boolean } = {}) {
const router = useRouter()
const [open, setOpen] = useState(false)
// initialOpen: LazyCommandPalette mounts this component on the first ⌘K,
// so the palette must come up already open rather than waiting for a
// second keypress.
const [open, setOpen] = useState(initialOpen)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
+31
View File
@@ -0,0 +1,31 @@
'use client'
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
const CommandPalette = dynamic(() => import('./CommandPalette'), { ssr: false })
/**
* Defers the command palette bundle (Radix dialog + its icon set) until the
* first ⌘K / Ctrl+K press. The palette is mounted on every dashboard page but
* used only by keyboard, so eagerly parsing it on initial load was pure cost.
* Once mounted, the palette's own global key listener takes over toggling;
* this wrapper's listener only fires the first time.
*/
export default function LazyCommandPalette() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
if (mounted) return
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setMounted(true)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted])
return mounted ? <CommandPalette initialOpen /> : null
}
+46 -57
View File
@@ -61,6 +61,7 @@ import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType } from '@/types'
@@ -83,8 +84,6 @@ interface DashboardNavProps {
// switched on. Drives visibility of the Kostnadsställen & projekt row:
// same mechanism as paysSalaries: fetched by the dashboard layout.
dimensionsEnabled?: boolean
uncategorizedTransactionCount?: number
pendingOperationsCount?: number
isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[]
// Signed-in user's full name + email: drives the bottom-left account
@@ -247,7 +246,7 @@ function accountInitial(name: string | null, email: string | null): string {
return '?'
}
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
@@ -261,17 +260,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [liveUncategorizedTransactionCount, setLiveUncategorizedTransactionCount] = useState(
uncategorizedTransactionCount,
)
const refreshInFlightRef = useRef(false)
const refreshQueuedRef = useRef(false)
// Badge counts load client-side after mount (and revalidate via the
// realtime subscriptions below). They used to arrive as server props, which
// put two head-count queries on the critical path of every dashboard
// navigation for numbers nobody needs before first paint.
const {
uncategorized: uncategorizedCount,
pendingOperations: pendingOpsCount,
refresh: refreshBadges,
} = useWorklistBadges(company?.id)
// Trial countdown for the sidebar touchpoint. Computed in an effect (not
// during render) so server and client markup agree at hydration; an hourly
// tick keeps a long-lived tab from showing yesterday's count.
// tick keeps a long-lived tab from showing yesterday's count. The sync
// setState is that hydration strategy, not derived-state-in-effect (the
// lint only started analyzing this component once the badge-refresh loop
// that made the compiler bail was removed).
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
useEffect(() => {
if (!trialEndsAt) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTrialDaysLeft(null)
return
}
@@ -364,43 +371,17 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
useEffect(() => {
if (!company?.id) return
let cancelled = false
const refreshUncategorizedCount = async () => {
if (!company?.id || cancelled) return
if (refreshInFlightRef.current) {
refreshQueuedRef.current = true
return
}
refreshInFlightRef.current = true
try {
do {
refreshQueuedRef.current = false
const { count, error } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', company.id)
.is('is_business', null)
.eq('is_ignored', false)
if (error) {
console.error('Failed to refresh uncategorized transaction count:', error)
break
}
setLiveUncategorizedTransactionCount(count ?? 0)
} while (refreshQueuedRef.current && !cancelled)
} finally {
refreshInFlightRef.current = false
refreshQueuedRef.current = false
}
// Realtime keeps the badges live; a trailing debounce collapses event
// bursts (bulk booking / bulk approvals emit one event per row) into a
// single SWR revalidation instead of a request stampede.
let debounce: ReturnType<typeof setTimeout> | null = null
const queueRefresh = () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => void refreshBadges(), 400)
}
void refreshUncategorizedCount()
const channel = supabase
.channel(`dashboard-nav:transactions:${company.id}`)
.channel(`dashboard-nav:badges:${company.id}`)
.on(
'postgres_changes',
{
@@ -409,17 +390,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
table: 'transactions',
filter: `company_id=eq.${company.id}`,
},
() => {
void refreshUncategorizedCount()
queueRefresh,
)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'pending_operations',
filter: `company_id=eq.${company.id}`,
},
queueRefresh,
)
.subscribe()
return () => {
cancelled = true
if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel)
}
}, [company?.id, supabase])
}, [company?.id, supabase, refreshBadges])
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
@@ -607,10 +596,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge =
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: item.href === '/pending' && pendingOpsCount > 0
? pendingOpsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
@@ -820,8 +809,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{mobileNavItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
const badge = item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: null
const content = (
@@ -977,10 +966,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
const badge = item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: item.href === '/pending' && pendingOpsCount > 0
? pendingOpsCount
: null
const decorBadge = renderBadge(item, 'mobile')
const content = (
+4 -1
View File
@@ -118,7 +118,7 @@ export default function PaymentBookingDialog({
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.select('accounting_method, entity_type, ore_rounding')
.eq('company_id', company.id)
.maybeSingle()
@@ -153,9 +153,12 @@ export default function PaymentBookingDialog({
vat_treatment: invoice.vat_treatment,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
ore_rounding: invoice.ore_rounding,
},
accountingMethod,
entityType,
companyOreRounding:
typeof settings?.ore_rounding === 'boolean' ? settings.ore_rounding : undefined,
})
setLines(proposed)
+29
View File
@@ -0,0 +1,29 @@
'use client'
import { SWRConfig } from 'swr'
import type { ReactNode } from 'react'
/**
* Global SWR defaults: one shared client-side cache so repeated mounts of the
* same data (company settings, nav badges, reference data) dedupe in-flight
* requests and render instantly from cache on back-navigation, revalidating
* in the background instead of re-showing skeletons on every visit.
*
* String keys are fetched as JSON from our own API routes; hooks that read
* Supabase directly pass an array key with their own fetcher.
*/
export function SWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
value={{
fetcher: async (key: string) => {
const res = await fetch(key)
if (!res.ok) throw new Error(`HTTP ${res.status} for ${key}`)
return res.json()
},
}}
>
{children}
</SWRConfig>
)
}
+441 -201
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Circle,
Download,
ExternalLink,
Link2,
@@ -19,9 +20,11 @@ import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
interface AGIPanelProps {
salaryRunId: string
@@ -32,6 +35,13 @@ interface AGIPanelProps {
/** Already-cached run-level signals for showing what step we're at. */
agiGeneratedAt?: string | null
agiSubmittedAt?: string | null
/**
* Per-period submission record, owned by the parent (via useAgiSubmission)
* so the progress rail and hero can render the same state machine.
*/
submission: AgiSubmissionState | null
/** Refetch the submission record after a state-changing action. */
onRefreshSubmission: () => void
/** When true, write actions are hidden. */
readOnly?: boolean
/** Called after a state-changing action so parent can refresh. */
@@ -60,28 +70,6 @@ interface KontrollFinding {
identifierare?: string
}
/**
* Local submission state mirrored in extension_data under
* `agi_submission_{period}`. Matches the `status` enum the index.ts handlers
* write back. Strict superset of what the UI actually keys off.
*/
interface SubmissionState {
status?:
| 'underlag_submitted' // POST /underlag returned an inlamningId
| 'underlag_rejected' // kontrollresultat surfaced stoppande fel
| 'awaiting_signing' // skapaGranskningsunderlag returned a link
| 'signed' // kvittenser shows uuidKvittens for the period
signeringslank?: string
kvittensnummer?: string
signeradAv?: string
signeradTid?: string
inlamningId?: number
tillstand?: string
meddelande?: string
/** ISO timestamp the submission record was last written by the extension. */
updatedAt?: string
}
/** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
interface Kontrollresultat {
status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED'
@@ -106,6 +94,36 @@ interface Kontrollresultat {
const ENABLED_KEY = 'EXTENSION_DISABLED'
/** One-click chain steps, in execution order. */
const CHAIN_STEPS = ['generate', 'submit', 'kontroll', 'link'] as const
type ChainStep = (typeof CHAIN_STEPS)[number]
interface ChainProgress {
current: ChainStep
failed: boolean
done: boolean
}
/**
* Sentinel for chain aborts where the failing step already surfaced its
* error via setError/setKontroller: the catch block must not overwrite it.
*/
class ChainFailed extends Error {}
/**
* Extract a human message from either the canonical { error: { message } }
* envelope (internal routes) or a plain { error: string } (extension routes).
*/
function errText(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
const err = (data as { error?: unknown }).error
if (typeof err === 'string') return err
if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') {
return (err as { message: string }).message
}
return null
}
export function AGIPanel(props: AGIPanelProps) {
const {
salaryRunId,
@@ -113,21 +131,47 @@ export function AGIPanel(props: AGIPanelProps) {
period,
agiGeneratedAt,
agiSubmittedAt,
submission,
onRefreshSubmission,
readOnly,
onChange,
} = props
const t = useTranslations('salary_agi')
const { toast } = useToast()
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
const [extensionDisabled, setExtensionDisabled] = useState(false)
const [status, setStatus] = useState<ConnectionStatus | null>(null)
const [submission, setSubmission] = useState<SubmissionState | null>(null)
const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [chain, setChain] = useState<ChainProgress | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false)
// "2026-06" for user-facing copy; the period prop is compact YYYYMM.
const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}`
// ── Derived filing state (needed by hooks, so derived before any return) ──
const subState = submission?.status
const awaitingSigning = subState === 'awaiting_signing'
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
// If the run's AGI was (re)generated AFTER this signing draft was created,
// the locked underlag at Skatteverket reflects superseded figures and must
// not be signed: surface a warning and steer the user to unlock + resubmit
// rather than presenting it as ready to sign (avoids filing stale amounts).
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
const draftIsStale =
awaitingSigning &&
!!agiGeneratedAt &&
!!draftUpdatedAt &&
!Number.isNaN(draftUpdatedAt.getTime()) &&
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
const fetchStatus = useCallback(async () => {
setLoading(true)
@@ -165,24 +209,9 @@ export function AGIPanel(props: AGIPanelProps) {
}
}, [])
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])
}, [fetchStatus])
// Handle of the OAuth popup opened by handleConnect: used to verify the
// sender identity of incoming postMessages.
@@ -231,6 +260,20 @@ export function AGIPanel(props: AGIPanelProps) {
)
}, [agiGeneratedAt])
// Loud success when the filing completes: a poll (live timers, tab refocus,
// or the parent's refresh) flips isSigned while the user is on the page.
// The ref starts null so an already-signed run doesn't toast on mount.
const prevSignedRef = useRef<boolean | null>(null)
useEffect(() => {
if (prevSignedRef.current === false && isSigned) {
toast({
title: t('toast_signed_title'),
description: t('toast_signed_description', { period: prettyPeriod }),
})
}
prevSignedRef.current = isSigned
}, [isSigned, toast, t, prettyPeriod])
// Background kvittens-polling timers (see scheduleKvittensPolls below).
// Held in a ref so the unmount-cleanup effect can cancel them if the
// user leaves the page mid-signing.
@@ -263,7 +306,7 @@ export function AGIPanel(props: AGIPanelProps) {
if (!res.ok) return false
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission()
onRefreshSubmission()
if (signed) {
// Replace any lingering "Granskningsunderlag klart…" / stale error
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
@@ -275,7 +318,7 @@ export function AGIPanel(props: AGIPanelProps) {
} catch {
return false
}
}, [arbetsgivare, period, fetchSubmission, onChange, t])
}, [arbetsgivare, period, onRefreshSubmission, onChange, t])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
@@ -348,13 +391,13 @@ export function AGIPanel(props: AGIPanelProps) {
}
setSuccess(t('disconnect_success'))
await fetchStatus()
await fetchSubmission()
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('disconnect_failed'))
} finally {
setActionLoading(null)
}
}, [fetchStatus, fetchSubmission, t])
}, [fetchStatus, onRefreshSubmission, t])
const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page
@@ -418,9 +461,27 @@ export function AGIPanel(props: AGIPanelProps) {
}
/**
* Step 1: POST the stored XML underlag, then poll kontrollresultat until
* status flips out of PROCESSING. Skatteverket's spec says polling is
* usually instantaneous, but we cap at 8 attempts × 1s to be safe.
* The XML must exist in agi_declarations before anything can be submitted.
* The internal xml route both generates and persists it (and stamps
* agi_generated_at); the response body, the downloadable file itself, is
* discarded here: "Ladda ner AGI-fil" remains the way to get a copy.
*/
async function ensureAgiGenerated(): Promise<boolean> {
if (agiGeneratedAt) return true
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(errText(data) || t('xml_generate_failed'))
return false
}
onChange?.() // parent refetches the run so agiGeneratedAt flips
return true
}
/**
* POST the stored XML underlag, then poll kontrollresultat until status
* flips out of PROCESSING. Skatteverket's spec says polling is usually
* instantaneous, but we cap at 8 attempts × 1s to be safe.
*
* On DONE_SUCCESS the underlag is auto-persisted by SKV: no /spara call.
* Calling /spara when there are no errors returns 400 felkod 20
@@ -431,10 +492,154 @@ export function AGIPanel(props: AGIPanelProps) {
*
* On DONE_REJECTED we surface the validation findings; the user can still
* choose to save (so they can fix it in Mina Sidor) or abort.
*
* Failures surface via setError/setKontroller and return false. Shared by
* the one-click chain and the advanced "Skicka in underlag" button;
* `onKontrollPhase` lets the chain advance its stepper when polling starts.
*/
async function runSubmitUnderlag(onKontrollPhase?: () => void): Promise<boolean> {
setKontroller([])
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const submitJson = await submitRes.json()
if (!submitRes.ok || submitJson.error) {
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
return false
}
const inlamningId = submitJson.data?.inlamningId as number | undefined
if (!inlamningId) {
setError(t('submit_missing_id'))
return false
}
// Poll kontrollresultat until DONE_*
onKontrollPhase?.()
let kr: Kontrollresultat | undefined
for (let attempt = 0; attempt < 8; attempt++) {
const krRes = await fetch(
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
)
const krJson = await krRes.json()
if (!krRes.ok || krJson.error) {
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
return false
}
kr = krJson.data as Kontrollresultat
if (kr.status !== 'PROCESSING') break
await new Promise(r => setTimeout(r, 1000))
}
if (!kr || kr.status === 'PROCESSING') {
setError(t('still_processing'))
return false
}
const findings = extractFindings(kr)
setKontroller(findings)
if (kr.status === 'DONE_SUCCESS') return true
if (kr.status === 'DONE_REJECTED') {
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
} else {
setError(t('underlag_failed'))
}
return false
}
/**
* skapaGranskningsunderlag: returns the Mina Sidor deep-link the user opens
* to sign with BankID. Defaults to `lasPeriod=true` so the period is locked
* while the signing window is open. Returns the link on success ('' when
* the response carried none: the signing-link card renders it after the
* submission refresh) and null on failure (error already surfaced).
*/
async function runCreateSigningLink(): Promise<string | null> {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('signing_link_failed_status', { status: res.status }))
return null
}
if (json.data?.tillstand === 'INCORRECT_DATA') {
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
return null
}
// The user typically opens the link, signs in Mina Sidor, then returns
// later (or never). Auto-poll so we capture the kvittens (and stamp
// agi_submitted_at) without forcing the user to click "Hämta kvittens".
scheduleKvittensPolls()
return typeof json.data?.link === 'string' ? json.data.link : ''
}
/**
* One-click filing: generate (if missing) → POST underlag → poll kontroll →
* create signing link → hand over to BankID signing in Mina Sidor.
*
* The signing link opens in a tab we open synchronously at click time:
* window.open after the async chain would be popup-blocked. On failure the
* placeholder tab is closed and the error renders in the panel; if the
* popup was blocked outright, the signing-link card (rendered from the
* refreshed submission state) is the fallback path.
*/
const handleSubmitChain = async () => {
setActionLoading('chain')
setError(null)
setSuccess(null)
let signingTab: Window | null = null
try {
signingTab = window.open('', '_blank')
if (signingTab) {
signingTab.document.title = t('chain_tab_title')
signingTab.document.body.textContent = t('chain_tab_body')
}
} catch {
signingTab = null
}
try {
setChain({ current: 'generate', failed: false, done: false })
if (!(await ensureAgiGenerated())) throw new ChainFailed()
setChain({ current: 'submit', failed: false, done: false })
const submitted = await runSubmitUnderlag(() =>
setChain({ current: 'kontroll', failed: false, done: false }),
)
if (!submitted) throw new ChainFailed()
setChain({ current: 'link', failed: false, done: false })
const link = await runCreateSigningLink()
if (link === null) throw new ChainFailed()
setChain({ current: 'link', failed: false, done: true })
setSuccess(t('chain_ready_to_sign'))
if (signingTab && link) {
signingTab.location.replace(link)
signingTab = null // handed over to Skatteverket: don't close it below
} else {
signingTab?.close()
signingTab = null
}
onRefreshSubmission()
onChange?.()
} catch (e) {
signingTab?.close()
setChain(prev => (prev ? { ...prev, failed: true } : prev))
if (!(e instanceof ChainFailed)) {
setError(e instanceof Error ? e.message : t('submit_failed'))
}
onRefreshSubmission()
} finally {
setActionLoading(null)
}
}
// Always-free: generate + download the AGI XML so the user can file manually
// in Skatteverket's e-service. AGI is a mandatory statutory filing, so this
// path must never be paywalled: only the direct API submission below is paid.
// path must never be paywalled: only the direct API submission is paid.
const handleDownloadXml = async () => {
setActionLoading('download')
setError(null)
@@ -442,7 +647,7 @@ export function AGIPanel(props: AGIPanelProps) {
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || t('xml_generate_failed'))
throw new Error(errText(data) || t('xml_generate_failed'))
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
@@ -461,60 +666,16 @@ export function AGIPanel(props: AGIPanelProps) {
}
}
/** Advanced/recovery variant: submit the underlag without continuing the chain. */
const handleSubmit = async () => {
setActionLoading('submit')
setError(null)
setSuccess(null)
setKontroller([])
try {
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const submitJson = await submitRes.json()
if (!submitRes.ok || submitJson.error) {
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
return
}
const inlamningId = submitJson.data?.inlamningId as number | undefined
if (!inlamningId) {
setError(t('submit_missing_id'))
return
}
// Poll kontrollresultat until DONE_*
let kr: Kontrollresultat | undefined
for (let attempt = 0; attempt < 8; attempt++) {
const krRes = await fetch(
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
)
const krJson = await krRes.json()
if (!krRes.ok || krJson.error) {
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
return
}
kr = krJson.data as Kontrollresultat
if (kr.status !== 'PROCESSING') break
await new Promise(r => setTimeout(r, 1000))
}
if (!kr || kr.status === 'PROCESSING') {
setError(t('still_processing'))
return
}
const findings = extractFindings(kr)
setKontroller(findings)
if (kr.status === 'DONE_SUCCESS') {
setSuccess(t('underlag_accepted'))
} else if (kr.status === 'DONE_REJECTED') {
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
} else {
setError(t('underlag_failed'))
}
await fetchSubmission()
if (!(await ensureAgiGenerated())) return
const ok = await runSubmitUnderlag()
if (ok) setSuccess(t('underlag_accepted'))
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('submit_failed'))
@@ -523,36 +684,15 @@ export function AGIPanel(props: AGIPanelProps) {
}
}
/**
* Step 2: skapaGranskningsunderlag: returns the Mina Sidor deep-link the
* user opens to sign with BankID. Defaults to `lasPeriod=true` so the
* period is locked while the signing window is open.
*/
/** Advanced/recovery variant: create the signing link on its own. */
const handleCreateSigningLink = async () => {
setActionLoading('granskning')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('signing_link_failed_status', { status: res.status }))
return
}
if (json.data?.tillstand === 'INCORRECT_DATA') {
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
} else {
setSuccess(t('signing_link_ready'))
// The user typically opens the link, signs in Mina Sidor, then
// returns later (or never). Auto-poll so we capture the kvittens
// (and stamp agi_submitted_at) without forcing the user to come
// back and click "Hämta kvittens".
scheduleKvittensPolls()
}
await fetchSubmission()
const link = await runCreateSigningLink()
if (link !== null) setSuccess(t('signing_link_ready'))
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('signing_link_failed'))
} finally {
@@ -575,7 +715,7 @@ export function AGIPanel(props: AGIPanelProps) {
return
}
setSuccess(t('unlock_success'))
await fetchSubmission()
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('unlock_failed'))
} finally {
@@ -584,7 +724,7 @@ export function AGIPanel(props: AGIPanelProps) {
}
/**
* Step 3 (post-signing): poll /agi/kvittenser to detect that the user has
* Post-signing recovery: poll /agi/kvittenser to detect that the user has
* signed in Mina Sidor. Once a kvittens turns up, the index.ts handler
* mirrors it onto agi_declarations and flips the local submission state
* to 'signed'.
@@ -608,7 +748,7 @@ export function AGIPanel(props: AGIPanelProps) {
} else {
setSuccess(t('no_kvittens_yet'))
}
await fetchSubmission()
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('check_status_failed'))
@@ -673,23 +813,6 @@ export function AGIPanel(props: AGIPanelProps) {
)
}
const subState = submission?.status
const awaitingSigning = subState === 'awaiting_signing'
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
// If the run's AGI was (re)generated AFTER this signing draft was created,
// the locked underlag at Skatteverket reflects superseded figures and must
// not be signed: surface a warning and steer the user to unlock + resubmit
// rather than presenting it as ready to sign (avoids filing stale amounts).
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
const draftIsStale =
awaitingSigning &&
!!agiGeneratedAt &&
!!draftUpdatedAt &&
!Number.isNaN(draftUpdatedAt.getTime()) &&
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
// Tokens issued before the agd scope was added to DEFAULT_SCOPES will
// 403 with invalid_scope at submission time: surface that proactively
// so the user reconnects before hitting the deadline rather than at it.
@@ -697,6 +820,23 @@ export function AGIPanel(props: AGIPanelProps) {
typeof status?.scope === 'string' &&
!status.scope.split(/\s+/).filter(Boolean).includes('agd')
// Recovery states expose the advanced actions on their own: the stale-draft
// and error-report guidance below reference them by name.
const forcedAdvanced = draftIsStale || underlagRejected
const advancedOpen = showAdvanced || forcedAdvanced
const signedAtRaw = submission?.signeradTid ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => {
if (!chain) return 'upcoming'
const idx = CHAIN_STEPS.indexOf(step)
const currentIdx = CHAIN_STEPS.indexOf(chain.current)
if (idx < currentIdx || (idx === currentIdx && chain.done)) return 'done'
if (idx === currentIdx) return chain.failed ? 'failed' : 'running'
return 'upcoming'
}
return (
<Card>
<CardHeader>
@@ -727,6 +867,40 @@ export function AGIPanel(props: AGIPanelProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Filed: the terminal state deserves more than a gray status row.
Kvittensnummer + signature metadata come from the submission
record; a run stamped only via agi_submitted_at (e.g. cron
reconciliation with an evicted cache) still gets the card. */}
{isSigned && (
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t('success_card_title', { period: prettyPeriod })}
</p>
{submission?.kvittensnummer && (
<p className="text-sm text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: submission.kvittensnummer })}
</p>
)}
{(submission?.signeradAv || signedAtText) && (
<p className="text-sm text-muted-foreground">
{submission?.signeradAv
? signedAtText
? t('success_card_signed_by_at', {
name: submission.signeradAv,
date: signedAtText,
})
: t('success_card_signed_by', { name: submission.signeradAv })
: t('success_card_signed_at', { date: signedAtText ?? '' })}
</p>
)}
</div>
</div>
</div>
)}
{/* Expired-session banner: the token row exists (so status.connected
is true) but the access token is past expiry and either has no
refresh token or has burned through its 10-refresh budget. The
@@ -919,74 +1093,140 @@ export function AGIPanel(props: AGIPanelProps) {
)}
{!readOnly && !isSigned && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
<div className="space-y-3">
{/* Primary path: one click runs the whole filing chain. Hidden
while a signing draft is open at SKV (the period is locked,
so a resubmission would be refused): the signing-link card
above is the CTA then, and the stale-draft recovery goes
through the advanced actions per the guidance text. The
XML download stays free for manual filing regardless. */}
<div className="flex flex-wrap items-center gap-2">
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
{t('download_xml_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading === 'submit' || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
onClick={handleCreateSigningLink}
disabled={actionLoading === 'granskning' || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading === 'check'}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading === 'unlock'}
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'unlock' ? (
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
{t('download_xml_button')}
</Button>
</div>
{chain && (
<ol className="space-y-1.5 rounded-md border border-border bg-muted/30 p-3">
{CHAIN_STEPS.map(step => {
const state = chainStepState(step)
return (
<li key={step} className="flex items-center gap-2 text-xs">
{state === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : state === 'running' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : state === 'failed' ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
)}
<span className={state === 'upcoming' ? 'text-muted-foreground' : ''}>
{t(`chain_step_${step}`)}
</span>
</li>
)
})}
</ol>
)}
{/* Recovery/expert actions: each is one step of the chain above,
for resuming after a partial failure. Auto-expanded when a
recovery state (stale draft, rejected underlag) references
them by name. */}
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
{advancedOpen && (
<div className="mt-2 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
</Button>
)}
</div>
)}
</div>
</div>
)}
+8
View File
@@ -168,6 +168,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
employment_start: form.get('employment_start') as string,
employment_end: form.get('employment_end') as string || undefined,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
hours_per_week: parseFloat(form.get('hours_per_week') as string) || 40,
workdays_per_week: parseFloat(form.get('workdays_per_week') as string) || 5,
salary_type: salaryType,
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
@@ -293,6 +295,12 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
</Field>
<Field label="Timmar per vecka" htmlFor="hours_per_week">
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue="40" min="1" max="80" step="0.5" />
</Field>
<Field label="Arbetsdagar per vecka" htmlFor="workdays_per_week">
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue="5" min="1" max="7" step="1" />
</Field>
<Field label="Löneform" htmlFor="salary_type" required>
<Select value={salaryType} onValueChange={setSalaryType}>
<SelectTrigger id="salary_type">
+271
View File
@@ -0,0 +1,271 @@
'use client'
/**
* Ingående saldon (payroll cutover) panel on the employee editor.
*
* Mid-year switchers from another payroll system enter per-employee state
* here: YTD accumulators, vacation balances (incl. sparade dagar per
* origin year), opening semesterlöneskuld SEK, and the karens adjustment.
* Locked (read-only) once the employee has a booked salary run; the lock
* self-releases if that run is corrected.
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Save } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
interface OpeningBalancesData {
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number>
opening_semester_liability: number
opening_semester_liability_avgifter: number
karens_periods_adjustment: number
locked: boolean
locked_by_run_id: string | null
}
const currentYear = new Date().getFullYear()
/** Sparade dagar origin years: Semesterlagen allows saving max 5 years. */
const SAVED_YEARS = Array.from({ length: 5 }, (_, i) => String(currentYear - 1 - i))
export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [locked, setLocked] = useState(false)
const [hasRow, setHasRow] = useState(false)
const [cutoverDate, setCutoverDate] = useState(`${currentYear}-01-01`)
const [ytdGross, setYtdGross] = useState('')
const [ytdTax, setYtdTax] = useState('')
const [ytdNet, setYtdNet] = useState('')
const [daysRemaining, setDaysRemaining] = useState('')
const [savedByYear, setSavedByYear] = useState<Record<string, string>>({})
const [liability, setLiability] = useState('')
const [liabilityAvgifter, setLiabilityAvgifter] = useState('')
const [karens, setKarens] = useState('')
useEffect(() => {
async function load() {
setLoading(true)
const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`)
if (res.ok) {
const { data } = (await res.json()) as { data: OpeningBalancesData | null }
if (data) {
setHasRow(true)
setLocked(data.locked)
setCutoverDate(data.cutover_date)
setYtdGross(String(data.ytd_gross))
setYtdTax(String(data.ytd_tax))
setYtdNet(String(data.ytd_net))
setDaysRemaining(String(data.vacation_paid_days_remaining))
setSavedByYear(
Object.fromEntries(
Object.entries(data.vacation_saved_days_by_year ?? {}).map(([y, d]) => [y, String(d)]),
),
)
setLiability(String(data.opening_semester_liability))
setLiabilityAvgifter(String(data.opening_semester_liability_avgifter))
setKarens(String(data.karens_periods_adjustment))
}
}
setLoading(false)
}
load()
}, [employeeId])
async function handleSave() {
setSaving(true)
const saved: Record<string, number> = {}
for (const [year, value] of Object.entries(savedByYear)) {
const days = parseFloat(value)
if (Number.isFinite(days) && days > 0) saved[year] = days
}
const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cutover_date: cutoverDate,
ytd_gross: parseFloat(ytdGross) || 0,
ytd_tax: parseFloat(ytdTax) || 0,
ytd_net: parseFloat(ytdNet) || 0,
vacation_paid_days_remaining: parseFloat(daysRemaining) || 0,
vacation_saved_days_by_year: saved,
opening_semester_liability: parseFloat(liability) || 0,
opening_semester_liability_avgifter: parseFloat(liabilityAvgifter) || 0,
karens_periods_adjustment: parseInt(karens, 10) || 0,
}),
})
if (res.ok) {
setHasRow(true)
toast({ title: t('opening_balances_saved') })
} else {
const result = await res.json()
toast({
title: t('opening_balances_save_failed'),
description: getErrorMessage(result, { statusCode: res.status }),
variant: 'destructive',
})
}
setSaving(false)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
)
}
const readOnly = locked || !canWrite
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('opening_balances_description')}</p>
</CardHeader>
<CardContent className="space-y-6">
{locked && (
<p className="text-sm text-muted-foreground border border-border rounded-lg p-3">
{t('opening_balances_locked_notice')}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-cutover">{t('opening_balances_cutover_date')}</Label>
<Input
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_cutover_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-karens">{t('opening_balances_karens')}</Label>
<Input
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_karens_hint')}</p>
</div>
</div>
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_ytd_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-ytd-gross">{t('opening_balances_ytd_gross')}</Label>
<Input id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-tax">{t('opening_balances_ytd_tax')}</Label>
<Input id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-net">{t('opening_balances_ytd_net')}</Label>
<Input id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_vacation_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-days-remaining">{t('opening_balances_days_remaining')}</Label>
<Input id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability">{t('opening_balances_liability')}</Label>
<Input id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability-avgifter">{t('opening_balances_liability_avgifter')}</Label>
<Input id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_saved_heading')}
</h2>
<p className="text-xs text-muted-foreground mb-3">{t('opening_balances_saved_hint')}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{SAVED_YEARS.map((year) => (
<div key={year} className="space-y-2">
<Label htmlFor={`ob-saved-${year}`} className="tabular-nums">{year}</Label>
<Input
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className="tabular-nums"
/>
</div>
))}
</div>
</div>
{!readOnly && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving
? t('opening_balances_saving')
: hasRow
? t('opening_balances_update')
: t('opening_balances_save')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
+238
View File
@@ -0,0 +1,238 @@
'use client'
/**
* Semester card on the salary dashboard (payroll gap-closure 3.5).
*
* Shows the open vacation-ledger totals (remaining + saved days across
* active employees) and hosts the year-close dialog: dry-run report first,
* a working confirm second (soft-guard convention).
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Palmtree, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
interface BalanceRow {
employee_vacation_balance_id: string
employee_name: string
remaining_days: number
saved_days_total: number
forced_payout_days: number
}
interface CloseReportRow {
employee_name: string
remaining_days: number
saveable_days: number
untaken_below_floor_days: number
expiring_days: number
next_year_entitled: number
}
interface CloseReport {
vacation_year_start: string
vacation_year_end: string
rows: CloseReportRow[]
sek: {
computed_liability: number
booked_2920: number
drift_2920: number
adjustment_needed: boolean
}
}
export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) {
const t = useTranslations('salary')
const { toast } = useToast()
const [rows, setRows] = useState<BalanceRow[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [report, setReport] = useState<CloseReport | null>(null)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
const [closing, setClosing] = useState(false)
async function fetchBalances(): Promise<BalanceRow[] | null> {
const res = await fetch('/api/salary/vacation-balances')
if (!res.ok) return null
const { data } = await res.json()
return (data ?? []) as BalanceRow[]
}
useEffect(() => {
let cancelled = false
fetchBalances().then((data) => {
if (cancelled) return
if (data) setRows(data)
setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const totalRemaining = rows.reduce((s, r) => s + r.remaining_days, 0)
const totalSaved = rows.reduce((s, r) => s + r.saved_days_total, 0)
async function openCloseDialog() {
setDialogOpen(true)
setPreviewing(true)
setReport(null)
setPreviewError(null)
const res = await fetch('/api/salary/vacation-year-close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dry_run: true }),
})
const body = await res.json()
if (res.ok) {
setReport(body.data.report as CloseReport)
} else {
setPreviewError(body.error ?? t('vacation_close_preview_failed'))
}
setPreviewing(false)
}
async function confirmClose() {
setClosing(true)
const res = await fetch('/api/salary/vacation-year-close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ book_adjustment: true }),
})
const body = await res.json()
if (res.ok) {
toast({ title: t('vacation_close_done') })
setDialogOpen(false)
const refreshed = await fetchBalances()
if (refreshed) setRows(refreshed)
} else {
toast({
title: t('vacation_close_failed'),
description: body.error,
variant: 'destructive',
})
}
setClosing(false)
}
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Palmtree className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_vacation_title')}</p>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">…</p>
) : rows.length > 0 ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{totalRemaining}
</p>
<p className="text-xs text-muted-foreground">
{t('card_vacation_detail', { employees: rows.length, saved: totalSaved })}
</p>
{canWrite && (
<Button variant="outline" size="sm" className="mt-2" onClick={openCloseDialog}>
{t('vacation_close_button')}
</Button>
)}
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_vacation_none')}</p>
)}
</CardContent>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('vacation_close_title')}</DialogTitle>
<DialogDescription>{t('vacation_close_description')}</DialogDescription>
</DialogHeader>
{previewing && (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('vacation_close_previewing')}
</div>
)}
{previewError && <p className="text-sm text-destructive py-4">{previewError}</p>}
{report && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('vacation_close_period', {
from: report.vacation_year_start,
to: report.vacation_year_end,
})}
</p>
<div className="max-h-64 overflow-y-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<th className="text-left px-3 py-2">{t('vacation_close_col_employee')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_saved')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_flagged')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_expiring')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_next')}</th>
</tr>
</thead>
<tbody>
{report.rows.map((row, idx) => (
<tr key={idx} className="border-t border-border">
<td className="px-3 py-2">{row.employee_name}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.saveable_days}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.untaken_below_floor_days}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.expiring_days}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.next_year_entitled}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-sm space-y-1">
<p className="tabular-nums">
{t('vacation_close_computed', {
amount: formatCurrency(report.sek.computed_liability),
})}
</p>
<p className="tabular-nums">
{t('vacation_close_booked', { amount: formatCurrency(report.sek.booked_2920) })}
</p>
<p className="tabular-nums">
{report.sek.adjustment_needed
? t('vacation_close_drift', { amount: formatCurrency(report.sek.drift_2920) })
: t('vacation_close_no_drift')}
</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={closing}>
{t('vacation_close_cancel')}
</Button>
<Button onClick={confirmClose} disabled={!report || closing}>
{closing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('vacation_close_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
+23 -9
View File
@@ -6,6 +6,7 @@ import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
import type { RunDetail } from './types'
type StepState = 'done' | 'active' | 'upcoming'
@@ -16,6 +17,12 @@ interface RunProgressBarProps {
// True when the run pays out nothing (nollkörning / fully net-deducted): the
// pay step carries no "download the file" hint because there is no file.
noPayout?: boolean
// Real filing state for the AGI step (deriveAgiFilingState): without it the
// rail only knows generated/submitted and mislabels "waiting for BankID
// signature" as "lämna in till Skatteverket".
agiState: AgiFilingState
// Shown on the AGI step once signed: the kvittens is the filing receipt.
agiKvittensnummer?: string | null
canWrite: boolean
actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till
@@ -43,7 +50,7 @@ const STATUS_RANK: Record<string, number> = {
export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run')
const locale = useLocale()
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction } = props
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction, agiState, agiKvittensnummer } = props
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
@@ -116,14 +123,21 @@ export function RunProgressBar(props: RunProgressBarProps) {
{
key: 'agi',
label: t('rail_agi'),
state: run.agi_submitted_at ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
detail: run.agi_submitted_at
? t('rail_agi_submitted')
: run.agi_generated_at
? t('rail_agi_generated')
: run.status === 'booked'
? t('rail_agi_hint')
: undefined,
state: agiState === 'signed' ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
detail:
agiState === 'signed'
? agiKvittensnummer
? t('rail_agi_submitted_kvittens', { kvittens: agiKvittensnummer })
: t('rail_agi_submitted')
: agiState === 'awaiting_signing'
? t('rail_agi_awaiting_signature')
: agiState === 'underlag_submitted'
? t('rail_agi_underlag_submitted')
: agiState === 'generated'
? t('rail_agi_generated')
: run.status === 'booked'
? t('rail_agi_hint')
: undefined,
},
]
+47 -40
View File
@@ -5,10 +5,9 @@ import {
createElement,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
import useSWR from 'swr'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
@@ -36,48 +35,56 @@ export interface SettingsState {
*/
export function useCompanySettings(): SettingsState {
const { company } = useCompany()
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(false)
const companyId = company?.id ?? null
const fetchSettings = useCallback(async () => {
if (!company?.id) {
// No active company (the no-company escape hatch). Nothing to load; surface
// a settled empty state rather than a perpetual spinner.
setSettings(null)
setError(false)
setIsLoading(false)
return
}
// SWR-backed: every consumer of the same company's settings shares one
// cache entry, so concurrent mounts dedupe into a single request and
// back-navigation renders from cache (revalidating in the background)
// instead of re-showing a skeleton. Null key = no active company (the
// no-company escape hatch): a settled empty state, never a spinner.
const { data, error: swrError, isLoading, mutate } = useSWR(
companyId ? ['company_settings', companyId] : null,
async ([, id]: [string, string]) => {
const supabase = createClient()
// maybeSingle() so a missing row resolves to { data: null } instead of
// throwing PGRST116: a company created outside the onboarding flow may
// have no company_settings row yet, and that must not be treated as a
// hard error mid-query (it's surfaced as `error` below once settled).
const { data, error: queryError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', id)
.maybeSingle()
if (queryError) throw queryError
return data as CompanySettings | null
},
)
setIsLoading(true)
setError(false)
const updateSettings = useCallback(
(updates: Partial<CompanySettings>) => {
// Optimistic local patch only: callers persist through their own API
// routes. revalidate: false so the patch isn't immediately overwritten
// by a refetch racing the server-side write.
void mutate((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev), {
revalidate: false,
})
},
[mutate],
)
const supabase = createClient()
// maybeSingle() so a missing row resolves to { data: null } instead of
// throwing PGRST116: a company created outside the onboarding flow may have
// no company_settings row yet, and that must not be treated as a hard error
// mid-query (it's surfaced as `error` below once the fetch settles).
const { data, error: queryError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', company.id)
.maybeSingle()
const refetch = useCallback(async () => {
await mutate()
}, [mutate])
setSettings(data)
setError(Boolean(queryError) || !data)
setIsLoading(false)
}, [company?.id])
useEffect(() => {
fetchSettings()
}, [fetchSettings])
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
setSettings((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev))
}, [])
return { settings, isLoading, error, updateSettings, refetch: fetchSettings }
return {
settings: data ?? null,
isLoading: companyId ? isLoading : false,
// Same contract as before SWR: error means "settled without a row"
// (query failure or missing company_settings row), never mid-flight.
error: companyId ? !isLoading && (Boolean(swrError) || !data) : false,
updateSettings,
refetch,
}
}
const SettingsContext = createContext<SettingsState | null>(null)