Files
accounted/components/onboarding/agent/ReviewCard.tsx
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

645 lines
24 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Pencil, X, Loader2, ArrowLeft, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { AVATAR_OPTIONS } from '@/components/agent/avatars'
import AgentAvatar from '@/components/agent/AgentAvatar'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
interface InitialFields {
entity_type_label: string
sni_codes: { code: string; name: string }[]
purpose: string | null
city: string | null
fiscal_period: string | null
vat_period: string | null
f_skatt: string | null
employees: string | null
}
interface ProfilePayload {
company_id: string
horizontal_atoms: string[]
vertical_atoms: string[]
modifier_atoms: string[]
is_multi_vertical: boolean
profile_summary: string
// Still carried from the composer + stored on the profile row, but no
// longer surfaced as a form step here: the Phase C chat intake owns the
// questions now (reads them server-side). Kept on the type so the payload
// shape stays aligned with the stream event.
verification_questions: string[]
uncertainty_notes: string[]
composer_model: string
composed_at: string
}
interface Props {
companyId: string
companyName: string
initialFields: InitialFields
// Pre-fetched atom titles from agent_atom_registry: used to render chips
// with the authored title instead of a naive slug-derived label. Missing
// ids fall through to deriveSlugTitle which is intentionally minimal.
atomTitles: Record<string, string>
profile: ProfilePayload | null
onVerified: () => void
}
// Maps an atom id to a chip label. Prefers the registry title when known.
function atomLabel(id: string, atomTitles: Record<string, string>): string {
if (atomTitles[id]) return atomTitles[id]
const slug = id.split('/').slice(-1)[0] ?? id
return slug
.split('-')
.map((w) => {
const upper = w.toUpperCase()
if (upper === 'VAT' || upper === 'SRU' || upper === 'SIE' || upper === 'IT') return upper
return w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w
})
.join(' ')
}
export default function ReviewCard({
companyId,
companyName,
initialFields,
atomTitles,
profile,
onVerified,
}: Props) {
// Field-edit state. The pencil affordances let the user override anything
// the composer inferred. Each override is sent to PATCH /api/agent/profile,
// which stamps an overridden_at timestamp.
const [fields, setFields] = useState<InitialFields>(initialFields)
const [editing, setEditing] = useState<keyof InitialFields | null>(null)
const [summary, setSummary] = useState<string>(profile?.profile_summary ?? '')
const [editingSummary, setEditingSummary] = useState(false)
const [horizontal, setHorizontal] = useState<string[]>(profile?.horizontal_atoms ?? [])
const [vertical, setVertical] = useState<string[]>(profile?.vertical_atoms ?? [])
const [modifier, setModifier] = useState<string[]>(profile?.modifier_atoms ?? [])
// Agent identity: name shown on the FAB, avatar shown alongside.
const [displayName, setDisplayName] = useState('')
const [avatarId, setAvatarId] = useState<string>(AVATAR_OPTIONS[0].id)
const [seedMemory, setSeedMemory] = useState('')
const [verifying, setVerifying] = useState(false)
const [verifyError, setVerifyError] = useState<string | null>(null)
// Two steps now:
// 1: meet your assistant (name + avatar)
// 2: agree on the facts (profile + specialties + form fields + optional
// seed note), then "kör" which hands off to the Phase C chat intake.
// The verification-question interview that used to live here as a form
// stepper is gone: the chat conducts the real interview instead.
type Step = 1 | 2
const [step, setStep] = useState<Step>(1)
const totalPositions = 2
const currentPosition = step - 1
const agentName = displayName.trim() || 'din assistent'
async function handleVerify() {
setVerifying(true)
setVerifyError(null)
try {
// Persist edits before verifying. Skipped if nothing changed.
const changedFields: Record<string, unknown> = {}
for (const key of Object.keys(initialFields) as (keyof InitialFields)[]) {
if (fields[key] !== initialFields[key]) {
changedFields[key] = fields[key]
}
}
const atomsChanged =
!arrEq(horizontal, profile?.horizontal_atoms ?? []) ||
!arrEq(vertical, profile?.vertical_atoms ?? []) ||
!arrEq(modifier, profile?.modifier_atoms ?? [])
const summaryChanged = summary !== (profile?.profile_summary ?? '')
const trimmedName = displayName.trim()
// Identity is always persisted on first verify so the FAB picks it up
// immediately. If the user typed nothing, we leave display_name null
// (UI falls back to "min revisor").
const identityChanged = trimmedName.length > 0 || avatarId !== AVATAR_OPTIONS[0].id
if (Object.keys(changedFields).length > 0 || atomsChanged || summaryChanged || identityChanged) {
const patchBody: Record<string, unknown> = { company_id: companyId }
if (Object.keys(changedFields).length > 0) patchBody.field_overrides = changedFields
if (atomsChanged) {
patchBody.atoms = {
horizontal_atoms: horizontal,
vertical_atoms: vertical,
modifier_atoms: modifier,
}
}
if (summaryChanged) patchBody.profile_summary = summary
if (identityChanged) {
patchBody.display_name = trimmedName.length > 0 ? trimmedName : null
patchBody.avatar_id = avatarId
}
const res = await fetch('/api/agent/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchBody),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || `HTTP ${res.status}`)
}
}
if (seedMemory.trim().length > 1) {
await fetch('/api/agent/memory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
company_id: companyId,
content: seedMemory.trim(),
kind: 'fact',
source: 'user_taught',
source_ref: 'onboarding_seed',
relevance_score: 1.0,
}),
})
}
const verifyRes = await fetch('/api/agent/profile/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ company_id: companyId }),
})
if (!verifyRes.ok) {
const text = await verifyRes.text()
throw new Error(text || `HTTP ${verifyRes.status}`)
}
onVerified()
} catch (err) {
setVerifyError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte verifiera.')
} finally {
setVerifying(false)
}
}
const stepTitle = step === 1 ? 'Träffa din assistent' : 'Stäm av detaljerna'
const stepSubtitle =
step === 1
? 'Ge din assistent ett namn och välj en avatar.'
: 'Bekräfta att uppgifterna stämmer, eller ändra det som blivit fel. Sen lär din assistent känna dig i en kort intervju.'
return (
<div className="w-full">
<header className="mb-6">
<p className="text-xs uppercase tracking-wider text-muted-foreground mb-1">
{companyName}
</p>
<h1 className="font-display text-3xl md:text-4xl tracking-tight">{stepTitle}</h1>
<p className="text-muted-foreground mt-2">{stepSubtitle}</p>
</header>
{/* Progress: one segment per step. Back navigation lives on the
"Tillbaka" button below. */}
<div className="flex items-center gap-1.5 mb-6" aria-hidden="true">
{Array.from({ length: totalPositions }, (_, i) => i).map((pos) => (
<div
key={pos}
className={cn(
'h-1.5 flex-1 rounded-full transition-colors',
pos <= currentPosition ? 'bg-foreground' : 'bg-border',
)}
/>
))}
</div>
<Card className="border-border">
<CardContent className="p-6 md:p-8 space-y-6">
{step === 1 && (
<section className="space-y-6">
<div className="flex flex-col items-center text-center gap-3 py-4">
<AgentAvatar avatarId={avatarId} size="lg" className="h-20 w-20" />
<div>
<p className="font-display text-xl tracking-tight">
{displayName.trim() || 'Din assistent'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Visas som <span className="font-medium">Fråga {displayName.trim() || 'min assistent'}</span> i appen.
</p>
</div>
</div>
<div>
<label htmlFor="agent-display-name" className="block text-sm font-medium mb-2">
Vad ska den heta?
</label>
<Input
id="agent-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="t.ex. Anna, Lars, Karin. Eller hoppa över."
maxLength={60}
autoFocus
/>
</div>
<div>
<p className="block text-sm font-medium mb-2">Välj en avatar</p>
<div className="grid grid-cols-4 gap-3 sm:grid-cols-8">
{AVATAR_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setAvatarId(opt.id)}
aria-label={`Välj avatar ${opt.label}`}
className={cn(
'aspect-square rounded-full overflow-hidden transition-all',
avatarId === opt.id
? 'ring-2 ring-foreground ring-offset-2 ring-offset-background'
: 'opacity-70 hover:opacity-100 hover:ring-1 hover:ring-border',
)}
>
<AgentAvatar avatarId={opt.id} size="md" className="h-full w-full" />
</button>
))}
</div>
</div>
</section>
)}
{step === 2 && (
<>
{/* Value first: the prose summary the composer wrote, so the
user sees the assistant understood them before being asked to
check dry registry facts. */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
här har jag förstått dig
</h2>
{!editingSummary && summary && (
<button
onClick={() => setEditingSummary(true)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Redigera profil"
>
<Pencil className="h-4 w-4" />
</button>
)}
</div>
{editingSummary ? (
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
onBlur={() => setEditingSummary(false)}
autoFocus
rows={5}
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm leading-6 resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
) : (
<p className="text-sm leading-6 italic text-muted-foreground">
{summary || 'Ingen sammanfattning ännu.'}
</p>
)}
</section>
{/* What the assistant can actually do: the differentiated
output of the build. Plain-language heading, not the internal
"atoms/specialiteter" framing. */}
{(horizontal.length > 0 || vertical.length > 0 || modifier.length > 0) && (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-1">
Vad jag kan hjälpa dig med
</h2>
<p className="text-xs text-muted-foreground mb-3">
Kunskapsområden jag läst in för din verksamhet. Ta bort det som inte passar, slipper du förslag som inte är relevanta.
</p>
<ChipGroup
primaryLabel="Bransch"
secondaryLabel="Övrigt"
primary={vertical.map((id) => ({ id, label: atomLabel(id, atomTitles) }))}
secondary={[
...modifier.map((id) => ({ id, label: atomLabel(id, atomTitles), group: 'modifier' as const })),
...horizontal.map((id) => ({ id, label: atomLabel(id, atomTitles), group: 'horizontal' as const })),
]}
onRemove={(id, group) => {
if (group === 'horizontal') setHorizontal((arr) => arr.filter((x) => x !== id))
else if (group === 'vertical') setVertical((arr) => arr.filter((x) => x !== id))
else setModifier((arr) => arr.filter((x) => x !== id))
}}
/>
</section>
)}
{/* Inferred facts to confirm */}
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-4">
Uppgifter
</h2>
<dl className="divide-y divide-border">
<FieldRow
label="Form"
value={fields.entity_type_label}
editing={editing === 'entity_type_label'}
onEdit={() => setEditing('entity_type_label')}
onChange={(v) => setFields((f) => ({ ...f, entity_type_label: v }))}
onCommit={() => setEditing(null)}
/>
<SniRow sniCodes={fields.sni_codes} />
<FieldRow
label="Säte"
value={fields.city ?? ''}
placeholder="-"
editing={editing === 'city'}
onEdit={() => setEditing('city')}
onChange={(v) => setFields((f) => ({ ...f, city: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="Räkenskapsår"
value={fields.fiscal_period ?? ''}
placeholder="januari-december"
editing={editing === 'fiscal_period'}
onEdit={() => setEditing('fiscal_period')}
onChange={(v) => setFields((f) => ({ ...f, fiscal_period: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="Moms"
value={fields.vat_period ?? ''}
placeholder="Kvartal / månad / år"
editing={editing === 'vat_period'}
onEdit={() => setEditing('vat_period')}
onChange={(v) => setFields((f) => ({ ...f, vat_period: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="F-skatt"
value={fields.f_skatt ?? ''}
placeholder="Aktivt / saknas"
editing={editing === 'f_skatt'}
onEdit={() => setEditing('f_skatt')}
onChange={(v) => setFields((f) => ({ ...f, f_skatt: v }))}
onCommit={() => setEditing(null)}
/>
<FieldRow
label="Anställda"
value={fields.employees ?? ''}
placeholder="0"
editing={editing === 'employees'}
onEdit={() => setEditing('employees')}
onChange={(v) => setFields((f) => ({ ...f, employees: v }))}
onCommit={() => setEditing(null)}
/>
</dl>
</section>
{/* Verksamhetsbeskrivning from Bolagsverket: verbatim, since
authoritative legal text. Hidden when TIC didn't return one. */}
{fields.purpose && (
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
Verksamhet
</h2>
<p className="text-sm leading-6 text-muted-foreground italic">
{fields.purpose}
</p>
</section>
)}
{/* Optional seed note: the fast path for users who'd rather jot
one thing than chat. The Phase C intake will draw the rest
out conversationally. */}
<section>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-1">
Bra att veta
</h2>
<p className="text-xs text-muted-foreground mb-3">
Valfritt: du kan också berätta i chatten strax. T.ex. återkommande kunder, en hyresfaktura som kommer den 25:e, eller att kunderna mest finns i Tyskland.
</p>
<textarea
id="seed-memory"
value={seedMemory}
onChange={(e) => setSeedMemory(e.target.value)}
rows={3}
placeholder="Skriv något, eller lämna tomt"
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm leading-6 resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
</section>
</>
)}
{verifyError && (
<p className="text-sm text-destructive">{verifyError}</p>
)}
{/* Step nav. Step 1 → forward to review. Step 2 → back to meet, or
run the verify pipeline and hand off to the chat intake. */}
<div className="flex items-center justify-between gap-3 pt-2">
<Button
variant="ghost"
onClick={() => setStep(1)}
disabled={step === 1}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Tillbaka
</Button>
{step === 1 && (
<Button onClick={() => setStep(2)}>
Nästa
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
{step === 2 && (
<Button size="lg" onClick={handleVerify} disabled={verifying}>
{verifying ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Sparar
</>
) : (
<>
Möt {agentName}
<ArrowRight className="h-4 w-4 ml-2" />
</>
)}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)
}
// Renders one or more SNI codes alongside their human-readable industry
// labels. Read-only for the POC: TIC is authoritative for SNI and we don't
// surface a UI to add codes that Bolagsverket doesn't have.
//
// TIC occasionally returns the same SNI code twice (e.g. as both primary and
// secondary on the company record). Dedupe by code so the same line doesn't
// render twice in a row.
function SniRow({ sniCodes }: { sniCodes: { code: string; name: string }[] }) {
const seen = new Set<string>()
const uniqueCodes = sniCodes.filter((s) => {
if (seen.has(s.code)) return false
seen.add(s.code)
return true
})
return (
<div className="flex items-start gap-4 py-3">
<dt className="w-32 text-sm text-muted-foreground shrink-0">SNI</dt>
<dd className="flex-1 min-w-0">
{uniqueCodes.length === 0 ? (
<span className="text-sm italic text-muted-foreground/60">Saknas</span>
) : (
<ul className="space-y-1">
{uniqueCodes.map((s) => (
<li key={s.code} className="flex gap-3 text-sm">
<span className="tabular-nums text-muted-foreground shrink-0">{s.code}</span>
<span className="min-w-0">{s.name}</span>
</li>
))}
</ul>
)}
</dd>
</div>
)
}
function FieldRow({
label,
value,
placeholder,
editing,
onEdit,
onChange,
onCommit,
}: {
label: string
value: string
placeholder?: string
editing: boolean
onEdit: () => void
onChange: (v: string) => void
onCommit: () => void
}) {
return (
<div className="flex items-center gap-4 py-3">
<dt className="w-32 text-sm text-muted-foreground shrink-0">{label}</dt>
<dd className="flex-1 min-w-0">
{editing ? (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onCommit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onCommit()
}
}}
autoFocus
className="h-9"
/>
) : (
<span
className={cn(
'text-sm',
!value && 'text-muted-foreground/60 italic',
)}
>
{value || placeholder || '-'}
</span>
)}
</dd>
{!editing && (
<button
onClick={onEdit}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
aria-label={`Redigera ${label}`}
>
<Pencil className="h-4 w-4" />
</button>
)}
</div>
)
}
function ChipGroup({
primaryLabel,
secondaryLabel,
primary,
secondary,
onRemove,
}: {
primaryLabel: string
secondaryLabel: string
primary: { id: string; label: string }[]
secondary: { id: string; label: string; group: 'horizontal' | 'modifier' }[]
onRemove: (id: string, group: 'horizontal' | 'vertical' | 'modifier') => void
}) {
return (
<div className="space-y-3">
{primary.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">{primaryLabel}</p>
<div className="flex flex-wrap gap-2">
{primary.map((c) => (
<Chip key={c.id} label={c.label} onRemove={() => onRemove(c.id, 'vertical')} />
))}
</div>
</div>
)}
{secondary.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-2">{secondaryLabel}</p>
<div className="flex flex-wrap gap-2">
{secondary.map((c) => (
<Chip
key={c.id}
label={c.label}
onRemove={() => onRemove(c.id, c.group)}
muted
/>
))}
</div>
</div>
)}
</div>
)
}
function Chip({
label,
onRemove,
muted,
}: {
label: string
onRemove: () => void
muted?: boolean
}) {
return (
<Badge
variant={muted ? 'outline' : 'secondary'}
className="pl-3 pr-1.5 py-1 text-xs gap-1 inline-flex items-center"
>
{label}
<button
onClick={onRemove}
className="ml-1 rounded-full hover:bg-foreground/10 p-0.5 transition-colors"
aria-label={`Ta bort ${label}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
)
}
function arrEq(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}