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:
co-authored by
Claude Fable 5
parent
e7e3c35f9e
commit
b6332e9ff4
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user