da859d7236
* refactor(ui): normalize dense pages to the locked design system Sweep of the info-dense surfaces against .claude/rules/design.md; no behavior changes, classNames and primitive adoption only. - Replace hand-rolled h1s with PageHeader (import, suppliers, kpi, salary/employees, skattekonto, settings layout) and drop the one double title (SalarySettingsContent under the settings h1) - Replace hand-rolled empty states with EmptyState (skattekonto, banking/api-keys/oauth/counterparty settings) and hand-rolled pulse divs with Skeleton (deadlines, report view loaders) - Remove semantic colors used as chrome: amber/emerald banners in AGIPanel and SkatteverketPanel, success/warning tints in kassaflodesanalys, arsredovisning and import become neutral surfaces with the tint kept on the icon only - Full-opacity borders everywhere (border-border/30-60, border-destructive/20-40, border-foreground/30, text-destructive/80) - Snap off-scale spacing (p-5 to p-6, p-2.5 to p-3, gap/mt-x.5 to scale values); KPI metric tiles p-6 to p-4 per the tile rule - Remove the mobile Select that duplicated the invoices status Tabs (TabsList already scrolls horizontally); single Tabs now serves both breakpoints - supplier-invoices: shared formatCurrency instead of a local formatAmount helper; skattekonto: formatDate/formatDateLong/ formatDateTime instead of raw dates and toLocaleString - arsredovisning flerarsoversikt converted to the Table primitive with right-aligned tabular-nums cells - Settings: CardTitle text-base on section cards, one heading idiom in AccountSettingsContent, h3 to h2 in CompanyProfileView Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(i18n): trim text bloat and fix an untranslated sv string - Fix invoice_credit.create_failed_fallback: sv catalog carried the English "Failed to create credit note"; now "Kunde inte skapa kreditfaktura". Translate new_user_checklist.step3_title in en - Drop descriptions that paraphrase their own title (design.md forbidden pattern): invoice_detail.credited_description, invoice_credit.original_card_description, invoice_editor customer/notes card descriptions (keys deleted from both catalogs, zero remaining usages); the transaction booking DialogDescription becomes sr-only so screen readers keep it - Trim redundant sentences from settings_salary.info_payroll_scope, settings_backup.intro, ext_cloud_backup_long_description, settings.name_description, salary_payments.open_payments_note and shorten invoice_credit.reason_card_description; statutory BFL/tax prose untouched - Normalize toast punctuation (dimensions/self_billing created_description lose the trailing period like their siblings) - common.delete "Radera" to "Ta bort" (zero live call sites; Radera stays reserved for irreversible account/company deletion) Catalogs verified key-identical (4795 keys each) and JSON-parseable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
230 lines
9.1 KiB
TypeScript
230 lines
9.1 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { ChevronDown, GraduationCap, Loader2 } from 'lucide-react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
import remarkGfm from 'remark-gfm'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { EmptyState } from '@/components/ui/empty-state'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
|
|
type Tier = 'horizontal' | 'vertical' | 'modifier'
|
|
|
|
interface AtomMeta {
|
|
id: string
|
|
tier: Tier
|
|
title: string
|
|
description: string
|
|
active: boolean
|
|
}
|
|
|
|
const TIER_ORDER: Tier[] = ['horizontal', 'vertical', 'modifier']
|
|
|
|
const TIER_SECTION: Record<Tier, { title: string; blurb: string }> = {
|
|
horizontal: {
|
|
title: 'Kärnkompetens',
|
|
blurb: 'Svenska bokförings- och skatteregler som assistenten alltid har med sig.',
|
|
},
|
|
vertical: {
|
|
title: 'Anpassat för din bransch',
|
|
blurb: 'Branschkunskap som valts utifrån vad ditt företag gör. Vilande områden finns men används inte för dig.',
|
|
},
|
|
modifier: {
|
|
title: 'Din bolagssituation',
|
|
blurb: 'Särskilda regler för hur just ditt bolag är uppbyggt.',
|
|
},
|
|
}
|
|
|
|
// Mirror of the chat surface's markdown styling (components/agent/AgentChat.tsx),
|
|
// trimmed for a wider settings column.
|
|
const PROSE =
|
|
'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-h1:text-lg prose-h2:text-base prose-h3:text-sm 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-a:text-foreground prose-a:underline prose-a:underline-offset-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-table:my-2 prose-table:text-xs [&_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'
|
|
|
|
// The API returns errors either as a plain string (legacy/validation) or as
|
|
// the canonical { code, message } envelope — extract something renderable.
|
|
function apiErrorText(error: unknown): string | undefined {
|
|
if (typeof error === 'string') return error
|
|
if (error && typeof error === 'object' && 'message' in error) {
|
|
const m = (error as { message?: unknown }).message
|
|
return typeof m === 'string' ? m : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function AgentSkillsPanel() {
|
|
const { toast } = useToast()
|
|
|
|
const [atoms, setAtoms] = useState<AtomMeta[] | null>(null)
|
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
|
const [bodies, setBodies] = useState<Record<string, string>>({})
|
|
const [loadingBody, setLoadingBody] = useState<string | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
const res = await fetch('/api/agent/skills')
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte hämta kunskap', description: apiErrorText(json.error), variant: 'destructive' })
|
|
setAtoms([])
|
|
return
|
|
}
|
|
setAtoms(json.data as AtomMeta[])
|
|
}, [toast])
|
|
|
|
useEffect(() => { void load() }, [load])
|
|
|
|
const grouped = useMemo(() => {
|
|
const map: Record<Tier, AtomMeta[]> = { horizontal: [], vertical: [], modifier: [] }
|
|
for (const a of atoms ?? []) map[a.tier]?.push(a)
|
|
return map
|
|
}, [atoms])
|
|
|
|
const counts = useMemo(() => {
|
|
const total = atoms?.length ?? 0
|
|
const active = atoms?.filter((a) => a.active).length ?? 0
|
|
return { total, active }
|
|
}, [atoms])
|
|
|
|
async function toggle(atom: AtomMeta) {
|
|
if (expandedId === atom.id) {
|
|
setExpandedId(null)
|
|
return
|
|
}
|
|
setExpandedId(atom.id)
|
|
if (bodies[atom.id] !== undefined) return
|
|
setLoadingBody(atom.id)
|
|
try {
|
|
const res = await fetch(`/api/agent/skills?slug=${encodeURIComponent(atom.id)}`)
|
|
const json = await res.json()
|
|
if (!res.ok) {
|
|
toast({ title: 'Kunde inte läsa kunskapen', description: apiErrorText(json.error), variant: 'destructive' })
|
|
return
|
|
}
|
|
setBodies((prev) => ({ ...prev, [atom.id]: (json.data?.body as string) ?? '' }))
|
|
} finally {
|
|
setLoadingBody(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Vad min assistent kan</CardTitle>
|
|
<CardDescription>
|
|
Utöver vad den minns om ditt företag bygger assistenten på en uppsättning kunskapsområden
|
|
om svensk bokföring och skatt. Kärnkompetensen gäller alla; bransch- och bolagsanpassningen
|
|
väljs utifrån ditt företag. Klicka för att läsa hela kunskapen.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-8">
|
|
{atoms && (
|
|
<div className="text-xs text-muted-foreground tabular-nums">
|
|
{counts.total} kunskapsområden · {counts.active} aktiva för ditt företag
|
|
</div>
|
|
)}
|
|
|
|
{atoms === null && (
|
|
<div className="space-y-3">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-16 w-full rounded-lg" />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{atoms && atoms.length === 0 && (
|
|
<EmptyState
|
|
icon={GraduationCap}
|
|
title="Inga kunskapsområden ännu"
|
|
description="När din assistent har komponerats dyker dess kunskapsområden upp här."
|
|
/>
|
|
)}
|
|
|
|
{atoms && atoms.length > 0 &&
|
|
TIER_ORDER.filter((tier) => grouped[tier].length > 0).map((tier) => (
|
|
<section key={tier} className="space-y-3">
|
|
<div className="space-y-1">
|
|
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
|
{TIER_SECTION[tier].title}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground">{TIER_SECTION[tier].blurb}</p>
|
|
</div>
|
|
|
|
<ul className="space-y-2">
|
|
{grouped[tier].map((atom) => {
|
|
const isOpen = expandedId === atom.id
|
|
const isLoading = loadingBody === atom.id
|
|
const body = bodies[atom.id]
|
|
const dormant = !atom.active
|
|
return (
|
|
<li
|
|
key={atom.id}
|
|
className={`rounded-lg border border-border transition-colors ${
|
|
dormant ? 'bg-muted/30' : 'bg-card'
|
|
}`}
|
|
>
|
|
<button
|
|
onClick={() => toggle(atom)}
|
|
aria-expanded={isOpen}
|
|
className="flex w-full items-start gap-3 p-4 text-left"
|
|
>
|
|
<ChevronDown
|
|
className={`mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
|
|
isOpen ? '' : '-rotate-90'
|
|
}`}
|
|
/>
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-medium text-foreground">{atom.title}</span>
|
|
{tier !== 'horizontal' && (
|
|
<Badge variant={atom.active ? 'success' : 'secondary'}>
|
|
{atom.active ? 'Aktiv' : 'Vilande'}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{atom.description}</p>
|
|
</div>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="border-t border-border px-4 py-4 pl-11">
|
|
{isLoading && (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
Läser in…
|
|
</div>
|
|
)}
|
|
{!isLoading && body !== undefined && body.length > 0 && (
|
|
<div className={PROSE}>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
{!isLoading && body !== undefined && body.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Innehållet kunde inte läsas in.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</section>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|