/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]>
238 lines
6.9 KiB
TypeScript
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>
|
|
);
|
|
}
|