feat(ui): webhooks as cards with events in words, calendar sync without env jargon

- Webhooks: one card per connection, events by their German label
  instead of keys like "product.stock_changed", state as icon + word,
  a one-sentence explanation of what a webhook is for, retry on errors.
- Calendar sync: the group id field says where to find it; the
  "not configured" note says who sets it up instead of listing
  environment variable names.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 12:15:54 +02:00
1 parent a21b1665e7
commit 549a75988e
2 files changed
+74 -75

No files matched your search

@@ -87,13 +87,18 @@ function SettingsForm({ settings }: { settings: CalendarSyncSettings }) {
<Label htmlFor="sync-enabled">Synchronisation aktiv</Label> <Label htmlFor="sync-enabled">Synchronisation aktiv</Label>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="sync-group-id">Objekt-ID der M365-Gruppe</Label> <Label htmlFor="sync-group-id">Kennung des Microsoft-365-Gruppenkalenders</Label>
<Input <Input
id="sync-group-id" id="sync-group-id"
value={groupId} value={groupId}
onChange={(event) => setGroupId(event.target.value)} onChange={(event) => setGroupId(event.target.value)}
placeholder="z. B. 3fa85f64-5717-4562-b3fc-2c963f66afa6" placeholder="z. B. 3fa85f64-5717-4562-b3fc-2c963f66afa6"
aria-describedby="sync-group-id-hint"
/> />
<p id="sync-group-id-hint" className="text-xs text-muted-foreground">
Steht im Microsoft-Entra-Admin-Center unter Gruppen → gewünschte Gruppe →
„Objekt-ID“. Bei Unklarheit die IT fragen.
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm font-medium">Welche Einträge übertragen werden</p> <p className="text-sm font-medium">Welche Einträge übertragen werden</p>
@@ -148,9 +153,9 @@ function StatusSection() {
<p className="text-muted-foreground">Lädt…</p> <p className="text-muted-foreground">Lädt…</p>
) : !status?.configured ? ( ) : !status?.configured ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Nicht konfiguriert - die SSO-Umgebungsvariablen (OIDC_ISSUER_URL/OIDC_CLIENT_ID/ Noch nicht eingerichtet. Das erledigt die Person, die den Server betreut: Microsoft-
OIDC_CLIENT_SECRET) fehlen, oder die App-Registrierung hat noch keine Anmeldung (SSO) einrichten und der App die Berechtigung „Calendars.ReadWrite“ geben
Application-Berechtigung „Calendars.ReadWrite“. (Details in der Betriebsanleitung, ARCHITECTURE.md Abschnitt 6.9).
</p> </p>
) : !canRunNow ? ( ) : !canRunNow ? (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -1,24 +1,22 @@
"use client"; "use client";
import { CircleCheck, CircleSlash, Webhook } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api"; import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api";
import { WEBHOOK_EVENT_LABELS } from "@/lib/webhooks/labels";
import { errorMessage } from "@/lib/forms/error-message";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { import { EmptyState } from "@/components/empty-state";
Table, import { QueryState } from "@/components/query-state";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CreateWebhookDialog } from "./create-webhook-dialog"; import { CreateWebhookDialog } from "./create-webhook-dialog";
import { EditWebhookDialog } from "./edit-webhook-dialog"; import { EditWebhookDialog } from "./edit-webhook-dialog";
/** One card per connection - events in words, not technical keys. */
export function WebhooksTab() { export function WebhooksTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: webhooks, isLoading, isError } = useQuery({ const webhooksQuery = useQuery({
queryKey: ["webhooks"], queryKey: ["webhooks"],
queryFn: fetchWebhooks, queryFn: fetchWebhooks,
}); });
@@ -29,76 +27,72 @@ export function WebhooksTab() {
void queryClient.invalidateQueries({ queryKey: ["webhooks"] }); void queryClient.invalidateQueries({ queryKey: ["webhooks"] });
toast.success("Webhook gelöscht"); toast.success("Webhook gelöscht");
}, },
onError: () => toast.error("Webhook konnte nicht gelöscht werden"), onError: (error) => toast.error(errorMessage(error, "Webhook konnte nicht gelöscht werden")),
}); });
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-muted-foreground">
Ein Webhook meldet einem anderen System sofort, wenn hier etwas passiert – zum Beispiel
eine neue Ernte. Nur nötig, wenn ein anderes Programm darauf wartet.
</p>
<div className="flex justify-end"> <div className="flex justify-end">
<CreateWebhookDialog /> <CreateWebhookDialog />
</div> </div>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} <QueryState
{isError ? ( query={webhooksQuery}
<p role="alert" className="text-sm text-destructive"> what="Webhooks"
Webhooks konnten nicht geladen werden. empty={<EmptyState icon={Webhook} title="Noch keine Webhooks angelegt" />}
</p> >
) : null} {(webhooks) => (
<ul className="flex flex-col gap-2">
{webhooks && webhooks.length === 0 ? ( {webhooks.map((webhook) => (
<p className="text-sm text-muted-foreground">Noch keine Webhooks angelegt.</p> <li
) : null} key={webhook.id}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start"
{webhooks && webhooks.length > 0 ? ( >
<div className="overflow-x-auto"> <div className="flex min-w-0 flex-1 flex-col gap-2">
<Table> <span className="break-all font-mono text-xs">{webhook.url}</span>
<TableHeader> <span
<TableRow> className={
<TableHead>URL</TableHead> webhook.active
<TableHead>Ereignisse</TableHead> ? "inline-flex items-center gap-1 text-xs font-medium"
<TableHead>Status</TableHead> : "inline-flex items-center gap-1 text-xs text-muted-foreground"
<TableHead className="text-right">Aktion</TableHead> }
</TableRow> >
</TableHeader> {webhook.active ? (
<TableBody> <CircleCheck className="size-3.5" aria-hidden />
{webhooks.map((webhook) => ( ) : (
<TableRow key={webhook.id}> <CircleSlash className="size-3.5" aria-hidden />
<TableCell className="max-w-64 truncate font-mono text-xs">{webhook.url}</TableCell> )}
<TableCell> {webhook.active ? "Aktiv" : "Pausiert"}
<div className="flex flex-wrap gap-1"> </span>
{webhook.events.map((event) => ( <div className="flex flex-wrap gap-1">
<Badge key={event} variant="secondary"> {webhook.events.map((event) => (
{event} <Badge key={event} variant="secondary">
</Badge> {WEBHOOK_EVENT_LABELS[event] ?? event}
))} </Badge>
</div> ))}
</TableCell> </div>
<TableCell> </div>
<Badge variant={webhook.active ? "default" : "outline"}> <div className="flex flex-wrap gap-2">
{webhook.active ? "Aktiv" : "Inaktiv"} <EditWebhookDialog webhook={webhook} />
</Badge> <ConfirmDialog
</TableCell> triggerLabel="Löschen"
<TableCell className="text-right"> triggerDisabled={deleteMutation.isPending}
<div className="flex justify-end gap-2"> title="Webhook löschen?"
<EditWebhookDialog webhook={webhook} /> description={`Der Webhook für „${webhook.url}“ wird endgültig gelöscht. Externe Systeme erhalten danach keine Ereignisse mehr über diese Verbindung, und der Vorgang kann nicht rückgängig gemacht werden.`}
<ConfirmDialog confirmLabel="Endgültig löschen"
triggerLabel="Löschen" isPending={deleteMutation.isPending}
triggerSize="sm" onConfirm={() => deleteMutation.mutate(webhook.id)}
triggerDisabled={deleteMutation.isPending} />
title="Webhook löschen?" </div>
description={`Der Webhook für „${webhook.url}“ wird endgültig gelöscht. Externe Systeme erhalten danach keine Ereignisse mehr über diese Verbindung, und der Vorgang kann nicht rückgängig gemacht werden.`} </li>
confirmLabel="Endgültig löschen" ))}
isPending={deleteMutation.isPending} </ul>
onConfirm={() => deleteMutation.mutate(webhook.id)} )}
/> </QueryState>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</div> </div>
); );
} }