Files
breakpilot-compliance/admin-compliance/components/sdk/ComplianceAdvisorWidget.tsx
T
Benjamin Admin e8ea179228 feat(advisor): topic threads, per-question delete/copy, fullscreen
Adds case management to the Compliance Advisor widget.

- topic threads: cases group into threads; the left menu shows each
  thread's first question as the Thema with expandable follow-ups.
  Send = follow-up to the active thread (carries the thread's prior Q&A
  as history for contextual answers); "+" starts a new topic.
- delete: a trash action per question (menu + stacked view).
- copy: single Q&A (question + answer + evidence + footnotes) or a whole
  thread, as Markdown to the clipboard (pure formatters in copy.ts).
- fullscreen: compact -> panel -> fullscreen view.
- route.ts consumes an optional bounded `history` so follow-ups are
  contextual for both the widget and the workspace consumer.

Tests: copy formatter unit tests + Playwright specs (threads/new-topic,
delete, fullscreen, copy affordance). No deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 18:51:17 +02:00

215 lines
7.9 KiB
TypeScript

'use client'
import { useCallback, useState } from 'react'
import {
Check,
Expand,
Loader2,
Mail,
Maximize2,
MessagesSquare,
Minimize2,
Plus,
Send,
Shrink,
Square,
X,
} from 'lucide-react'
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
import { useAdvisorCase } from './advisor/useAdvisorCase'
import { useAdvisorEmail } from './advisor/useAdvisorEmail'
interface ComplianceAdvisorWidgetProps {
currentStep?: string
}
type Country = 'DE' | 'AT' | 'CH' | 'EU'
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
type View = 'compact' | 'panel' | 'fullscreen'
const SIZE: Record<View, string> = {
compact: 'bottom-6 right-6 h-[560px] w-[420px] rounded-2xl',
panel: 'bottom-6 right-6 h-[85vh] w-[960px] rounded-2xl',
fullscreen: 'inset-0 h-full w-full',
}
/**
* Compliance Advisor — a floating Case Workspace on every SDK page (compact / panel / fullscreen).
* Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text.
* See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract.
*/
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [view, setView] = useState<View>('compact')
const [inputValue, setInputValue] = useState('')
const [country, setCountry] = useState<Country>('DE')
const { cases, threads, busy, activeCaseId, ask, newTopic, selectContext, selectCase, remove, stop } =
useAdvisorCase({ currentStep, country })
const email = useAdvisorEmail(cases, country, currentStep)
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
const expanded = view !== 'compact'
const submit = useCallback(
(q: string) => {
if (!q.trim() || busy) return
setInputValue('')
ask(q)
},
[busy, ask],
)
const submitNewTopic = useCallback(
(q: string) => {
if (!q.trim() || busy) return
setInputValue('')
newTopic(q)
},
[busy, newTopic],
)
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
submit(inputValue)
}
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-[5.5rem] z-50 flex h-14 w-14 items-center justify-center rounded-full bg-indigo-600 text-white shadow-lg transition-all duration-200 hover:scale-110 hover:bg-indigo-700"
aria-label="Compliance Advisor oeffnen"
>
<MessagesSquare className="h-6 w-6" />
</button>
)
}
const headRound = view === 'fullscreen' ? '' : 'rounded-t-2xl'
const footRound = view === 'fullscreen' ? '' : 'rounded-b-2xl'
return (
<div
className={`fixed z-50 flex max-h-screen flex-col border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${SIZE[view]}`}
>
<div className={`flex items-center justify-between bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white ${headRound}`}>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<MessagesSquare className="h-5 w-5" />
</div>
<div>
<div className="text-sm font-semibold">Compliance Advisor</div>
<div className="mt-0.5 flex items-center gap-1">
{COUNTRIES.map((c) => (
<button
key={c}
onClick={() => setCountry(c)}
className={`rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors ${
country === c ? 'bg-white text-indigo-700' : 'bg-white/15 text-white/80 hover:bg-white/25'
}`}
>
{c}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center gap-1">
{cases.length > 0 && (
<button
onClick={email.send}
disabled={email.sending}
className={`text-white/80 transition-colors hover:text-white ${email.sent ? 'text-green-300' : ''}`}
title={email.sent ? 'Email gesendet!' : 'Beratungsprotokoll als Email senden'}
aria-label="Als Email an DSB senden"
>
{email.sent ? <Check className="h-5 w-5" /> : email.sending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Mail className="h-5 w-5" />}
</button>
)}
{view !== 'fullscreen' && (
<button
onClick={() => setView((v) => (v === 'compact' ? 'panel' : 'compact'))}
className="text-white/80 transition-colors hover:text-white"
aria-label={view === 'compact' ? 'Vergroessern' : 'Verkleinern'}
>
{view === 'compact' ? <Maximize2 className="h-5 w-5" /> : <Minimize2 className="h-5 w-5" />}
</button>
)}
<button
onClick={() => setView((v) => (v === 'fullscreen' ? 'panel' : 'fullscreen'))}
className="text-white/80 transition-colors hover:text-white"
aria-label={view === 'fullscreen' ? 'Vollbild verlassen' : 'Vollbild'}
title={view === 'fullscreen' ? 'Vollbild verlassen' : 'Vollbild'}
>
{view === 'fullscreen' ? <Shrink className="h-5 w-5" /> : <Expand className="h-5 w-5" />}
</button>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 transition-colors hover:text-white"
aria-label="Schliessen"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<EvidenceWorkspace
cases={cases}
threads={threads}
expanded={expanded}
busy={busy}
activeCaseId={activeCaseId}
exampleQuestions={exampleQuestions}
onExample={submit}
onSelectContext={selectContext}
onSelectCase={selectCase}
onRemove={remove}
/>
<div className={`border-t border-gray-200 bg-white p-3 ${footRound}`}>
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder={cases.length > 0 ? 'Folgefrage eingeben...' : 'Frage eingeben...'}
disabled={busy}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
/>
{busy ? (
<button onClick={stop} className="rounded-lg bg-red-500 px-4 py-2 text-white transition-colors hover:bg-red-600" title="Abbrechen">
<Square className="h-5 w-5" />
</button>
) : (
<>
{cases.length > 0 && (
<button
onClick={() => submitNewTopic(inputValue)}
disabled={!inputValue.trim()}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
title="Als neues Thema stellen"
aria-label="Neues Thema"
>
<Plus className="h-5 w-5" />
</button>
)}
<button
onClick={() => submit(inputValue)}
disabled={!inputValue.trim()}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
title={cases.length > 0 ? 'Folgefrage senden' : 'Frage senden'}
>
<Send className="h-5 w-5" />
</button>
</>
)}
</div>
</div>
</div>
)
}