Files
accounted/components/settings/AgentMemoryPanel.tsx
T
Jakob Wennberg 6d9846b1e7 feat(settings): Fönster redesign - flat rows, ? help, dirty save bar (#1193)
* feat(settings): Fönster redesign - flat rows, help behind ?, dirty save bar

Founder-approved concept (2026-07-25) applied to the whole settings
surface, modal and full-page variants alike:

- New primitives in components/settings/SettingsRows.tsx: section header
  (serif title + one-line intro), eyebrow groups, hairline label/control
  rows, flat inputs/selects/textareas, segmented control, animated
  reveal for gated settings, danger zone.
- Every static explanation paragraph moved behind a "?" popover
  (HelpPopover) at row or group level; dynamic status stays visible.
- Modal chrome: company kicker over serif title, fixed 920x680 window.
- SettingsFormWrapper: save is a sticky bar that appears only when the
  form is dirty; collapses to zero height when clean.
- All 11 sections converted (Konto, Abonnemang, Företag, Bokföring,
  Skatt, Löner, Fakturering, Mallar, Bank incl. Enable Banking-panel,
  Assistenten, API) with handlers, validation, role/entitlement/sandbox
  gates and i18n keys preserved; checkboxes became switches, cards
  dissolved into groups.
- Fix: Escape with an open help popover closed the whole settings
  modal; it now closes the popover first.
- New i18n keys: settings_intro.*, group labels, wrapper_unsaved
  (sv+en).

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

* fix(settings): founder feedback round 1 on the Fönster redesign

- Abonnemang paying state: status and manage split into two rows so the
  row no longer wraps awkwardly; the included-features list now shows
  for paying companies too.
- Logos where the counterpart has one: BankID mark on the security row
  and on the Koppla BankID button, Skatteverket mark on the connection
  rows.
- Buttons are unmistakably buttons: 27 text-labeled row actions went
  from ghost to outline pills; icon-only actions stay quiet.
- The agent-knowledge view (Regler & profil: Dina regler, Momsprofil,
  Konventioner) converted to the flat row language; it was the last
  old-style surface inside settings. Descriptions moved behind "?",
  rules render as hairline rows, the per-row "Regel" chip demoted to
  muted text.

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

* fix(settings): address review-bot findings on the Fönster redesign

- SettingsFormWrapper marks the form dirty on switch clicks too: Radix
  Switch is a button and fires no input event, so switch-only changes
  (f-skatt, KU, ROT/RUT, OSS...) never revealed the save bar.
- i18n: the migrated hardcoded strings got keys in both locales
  (fiscal-period start date/range/months, security set-password trio);
  dates in ApiKeysPanel/OAuthClientsPanel/CalendarFeedSettings now pass
  the active locale to formatDateLong.
- A11y: member remove/revoke buttons and the invite role select got
  correct accessible names; BankNameCombobox accepts aria-label wired
  from its row; the pinned-fact icon exposes role img.
- BankIdSettings: explicit Avbryt under the QR block so a cancelled
  BankID flow cannot strand isLinking.
- VoucherSeriesManager: clear the skeleton when no company is resolved.

Verified end to end in sandbox: switch-only dirty bar, PUT /api/settings
200 for text and switch saves, persistence across hard reload.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 23:55:08 +02:00

419 lines
16 KiB
TypeScript

'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Brain, Loader2, Pin, PinOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsGroup,
SettingsReveal,
SettingsRow,
SettingsRowEnd,
SettingsRowNote,
SettingsSeg,
SettingsSelect,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { cn, formatDateLong } from '@/lib/utils'
type Kind = 'fact' | 'preference' | 'pattern' | 'correction'
type Source = 'composer' | 'user_taught' | 'agent_learned' | 'derived'
interface AgentMemoryRow {
id: string
kind: Kind
content: string
source: Source
source_ref: string | null
relevance_score: number
is_pinned: boolean
is_active: boolean
last_accessed_at: string | null
created_at: string
updated_at: string
}
const KIND_LABEL: Record<Kind, string> = {
fact: 'Fakta',
preference: 'Preferens',
pattern: 'Mönster',
correction: 'Korrigering',
}
const SOURCE_LABEL: Record<Source, string> = {
composer: 'Inläst vid uppstart',
user_taught: 'Du lärde mig',
agent_learned: 'Jag noterade',
derived: 'Härlett',
}
const KIND_FILTER: { value: 'all' | Kind; label: string }[] = [
{ value: 'all', label: 'Alla' },
{ value: 'fact', label: 'Fakta' },
{ value: 'preference', label: 'Preferenser' },
{ value: 'pattern', label: 'Mönster' },
{ value: 'correction', label: 'Korrigeringar' },
]
// 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 AgentMemoryPanel() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [rows, setRows] = useState<AgentMemoryRow[] | null>(null)
const [includeDismissed, setIncludeDismissed] = useState(false)
const [kindFilter, setKindFilter] = useState<'all' | Kind>('all')
const [busyId, setBusyId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [newContent, setNewContent] = useState('')
const [newKind, setNewKind] = useState<Kind>('fact')
const [adding, setAdding] = useState(false)
const load = useCallback(async () => {
const params = new URLSearchParams()
if (includeDismissed) params.set('include_dismissed', 'true')
if (kindFilter !== 'all') params.set('kind', kindFilter)
const res = await fetch(`/api/agent/memory?${params.toString()}`)
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte hämta minne', description: apiErrorText(json.error), variant: 'destructive' })
setRows([])
return
}
setRows(json.data as AgentMemoryRow[])
}, [includeDismissed, kindFilter, toast])
useEffect(() => { void load() }, [load])
const counts = useMemo(() => {
const active = rows?.filter((r) => r.is_active).length ?? 0
const pinned = rows?.filter((r) => r.is_active && r.is_pinned).length ?? 0
const dismissed = rows?.filter((r) => !r.is_active).length ?? 0
return { active, pinned, dismissed }
}, [rows])
async function patch(id: string, body: Partial<Pick<AgentMemoryRow, 'content' | 'is_pinned' | 'is_active'>>) {
setBusyId(id)
try {
const res = await fetch(`/api/agent/memory/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte uppdatera', description: apiErrorText(json.error), variant: 'destructive' })
return
}
setRows((prev) => prev?.map((r) => (r.id === id ? (json.data as AgentMemoryRow) : r)) ?? null)
} finally {
setBusyId(null)
}
}
async function addMemory() {
if (newContent.trim().length < 2) return
setAdding(true)
try {
const res = await fetch('/api/agent/memory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent.trim(), kind: newKind }),
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte spara minne', description: apiErrorText(json.error), variant: 'destructive' })
return
}
setRows((prev) => [json.data as AgentMemoryRow, ...(prev ?? [])])
setNewContent('')
setNewKind('fact')
setShowAdd(false)
toast({ title: 'Minne sparat' })
} finally {
setAdding(false)
}
}
function startEdit(row: AgentMemoryRow) {
setEditingId(row.id)
setEditDraft(row.content)
}
async function saveEdit(row: AgentMemoryRow) {
const next = editDraft.trim()
if (next.length < 2 || next === row.content) {
setEditingId(null)
return
}
await patch(row.id, { content: next })
setEditingId(null)
}
// The view wrapper in AssistantSettingsContent already provides the gap
// under the segmented control, so the group starts flush (pt-0).
return (
<SettingsGroup
label="Vad min assistent kommer ihåg"
help={
<>
Bokföringsassistenten använder dessa anteckningar för att ge dig rätt råd. Fäst det som
alltid ska vara med, redigera fel, eller dölj det som inte längre stämmer. Upp till 30
minnen ingår i samtal per tur.
</>
}
className="pt-0 first:pt-0"
>
{/* Toolbar row: kind filter + the add-memory entry point. */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-1 py-3">
<SettingsSeg
value={kindFilter}
onChange={setKindFilter}
options={KIND_FILTER}
aria-label="Filtrera minnen"
/>
{canWrite && (
<Button
variant="outline"
size="sm"
onClick={() => setShowAdd((v) => !v)}
disabled={adding}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till minne
</Button>
)}
</div>
{canWrite && (
<SettingsReveal open={showAdd}>
<div className="space-y-3 py-3">
<SettingsTextarea
value={newContent}
onChange={(e) => setNewContent(e.target.value)}
placeholder="T.ex. Vi använder Stripe för B2C-betalningar; utbetalningar landar på 1930 var måndag."
rows={3}
maxLength={2000}
aria-label="Nytt minne"
className="w-full border-border"
/>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Typ</span>
<SettingsSelect
value={newKind}
onChange={(e) => setNewKind(e.target.value as Kind)}
aria-label="Typ"
>
{(Object.keys(KIND_LABEL) as Kind[]).map((k) => (
<option key={k} value={k}>{KIND_LABEL[k]}</option>
))}
</SettingsSelect>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setNewContent('') }}>
Avbryt
</Button>
<Button size="sm" onClick={addMemory} disabled={adding || newContent.trim().length < 2}>
{adding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Spara
</Button>
</div>
</div>
</div>
</SettingsReveal>
)}
<SettingsRow label="Visa dolda">
<SettingsRowEnd>
<Switch
checked={includeDismissed}
onCheckedChange={setIncludeDismissed}
aria-label="Visa dolda"
/>
</SettingsRowEnd>
</SettingsRow>
{/* Dynamic status stays visible; the static "how it's used" copy lives
in the group help above. */}
{rows && (
<p className="px-1 pt-3">
<SettingsRowNote className="tabular-nums">
{counts.active} aktiva · {counts.pinned} fästa
{includeDismissed && counts.dismissed > 0 ? ` · ${counts.dismissed} dolda` : ''}
</SettingsRowNote>
</p>
)}
{rows === null && (
<div className="space-y-3 pt-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
)}
{rows && rows.length === 0 && (
<EmptyState
icon={Brain}
title="Inga minnen ännu"
description="När du lär assistenten saker (eller när den noterar saker själv med ditt godkännande) dyker de upp här."
/>
)}
{rows && rows.length > 0 && (
<ul>
{rows.map((row) => {
const isEditing = editingId === row.id
const isBusy = busyId === row.id
const dimmed = !row.is_active
return (
<li
key={row.id}
className={cn(
'border-b border-border px-1 py-3 transition-colors',
dimmed && 'opacity-70',
)}
>
<div className="flex items-start gap-3">
{canWrite && row.is_active ? (
<button
onClick={() => patch(row.id, { is_pinned: !row.is_pinned })}
disabled={isBusy}
className={cn(
'mt-0.5 shrink-0 rounded-md p-1.5 transition-colors duration-150',
row.is_pinned
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
aria-label={row.is_pinned ? 'Lossa' : 'Fäst'}
title={row.is_pinned ? 'Lossa' : 'Fäst: säkerställer att minnet alltid skickas med'}
>
{row.is_pinned ? <Pin className="h-4 w-4 fill-current" /> : <PinOff className="h-4 w-4" />}
</button>
) : (
<div className="mt-0.5 shrink-0 p-1.5">
{row.is_pinned && <Pin className="h-4 w-4 fill-current text-foreground" />}
</div>
)}
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-muted-foreground">
{KIND_LABEL[row.kind]} · {SOURCE_LABEL[row.source]}
</span>
{dimmed && <Badge variant="secondary">Dold</Badge>}
</div>
{isEditing ? (
<SettingsTextarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
rows={3}
maxLength={2000}
autoFocus
aria-label="Redigera minne"
className="w-full border-border"
/>
) : (
<p className="whitespace-pre-wrap break-words text-sm text-foreground">{row.content}</p>
)}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="text-[11px] text-muted-foreground tabular-nums">
Skapad {formatDateLong(row.created_at)}
{row.updated_at !== row.created_at && ` · uppdaterad ${formatDateLong(row.updated_at)}`}
</p>
{canWrite && (
<div className="flex items-center gap-1">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => setEditingId(null)}
disabled={isBusy}
>
<X className="h-4 w-4" />
<span className="sr-only">Avbryt</span>
</Button>
<Button
size="sm"
onClick={() => saveEdit(row)}
disabled={isBusy || editDraft.trim().length < 2}
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Spara'}
</Button>
</>
) : row.is_active ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => startEdit(row)}
disabled={isBusy}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
Redigera
</Button>
<Button
variant="outline"
size="sm"
onClick={() => patch(row.id, { is_active: false })}
disabled={isBusy}
>
{isBusy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
Dölj
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => patch(row.id, { is_active: true })}
disabled={isBusy}
>
{isBusy ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-1 h-3.5 w-3.5" />
)}
Återställ
</Button>
)}
</div>
)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</SettingsGroup>
)
}