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:
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user