Files
ApiCore/ApiCore-Frontend/src/components/voice/voice-capture-card.tsx
T
Nick SzaunigandClaude Opus 5.5 e0be687b7c feat: Aufnahmeseite und Liste der Sprachnotizen (T7 L5 Task 19)
/voice/new: großer Aufnahmeknopf mit Zeit, Hinweis bei 150 s, Stopp bei
180 s; eine automatisch beendete Aufnahme liegt sofort in der
Warteschlange des Geräts (Ruling 13); Abbrechen und Neu aufnehmen
fragen nach; Hilfe bei gesperrtem Mikrofon; Fokus bleibt bei jedem
Zustandswechsel an einem Bedienelement; Volk und Datum während der
Aufnahme gesperrt, ?date= auf die letzten 7 Tage begrenzt.
/voice: wartende Notizen des Geräts zuerst, dann die des Servers mit
Status, „Prüfen“ nur bei fertigen, Verwerfen mit Rückfrage.
Einstiege über Schnellanlage, Menü (nur mit voice-captures:use und
inspections:create), Volksseite und Durchsicht. Bei ausgeschalteter
Aufnahme verschwinden die Einstiege; jeder HTTP-Fehler der
Konfiguration zählt als aus, nur fehlendes Netz als unbekannt.
Fehlertexte gleich mit dem Backend (Test).

Gates: Frontend lint, typecheck, 2979 Tests, build.

Refs #27

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 11:49:56 +02:00

238 lines
6.9 KiB
TypeScript

"use client";
import { ReactNode, useState } from "react";
import Link from "next/link";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
Clock,
Loader2,
type LucideIcon,
} from "lucide-react";
import { VOICE_TOO_MANY_OPEN_HINT, voiceRejectionText } from "@/lib/offline/queue-texts";
import type { VoiceCaptureListItem } from "@/lib/voice/api";
import type { StoredVoiceNote } from "@/lib/voice/pending-voice";
import {
VOICE_WAITING_FOR_NETWORK,
describeVoiceCapture,
type VoiceStatusIcon,
type VoiceStatusText,
} from "@/lib/voice/status-texts";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { VoiceConfirmDialog } from "./voice-confirm-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const STATUS_ICONS: Record<VoiceStatusIcon, LucideIcon> = {
wait: Clock,
work: Loader2,
ready: CheckCircle2,
failed: AlertTriangle,
};
const TIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Berlin",
});
function timeText(iso: string): string {
return `${TIME_FORMAT.format(new Date(iso))} Uhr`.replace(", ", " · ");
}
function StatusLine({ status }: { status: VoiceStatusText }) {
const Icon = STATUS_ICONS[status.icon];
return (
<p className="flex items-center gap-2 text-sm font-medium">
<Icon
className={`size-4 shrink-0${status.icon === "work" ? " motion-safe:animate-spin" : ""}`}
aria-hidden
/>
{status.label}
</p>
);
}
interface DiscardMenuProps {
/** Names the menu for assistive tech, e.g. "B3, 14.10. · 09:12 Uhr". */
subject: string;
onDiscard(): void;
disabled?: boolean;
}
/** „Mehr“ with „Verwerfen“, which asks first: a discarded note cannot come back. */
function DiscardMenu({ subject, onDiscard, disabled }: DiscardMenuProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="touch"
className="min-h-14"
aria-label={`Mehr zu ${subject}`}
/>
}
>
Mehr
<ChevronDown aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-48">
<DropdownMenuItem
className="min-h-11 px-3"
disabled={disabled}
onClick={() => setConfirmOpen(true)}
>
Verwerfen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<VoiceConfirmDialog
open={confirmOpen}
title="Sprachnotiz verwerfen?"
description="Die Sprachnotiz wird gelöscht. Das lässt sich nicht rückgängig machen."
cancelLabel="Abbrechen"
confirmLabel="Verwerfen"
onCancel={() => setConfirmOpen(false)}
onConfirm={() => {
setConfirmOpen(false);
onDiscard();
}}
/>
</>
);
}
interface CardShellProps {
title: string;
time: string;
status: VoiceStatusText;
detail?: string | null;
children?: ReactNode;
}
function CardShell({ title, time, status, detail, children }: CardShellProps) {
return (
<Card>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<h2 className="font-heading text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground">{time}</p>
</div>
<StatusLine status={status} />
{detail ? <p className="text-sm text-muted-foreground">{detail}</p> : null}
{children ? <div className="flex flex-wrap gap-2 [&>*]:flex-1 sm:[&>*]:flex-none">{children}</div> : null}
</CardContent>
</Card>
);
}
interface LocalVoiceCardProps {
note: StoredVoiceNote;
colonyName: string;
/** Whether the device is online: a waiting note is then about to be sent. */
online: boolean;
onDiscard(): void;
}
/** What a recording on this device is doing, in words. */
function localStatus(note: StoredVoiceNote, online: boolean): { status: VoiceStatusText; detail: string | null } {
if (note.lastError) {
return {
status: { label: "Fehlgeschlagen", icon: "failed" },
detail: voiceRejectionText(note.lastError),
};
}
if (note.blockedBy === "TOO_MANY_OPEN") {
return {
status: { label: "Wartet auf freien Platz", icon: "wait" },
detail: VOICE_TOO_MANY_OPEN_HINT,
};
}
if (note.state === "uploading" || online) {
return { status: { label: "Wird gesendet", icon: "work" }, detail: null };
}
return { status: VOICE_WAITING_FOR_NETWORK, detail: null };
}
/** A recording that still waits on this device. */
export function LocalVoiceCard({ note, colonyName, online, onDiscard }: LocalVoiceCardProps) {
const time = timeText(note.createdAt);
const { status, detail } = localStatus(note, online);
const again = new URLSearchParams({ colonyId: note.colonyId, date: note.inspectionDate });
return (
<CardShell title={colonyName} time={time} status={status} detail={detail}>
{note.lastError ? (
<Button
size="touch"
className="min-h-14"
nativeButton={false}
render={<Link href={`/voice/new?${again.toString()}`} />}
>
Neu aufnehmen
</Button>
) : null}
<DiscardMenu subject={`${colonyName}, ${time}`} onDiscard={onDiscard} />
</CardShell>
);
}
interface VoiceCaptureCardProps {
item: VoiceCaptureListItem;
/** False when the operator switched the evaluation off. */
extractionEnabled?: boolean;
onRetry(): void;
onDiscard(): void;
busy?: boolean;
}
/** A note the server holds. */
export function VoiceCaptureCard({
item,
extractionEnabled = true,
onRetry,
onDiscard,
busy,
}: VoiceCaptureCardProps) {
const state = describeVoiceCapture(item, extractionEnabled);
const time = timeText(item.createdAt);
const reviewHref = `/inspections/new?colonyId=${encodeURIComponent(item.colonyId)}&voice=${encodeURIComponent(item.id)}`;
return (
<CardShell title={item.colonyName} time={time} status={state.status} detail={state.detail}>
{state.action !== "none" ? (
<Button
size="touch"
className="min-h-14"
nativeButton={false}
render={<Link href={reviewHref} />}
>
{state.action === "review" ? "Prüfen" : "Text ansehen"}
</Button>
) : null}
{state.canRetry ? (
<Button
type="button"
size="touch"
variant="outline"
className="min-h-14"
disabled={busy}
onClick={onRetry}
>
Nochmal versuchen
</Button>
) : null}
<DiscardMenu subject={`${item.colonyName}, ${time}`} onDiscard={onDiscard} disabled={busy} />
</CardShell>
);
}