UI-volledig voor de Claude vraag-antwoord-flow (M11). Bel-icon links van avatar in NavBar; klik opent slide-over rechts met openstaande vragen; klik op een vraag opent een modal voor antwoord. Story-assignee = current user krijgt visuele "voor jou"-emphase met primary-container accent en error-color badge-ring. Bestanden: - stores/notifications-store.ts — Zustand store met init/upsert/remove + openCount/forYouCount selectors (vereenvoudigd vs solo-store: geen pendingOps, geen optimistic-echo-onderdrukking) - lib/realtime/use-notifications-realtime.ts — EventSource hook met state- event en message-event handling, exponential-backoff reconnect, Page Visibility pause-resume - components/notifications/notifications-bridge.tsx — Server Component die initial open-questions fetcht via productAccessFilter - components/notifications/notifications-realtime-mount.tsx — tiny client island dat de store hydrateert + de hook activeert - components/notifications/notifications-sheet.tsx — shadcn Sheet met item- lijst, "voor jou"-accent voor assignee-vragen, lege staat - components/notifications/answer-modal.tsx — Dialog met options-radio of free-text Textarea (max 4000), char-counter, demo-blok via Tooltip; bij succes optimistisch remove + sheet blijft open zodat meerdere vragen achter elkaar te beantwoorden zijn - components/shared/notifications-bell.tsx — Bell-icon met badge (count >9 → "9+"), ring-accent als forYouCount > 0, ARIA-label voor screenreaders Wiring: - components/shared/nav-bar.tsx — <NotificationsBell /> rechts naast <UserMenu> - app/(app)/layout.tsx — <NotificationsBridge /> naast <SoloRealtimeBridge />, user.id (server-side) als prop base-ui-aanpassingen: SheetTrigger/TooltipTrigger gebruiken render-prop ipv asChild (geen Radix). Quality gates: lint 0 errors, tsc clean, vitest 146/146, npm run build groen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
157 lines
5.1 KiB
TypeScript
157 lines
5.1 KiB
TypeScript
'use client'
|
|
|
|
// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11).
|
|
//
|
|
// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de
|
|
// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via
|
|
// useTransition; bij succes wordt de vraag uit de store verwijderd
|
|
// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit
|
|
// disabled met tooltip.
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import Link from 'next/link'
|
|
import { ExternalLink } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import { answerQuestion } from '@/actions/questions'
|
|
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
|
|
|
const MAX_ANSWER_CHARS = 4000
|
|
|
|
interface AnswerModalProps {
|
|
question: NotificationQuestion | null
|
|
isDemo: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
|
const [answer, setAnswer] = useState('')
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
if (!question) return null
|
|
|
|
const charsLeft = MAX_ANSWER_CHARS - answer.length
|
|
const tooLong = charsLeft < 0
|
|
const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong
|
|
|
|
function submit(text: string) {
|
|
if (!question) return
|
|
if (isDemo) {
|
|
toast.error('Niet beschikbaar in demo-modus')
|
|
return
|
|
}
|
|
startTransition(async () => {
|
|
const res = await answerQuestion(question.id, text)
|
|
if (!res.ok) {
|
|
toast.error(res.error)
|
|
return
|
|
}
|
|
// Optimistisch verwijderen — SSE-event komt anders later met dezelfde
|
|
// remove en kost een extra render
|
|
useNotificationsStore.getState().remove(question.id)
|
|
toast.success('Antwoord verstuurd')
|
|
setAnswer('')
|
|
onClose()
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Dialog open={!!question} onOpenChange={(open) => !open && onClose()}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Beantwoord Claude</DialogTitle>
|
|
<DialogDescription>
|
|
<span className="font-mono">{question.story_code ?? 'story'}</span>
|
|
{' — '}
|
|
{question.story_title}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<Link
|
|
href={`/products/${question.product_id}/sprint`}
|
|
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
<span>Open in Sprint</span>
|
|
</Link>
|
|
|
|
<div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap">
|
|
{question.question}
|
|
</div>
|
|
|
|
{question.options && question.options.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
|
|
<div className="flex flex-col gap-2">
|
|
{question.options.map((opt) => (
|
|
<Button
|
|
key={opt}
|
|
type="button"
|
|
variant="outline"
|
|
className="justify-start"
|
|
disabled={isDemo || pending}
|
|
onClick={() => submit(opt)}
|
|
>
|
|
{opt}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<Textarea
|
|
value={answer}
|
|
onChange={(e) => setAnswer(e.target.value)}
|
|
placeholder="Typ je antwoord…"
|
|
rows={5}
|
|
maxLength={MAX_ANSWER_CHARS}
|
|
readOnly={isDemo}
|
|
aria-label="Antwoord op Claude's vraag"
|
|
/>
|
|
<div
|
|
className={
|
|
tooLong
|
|
? 'text-error text-right text-xs'
|
|
: 'text-muted-foreground text-right text-xs'
|
|
}
|
|
>
|
|
{charsLeft} tekens over
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={onClose} disabled={pending}>
|
|
Annuleren
|
|
</Button>
|
|
{(!question.options || question.options.length === 0) && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger render={<span className="inline-flex" />}>
|
|
<Button
|
|
onClick={() => submit(answer)}
|
|
disabled={submitDisabled}
|
|
>
|
|
{pending ? 'Bezig…' : 'Verstuur'}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
{isDemo && (
|
|
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|