* feat(salary): per-day absence tracking with calendar UX Replace aggregated-day absence counts with per-day records so payroll calculations can correctly enforce Swedish legal rules that depend on actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per rolling 12 months, day-8 läkarintyg flag, day-15 transition to Försäkringskassan. Adds: - salary_absence_days table (RLS, dedup unique on employee+date+type) - /api/salary/employees/[id]/absence CRUD route - deriveAbsenceLineItems helper that walks per-day records into sjuklöneperioder and emits correctly-classified line items, with the existing absence-calculator formulas reused for VAB / parental - Per-employee pay-spec detail page with month-grid AbsenceCalendar - Calculate route now derives line items from the calendar before running the salary engine, replacing the prior sumQuantity model - Salary run GET surfaces the formatted Skatteverket arbetsgivare ID so downstream UI can build extension URLs without a second round-trip - GET /salary/runs/[id]/employees/[employeeId] for the detail page Tests: 15 new unit tests covering segment merge, återinsjuknande within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag, VAB/parental semesterlönegrundande ceilings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag Three hardening fixes from the prior audit, plus a runtime extension toggle for phased rollout. api-client.ts: - Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a Swedish user message. The 4 req/sec local rate limiter normally prevents this, but the per-consumer gateway quota can still hit. - Extend the error union with TOKEN_CORRUPTED for the token-store fix below. token-store.ts: - Surface decryption failures instead of silently returning null. A rotated key or tampered ciphertext used to look like "not connected"; callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID" message and a structured log line for ops. Extension dispatcher (app/api/extensions/ext/[...path]/route.ts): - Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED is not exactly "true", the dispatcher returns 503 with code EXTENSION_DISABLED, letting ops disable a single integration mid- rollout without redeploying or removing it from extensions.config.json. UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an empty state. Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission AGI XML upgrade: - Emit <gem:Franvarouppgift> top-level blocks for VAB and parental leave events sourced from salary_absence_days, per SKV 4785 + technical doc. Element order matches the spec example file. TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental, with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for hours. Stable 1-based specifikationsnummer per (employee, period), date-sorted. Skipped entirely for periods before 202501. - Sick days are NOT emitted (they go to Försäkringskassan). - FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity × dailyRate × 0.80 instead of Math.abs(amount). The line-item amount is the net deduction (lostPay − sjuklon), not the cost, so the prior formula understated by a factor of four. AGI submission UI: - New AGIPanel mirroring SkatteverketPanel's validate → draft → lock → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED and renders a clear empty state. Replaces the bare "Skicka till Skatteverket" button on /salary/runs/[id], keeping the AGI XML download as a sibling for archival / manual upload fallback. - Salary run rows now link to the per-employee detail page added in the previous commit. Tests: 14 new agi-xml cases covering element order, type↔hour-field mapping, specifikationsnummer ordering, fractional-hour formatting, range clamping (0.01-24.00), period guard at 202501 boundary, placement after Blankett blocks, multi-employee date ordering, required-fields invariant, omission when no events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför Adds read-only Skattekonto v2.1 access via the existing BankID OAuth flow (extends the OAuth scope with `skattekonto`). Daily background sync pulls saldo + transactions, dedupes on (company_id, dedup_key), and surfaces the data in a /skattekonto dashboard plus a settings panel for connection management. Backend: - skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated SkatteverketSkattekontoError. - skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT on (company_id, dedup_key) so kommande rows graduate to tidigare in place. Dedup key uses transaktionsidentitet when available, else sha256 of (date|amount|text). Caches saldo snapshot in extension_data. Emits skattekonto.synced / balance.changed (sign flip) / transaction.upcoming (first appearance) / connection.expired. - skattekonto-booking.ts: keyword→counter-account rules with AB/EF differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650 for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for kostnads-/intäktsränta). Creates a draft journal entry against BAS 1630, leaves it for the user to review and commit. Throws NO_COUNTER_ACCOUNT instead of guessing when no rule matches. - Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company cooldown of 1 hour, time budget 50s, distinct `expired` status for token-exhaustion separate from generic errors. Database: - skattekonto_transactions: company-scoped with RLS, unique (company_id, dedup_key), indexed on (company_id, date DESC) and (company_id, status). journal_entry_id FK with ON DELETE SET NULL so a row can be re-bokförd after entry deletion. Frontend: - /skattekonto/page.tsx: dashboard with saldo card, transactions list (booked + upcoming), per-row "Bokför" action. - /settings/skatteverket: connection panel showing scope/expiry. - Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS). Tests: 9 booking-rule cases (counter-account guessing, AB/EF divergence, no-match throw) + 7 mapper cases (dedup key stability, sign convention, kommande→tidigare graduation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review findings Build: - Fix Next.js build failure: Zod refuses .partial() on a refined schema. Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence DELETE handler with a fresh z.object that defines its own optional fields. Greptile findings (PR #388): - skattekonto_transactions UPDATE policy was missing WITH CHECK; without it a user could mutate company_id to one they don't belong to. Edit the original migration for fresh applies + add a follow-up migration that drops/recreates the policy with both clauses (already applied to prod via Supabase MCP). - FK499 TotalSjuklonekostnad now reads sjuklonRate from run.calculation_params (snapshot taken at calc time) instead of a hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is honored. Falls back to 0.80 for older runs without the snapshot. - Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the flag is server-side only. NEXT_PUBLIC_* vars are inlined into the client bundle at build time, which would create split-brain (server 503 vs client still rendering enabled flow) on a flag flip without redeploy. UI panels detect 503 by response code, not by reading the env directly, so no client-visible change is needed. - Add pg-real RLS smoke tests for both new tables (salary_absence_days and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH CHECK enforcement, unique-constraint enforcement, cross-tenant dedup key allowed. Swedish compliance review: - Document the högriskskydd cap interpretation in derive-absence-line-items.ts. We count *sjuklöneperioder* in the rolling 12-month window, matching the law's plain reading ("från och med den 11:e sjukperioden ... görs inget karensavdrag"). An alternative reading counts only periods that actually had karens deducted; that requires persisting per-period karens-deduction state, which gnubok doesn't yet do. The period-count reading can over- suppress, never under-suppress, so it's the safer default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/. Inline the saldoResponse + transaktionerResponse fixtures verbatim from the spec; the test still verifies our mappers + dedup-key logic against the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Download,
|
|
ExternalLink,
|
|
FileCheck,
|
|
Link2,
|
|
Link2Off,
|
|
Loader2,
|
|
Lock,
|
|
Send,
|
|
ShieldAlert,
|
|
Unlock,
|
|
} from 'lucide-react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
interface AGIPanelProps {
|
|
salaryRunId: string
|
|
/** Skatteverket arbetsgivare ID (12-digit) — formatted by parent. */
|
|
arbetsgivare: string
|
|
/** YYYYMM */
|
|
period: string
|
|
/** Already-cached run-level signals for showing what step we're at. */
|
|
agiGeneratedAt?: string | null
|
|
agiSubmittedAt?: string | null
|
|
/** When true, write actions are hidden. */
|
|
readOnly?: boolean
|
|
/** Called after a state-changing action so parent can refresh. */
|
|
onChange?: () => void
|
|
}
|
|
|
|
interface ConnectionStatus {
|
|
connected: boolean
|
|
expired?: boolean
|
|
canRefresh?: boolean
|
|
scope?: string
|
|
expiresAt?: string
|
|
}
|
|
|
|
interface KontrollResult {
|
|
kod: string
|
|
status: 'ERROR' | 'WARNING'
|
|
beskrivning: string
|
|
}
|
|
|
|
interface SubmissionState {
|
|
status?: 'draft_saved' | 'draft_locked' | 'signed'
|
|
signeringslank?: string
|
|
kvittensnummer?: string
|
|
tidpunkt?: string
|
|
inlamningId?: string
|
|
}
|
|
|
|
const ENABLED_KEY = 'EXTENSION_DISABLED'
|
|
|
|
export function AGIPanel(props: AGIPanelProps) {
|
|
const {
|
|
salaryRunId,
|
|
arbetsgivare,
|
|
period,
|
|
agiGeneratedAt,
|
|
agiSubmittedAt,
|
|
readOnly,
|
|
onChange,
|
|
} = props
|
|
|
|
const [extensionDisabled, setExtensionDisabled] = useState(false)
|
|
const [status, setStatus] = useState<ConnectionStatus | null>(null)
|
|
const [submission, setSubmission] = useState<SubmissionState | null>(null)
|
|
const [kontroller, setKontroller] = useState<KontrollResult[]>([])
|
|
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 fetchStatus = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/status')
|
|
if (res.status === 503) {
|
|
const data = await res.json().catch(() => ({}))
|
|
if (data?.code === ENABLED_KEY) {
|
|
setExtensionDisabled(true)
|
|
return
|
|
}
|
|
}
|
|
if (res.ok) {
|
|
setStatus(await res.json())
|
|
}
|
|
} catch {
|
|
// ignore — UI shows the not-connected state
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const fetchSubmission = useCallback(async () => {
|
|
try {
|
|
const res = await fetch(
|
|
`/api/extensions/ext/skatteverket/agi/status?period=${period}`,
|
|
)
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setSubmission(json.data ?? null)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [period])
|
|
|
|
useEffect(() => {
|
|
fetchStatus()
|
|
fetchSubmission()
|
|
}, [fetchStatus, fetchSubmission])
|
|
|
|
const handleConnect = () => {
|
|
window.location.href = '/api/extensions/ext/skatteverket/authorize'
|
|
}
|
|
|
|
const handleValidate = async () => {
|
|
setActionLoading('validate')
|
|
setError(null)
|
|
setSuccess(null)
|
|
setKontroller([])
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/agi/validate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ salaryRunId }),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok || json.error) {
|
|
setError(json.error || `Validering misslyckades (${res.status})`)
|
|
return
|
|
}
|
|
const controls: KontrollResult[] = json.data?.kontrollresultat?.resultat ?? []
|
|
setKontroller(controls)
|
|
const errs = controls.filter(c => c.status === 'ERROR')
|
|
if (errs.length === 0) setSuccess('Valideringen godkänd')
|
|
else setError(`${errs.length} valideringsfel hittades`)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Kunde inte validera AGI')
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const handleSaveDraft = async () => {
|
|
setActionLoading('draft')
|
|
setError(null)
|
|
setSuccess(null)
|
|
try {
|
|
const res = await fetch('/api/extensions/ext/skatteverket/agi/draft', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ salaryRunId }),
|
|
})
|
|
const json = await res.json()
|
|
if (!res.ok || json.error) {
|
|
setError(json.error || `Kunde inte spara utkast (${res.status})`)
|
|
return
|
|
}
|
|
setSuccess('AGI-utkast sparat hos Skatteverket')
|
|
await fetchSubmission()
|
|
onChange?.()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Kunde inte spara utkast')
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const handleLock = async () => {
|
|
setActionLoading('lock')
|
|
setError(null)
|
|
setSuccess(null)
|
|
try {
|
|
const res = await fetch(
|
|
`/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
|
{ method: 'PUT' },
|
|
)
|
|
const json = await res.json()
|
|
if (!res.ok || json.error) {
|
|
setError(json.error || `Kunde inte låsa AGI (${res.status})`)
|
|
return
|
|
}
|
|
setSuccess('AGI låst — öppna signeringslänken för att signera med BankID.')
|
|
await fetchSubmission()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Kunde inte låsa AGI')
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const handleUnlock = async () => {
|
|
setActionLoading('unlock')
|
|
setError(null)
|
|
setSuccess(null)
|
|
try {
|
|
const res = await fetch(
|
|
`/api/extensions/ext/skatteverket/agi/lock?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
|
{ method: 'DELETE' },
|
|
)
|
|
const json = await res.json()
|
|
if (!res.ok || json.error) {
|
|
setError(json.error || `Kunde inte låsa upp (${res.status})`)
|
|
return
|
|
}
|
|
setSuccess('AGI har låsts upp')
|
|
await fetchSubmission()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Kunde inte låsa upp')
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
const handleCheckSubmitted = async () => {
|
|
setActionLoading('check')
|
|
setError(null)
|
|
setSuccess(null)
|
|
try {
|
|
const res = await fetch(
|
|
`/api/extensions/ext/skatteverket/agi/submitted?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
|
)
|
|
const json = await res.json()
|
|
if (!res.ok || json.error) {
|
|
setError(json.error || 'Kunde inte hämta inlämningsstatus')
|
|
return
|
|
}
|
|
if (json.data?.kvittensnummer) {
|
|
setSuccess('AGI har lämnats in')
|
|
} else {
|
|
setSuccess('Ingen inlämning hittades än för perioden')
|
|
}
|
|
await fetchSubmission()
|
|
onChange?.()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Kunde inte kontrollera status')
|
|
} finally {
|
|
setActionLoading(null)
|
|
}
|
|
}
|
|
|
|
// ── Render branches ─────────────────────────────────────────────
|
|
|
|
if (extensionDisabled) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
<div className="flex items-start gap-2">
|
|
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<p>
|
|
Skatteverket-integrationen är inaktiverad i denna miljö. Aktivera
|
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
|
|
för att skicka AGI direkt till Skatteverket.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> Hämtar Skatteverket-status...
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
if (!status?.connected) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
Anslut till Skatteverket med BankID för att skicka AGI direkt från {`gnubok`}.
|
|
</p>
|
|
{!readOnly && (
|
|
<Button onClick={handleConnect}>
|
|
<Link2 className="mr-2 h-4 w-4" />
|
|
Anslut med BankID
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
const subState = submission?.status
|
|
const isLocked = subState === 'draft_locked'
|
|
const isSigned = subState === 'signed' || !!agiSubmittedAt
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between text-base">
|
|
<span>Arbetsgivardeklaration (AGI)</span>
|
|
<span className="flex items-center gap-1 text-xs font-normal text-muted-foreground">
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
|
|
Ansluten
|
|
</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Status summary */}
|
|
<div className="space-y-1.5 text-sm">
|
|
<StatusRow
|
|
ok={!!agiGeneratedAt}
|
|
okText={agiGeneratedAt ? `AGI-fil genererad ${new Date(agiGeneratedAt).toLocaleString('sv-SE')}` : ''}
|
|
pendingText="AGI-fil har inte genererats ännu."
|
|
/>
|
|
<StatusRow
|
|
ok={isSigned}
|
|
okText={
|
|
submission?.kvittensnummer
|
|
? `Skickad till Skatteverket — kvittens ${submission.kvittensnummer}`
|
|
: agiSubmittedAt
|
|
? `Skickad till Skatteverket ${new Date(agiSubmittedAt).toLocaleString('sv-SE')}`
|
|
: 'Skickad'
|
|
}
|
|
pendingText={
|
|
isLocked
|
|
? 'AGI låst — väntar på BankID-signatur.'
|
|
: subState === 'draft_saved'
|
|
? 'Utkast sparat hos Skatteverket. Lås och signera för att slutföra.'
|
|
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.'
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{submission?.signeringslank && isLocked && (
|
|
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
|
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
Öppna länken nedan och signera med BankID på Skatteverkets sida.
|
|
</p>
|
|
<a
|
|
href={submission.signeringslank}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-amber-900 hover:underline dark:text-amber-200"
|
|
>
|
|
Öppna signeringslänk <ExternalLink className="h-3.5 w-3.5" />
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{kontroller.length > 0 && (
|
|
<div className="space-y-1 rounded-md border bg-muted/30 p-2.5">
|
|
{kontroller.map((k, i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex items-start gap-2 text-xs ${
|
|
k.status === 'ERROR' ? 'text-destructive' : 'text-amber-700 dark:text-amber-400'
|
|
}`}
|
|
>
|
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span>
|
|
<span className="font-mono">{k.kod}</span> — {k.beskrivning}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-md bg-destructive/10 p-2.5 text-sm text-destructive">
|
|
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
{success && !error && (
|
|
<div className="rounded-md bg-emerald-50 p-2.5 text-sm text-emerald-900 dark:bg-emerald-900/20 dark:text-emerald-300">
|
|
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5" />
|
|
{success}
|
|
</div>
|
|
)}
|
|
|
|
{!readOnly && !isSigned && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleValidate}
|
|
disabled={!!actionLoading}
|
|
>
|
|
{actionLoading === 'validate' ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<FileCheck className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Validera
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleSaveDraft}
|
|
disabled={!!actionLoading || isLocked}
|
|
>
|
|
{actionLoading === 'draft' ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Spara utkast
|
|
</Button>
|
|
{!isLocked ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={handleLock}
|
|
disabled={!!actionLoading || subState !== 'draft_saved'}
|
|
>
|
|
{actionLoading === 'lock' ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Lock className="mr-1.5 h-3.5 w-3.5" />
|
|
)}
|
|
Lås för signering
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleUnlock}
|
|
disabled={!!actionLoading}
|
|
>
|
|
{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" />
|
|
)}
|
|
Lås upp
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={handleCheckSubmitted}
|
|
disabled={!!actionLoading}
|
|
>
|
|
{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" />
|
|
)}
|
|
Hämta status
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function StatusRow({
|
|
ok,
|
|
okText,
|
|
pendingText,
|
|
}: {
|
|
ok: boolean
|
|
okText: string
|
|
pendingText: string
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
{ok ? (
|
|
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
|
) : (
|
|
<Link2Off className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
|
|
</div>
|
|
)
|
|
}
|