since3-portal/modules/templates/ai/ui/assistant-demo.tsx
2026-10-05 08:23:02 +02:00

284 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState } from "react"
import { CheckIcon, CopyIcon, FileTextIcon, LoaderCircleIcon, MailIcon, RotateCcwIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import type { DemoInfo } from "../demo-info"
import type { AiAccess } from "../demos"
import type { Quote } from "../schemas"
import { readNdjson } from "./use-ai-chat"
type Mode = "email" | "quote"
type QuoteView = Quote & { number: string; date: Date }
const steps: Record<Mode, string[]> = {
email: ["Anfrage verstehen", "Passende Infos heraussuchen", "Antwort formulieren"],
quote: ["Anfrage analysieren", "Positionen ableiten", "Preise kalkulieren", "Angebot gestalten"],
}
const euro = new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
const day = new Intl.DateTimeFormat("de-DE", { dateStyle: "long" })
function quoteNumber(text: string, year: number) {
let hash = 0
for (const char of text) hash = (hash * 31 + char.charCodeAt(0)) | 0
return `A-${year}-${(Math.abs(hash) % 9000) + 1000}`
}
export function AssistantDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
const [inquiry, setInquiry] = useState(info.samples.inquiry)
const [mode, setMode] = useState<Mode>("email")
const [email, setEmail] = useState("")
const [quote, setQuote] = useState<QuoteView | null>(null)
const [pending, setPending] = useState(false)
const [step, setStep] = useState(0)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!pending) return
const timer = setInterval(() => setStep((s) => s + 1), 1600)
return () => clearInterval(timer)
}, [pending])
async function run(next: Mode) {
setMode(next)
setPending(true)
setStep(0)
setError(null)
try {
if (next === "email") {
setEmail("")
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task: "email", access, inquiry }),
})
await readNdjson(response, (event) => {
if (event.type === "error") throw new Error(event.message)
if (event.type === "text") setEmail((text) => text + event.text)
})
} else {
setQuote(null)
const response = await fetch("/api/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task: "quote", access, inquiry }),
})
const data = (await response.json().catch(() => null)) as { result?: Quote; error?: string } | null
if (!response.ok || !data?.result) throw new Error(data?.error ?? "Die KI ist gerade nicht erreichbar.")
const today = new Date()
setQuote({ ...data.result, number: quoteNumber(inquiry, today.getFullYear()), date: today })
}
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Die KI ist gerade nicht erreichbar.")
} finally {
setPending(false)
}
}
async function copy(text: string) {
await navigator.clipboard.writeText(text).catch(() => undefined)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const subjectLine = email.match(/^Betreff:\s*(.*)$/m)
const subject = subjectLine?.[1]?.trim() ?? ""
const body = subject ? email.replace(/^Betreff:.*\n?/m, "").replace(/^\n+/, "") : email
const showLoader = pending && (mode === "quote" || !email)
return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
<section className="flex flex-col rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
<div className="flex items-center justify-between">
<h2 className="font-medium">Kundenanfrage</h2>
<button
type="button"
onClick={() => setInquiry(info.samples.inquiry)}
className="flex cursor-pointer items-center gap-1.5 text-xs text-zinc-400 hover:text-white"
>
<RotateCcwIcon className="size-3.5" /> Beispiel
</button>
</div>
<p className="mt-1 text-sm text-zinc-500">Eigene Anfrage einfügen oder das Beispiel nutzen.</p>
<textarea
value={inquiry}
onChange={(event) => setInquiry(event.target.value)}
rows={10}
aria-label="Kundenanfrage"
className="mt-4 w-full flex-1 resize-none rounded-2xl border border-white/10 bg-black/30 p-4 text-sm leading-relaxed outline-none focus:border-t-accent"
/>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
disabled={pending || inquiry.trim().length < 10}
onClick={() => run("email")}
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl bg-t-accent px-4 py-3 text-sm font-medium text-t-accent-fg transition-opacity disabled:opacity-40"
>
<MailIcon className="size-4" /> Antwort schreiben
</button>
<button
type="button"
disabled={pending || inquiry.trim().length < 10}
onClick={() => run("quote")}
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-white/15 px-4 py-3 text-sm font-medium transition-colors hover:border-white/40 disabled:opacity-40"
>
<FileTextIcon className="size-4" /> Angebot erstellen
</button>
</div>
</section>
<section className="min-h-[34rem] rounded-[2rem] border border-white/10 bg-white/[0.03] p-6">
{error && <p className="mb-4 rounded-xl bg-red-500/10 px-4 py-3 text-sm text-red-300">{error}</p>}
{showLoader ? (
<ol className="mx-auto mt-16 max-w-xs space-y-3">
{steps[mode].map((label, i) => {
const current = Math.min(step, steps[mode].length - 1)
return (
<li key={label} className={cn("flex items-center gap-3 text-sm transition-colors", i <= current ? "text-zinc-100" : "text-zinc-600")}>
{i < current ? (
<CheckIcon className="size-4 text-t-accent" />
) : i === current ? (
<LoaderCircleIcon className="size-4 animate-spin text-t-accent" />
) : (
<span className="size-4 rounded-full border border-zinc-700" />
)}
{label}
</li>
)
})}
</ol>
) : mode === "email" && email ? (
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="overflow-hidden rounded-2xl bg-white text-zinc-900">
<header className="space-y-1 border-b border-zinc-200 px-6 py-4 text-sm">
<p><span className="text-zinc-500">Von:</span> Team {info.brand}</p>
<p><span className="text-zinc-500">Betreff:</span> <span className="font-medium">{subject || "…"}</span></p>
</header>
<div className="whitespace-pre-wrap px-6 py-5 text-[15px] leading-relaxed">
{body}
{pending && <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-900 align-middle" />}
</div>
{!pending && (
<footer className="flex justify-end border-t border-zinc-200 px-6 py-3">
<CopyButton copied={copied} onClick={() => copy(email)} />
</footer>
)}
</motion.article>
) : mode === "quote" && quote ? (
<QuoteDocument quote={quote} brand={info.brand} copied={copied} onCopy={copy} />
) : (
<div className="grid h-full min-h-[28rem] place-items-center text-center text-sm text-zinc-500">
<p>Wählen Sie links „Antwort schreiben“ oder „Angebot erstellen“.<br />Das Ergebnis erscheint hier – in wenigen Sekunden.</p>
</div>
)}
</section>
</div>
)
}
function CopyButton({ copied, onClick }: { copied: boolean; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex cursor-pointer items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-100">
{copied ? <CheckIcon className="size-4 text-emerald-600" /> : <CopyIcon className="size-4" />}
{copied ? "Kopiert" : "Kopieren"}
</button>
)
}
function QuoteDocument({ quote, brand, copied, onCopy }: { quote: QuoteView; brand: string; copied: boolean; onCopy: (text: string) => void }) {
const net = quote.positionen.reduce((sum, item) => sum + item.menge * item.einzelpreis, 0)
const vat = net * 0.19
const validUntil = new Date(quote.date.getTime() + Math.max(1, quote.gueltig_tage) * 86_400_000)
const plain = [
`Angebot ${quote.number} – ${quote.betreff}`,
"",
quote.anrede,
quote.einleitung,
"",
...quote.positionen.map((item, i) => `${i + 1}. ${item.titel} – ${item.menge} ${item.einheit} × ${euro.format(item.einzelpreis)} = ${euro.format(item.menge * item.einzelpreis)}`),
"",
`Netto: ${euro.format(net)} · MwSt. 19 %: ${euro.format(vat)} · Gesamt: ${euro.format(net + vat)}`,
...quote.hinweise.map((note) => `– ${note}`),
"",
quote.abschluss,
].join("\n")
return (
<div className="space-y-4">
<AnimatePresence>
{quote.rueckfragen.length > 0 && (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 text-sm text-amber-100">
<p className="font-medium">Vor dem Versand klären (nur intern)</p>
<ul className="mt-2 list-disc space-y-1 pl-5 text-amber-100/80">
{quote.rueckfragen.map((question) => (
<li key={question}>{question}</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
<motion.article initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="rounded-2xl bg-white p-6 text-zinc-900 md:p-8">
<header className="flex flex-wrap items-start justify-between gap-4 border-b border-zinc-200 pb-6">
<div>
<p className="text-xl font-semibold">{brand}</p>
<p className="text-sm text-zinc-500">Angebot {quote.number}</p>
</div>
<p className="text-sm text-zinc-500">{day.format(quote.date)}</p>
</header>
<h3 className="mt-6 text-lg font-semibold">{quote.betreff}</h3>
<p className="mt-4 text-sm">{quote.anrede}</p>
<p className="mt-2 text-sm leading-relaxed text-zinc-700">{quote.einleitung}</p>
<div className="mt-6 overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead>
<tr className="border-b border-zinc-200 text-left text-xs uppercase tracking-wider text-zinc-500">
<th className="py-2 pr-2 font-medium">Pos.</th>
<th className="py-2 pr-2 font-medium">Leistung</th>
<th className="py-2 pr-2 text-right font-medium">Menge</th>
<th className="py-2 pr-2 text-right font-medium">Einzelpreis</th>
<th className="py-2 text-right font-medium">Gesamt</th>
</tr>
</thead>
<tbody>
{quote.positionen.map((item, i) => (
<tr key={i} className="border-b border-zinc-100 align-top">
<td className="py-3 pr-2 text-zinc-500">{i + 1}</td>
<td className="py-3 pr-2">
<p className="font-medium">{item.titel}</p>
<p className="text-xs text-zinc-500">{item.beschreibung}</p>
</td>
<td className="py-3 pr-2 text-right whitespace-nowrap">{item.menge.toLocaleString("de-DE")} {item.einheit}</td>
<td className="py-3 pr-2 text-right whitespace-nowrap">{euro.format(item.einzelpreis)}</td>
<td className="py-3 text-right font-medium whitespace-nowrap">{euro.format(item.menge * item.einzelpreis)}</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="mt-4 ml-auto w-full max-w-xs space-y-1 text-sm">
<div className="flex justify-between"><dt className="text-zinc-500">Netto</dt><dd>{euro.format(net)}</dd></div>
<div className="flex justify-between"><dt className="text-zinc-500">MwSt. 19 %</dt><dd>{euro.format(vat)}</dd></div>
<div className="flex justify-between border-t border-zinc-200 pt-2 text-base font-semibold"><dt>Gesamt</dt><dd>{euro.format(net + vat)}</dd></div>
</dl>
{quote.hinweise.length > 0 && (
<ul className="mt-6 space-y-1 text-xs text-zinc-500">
{quote.hinweise.map((note) => (
<li key={note}>– {note}</li>
))}
</ul>
)}
<p className="mt-6 text-sm leading-relaxed text-zinc-700">{quote.abschluss}</p>
<footer className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 pt-4 text-xs text-zinc-500">
<span>Gültig bis {day.format(validUntil)}</span>
<CopyButton copied={copied} onClick={() => onCopy(plain)} />
</footer>
</motion.article>
</div>
)
}