377 lines
11 KiB
TypeScript
377 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import { useMutation, useQuery } from "convex/react";
|
|
import { FunctionReturnType } from "convex/server";
|
|
import { Id } from "@/convex/_generated/dataModel";
|
|
|
|
import { api } from "@/convex/_generated/api";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
type BlacklistResult = FunctionReturnType<typeof api.blacklist.list>;
|
|
type BlacklistEntry = NonNullable<BlacklistResult>[number];
|
|
|
|
type BlacklistType =
|
|
| "domain"
|
|
| "email"
|
|
| "phone"
|
|
| "company"
|
|
| "google_place_id";
|
|
|
|
const blacklistTypeOptions: BlacklistType[] = [
|
|
"domain",
|
|
"email",
|
|
"phone",
|
|
"company",
|
|
"google_place_id",
|
|
];
|
|
|
|
function labelForType(type: BlacklistType): string {
|
|
if (type === "google_place_id") {
|
|
return "Google Place ID";
|
|
}
|
|
|
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
}
|
|
|
|
function formatDate(value: number): string {
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
dateStyle: "short",
|
|
timeStyle: "short",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
export function BlacklistManager() {
|
|
const entries = useQuery(api.blacklist.list, { limit: 150 }) as
|
|
| BlacklistResult
|
|
| undefined;
|
|
const createEntry = useMutation(api.blacklist.create);
|
|
const updateEntry = useMutation(api.blacklist.update);
|
|
const removeEntry = useMutation(api.blacklist.remove);
|
|
|
|
const [type, setType] = useState<BlacklistType>("domain");
|
|
const [value, setValue] = useState("");
|
|
const [note, setNote] = useState("");
|
|
const [rowBusyId, setRowBusyId] = useState<Id<"blacklistEntries"> | null>(null);
|
|
const [formBusy, setFormBusy] = useState(false);
|
|
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
const [statusError, setStatusError] = useState<string | null>(null);
|
|
|
|
const entriesSorted = useMemo(() => {
|
|
if (!entries) {
|
|
return [];
|
|
}
|
|
|
|
return [...entries].sort((a, b) => b.createdAt - a.createdAt);
|
|
}, [entries]);
|
|
|
|
const submitNew = async () => {
|
|
if (!value.trim()) {
|
|
setStatusError("Bitte ein Sperrwert eintragen.");
|
|
return;
|
|
}
|
|
|
|
setFormBusy(true);
|
|
setStatusError(null);
|
|
setStatusMessage(null);
|
|
|
|
try {
|
|
await createEntry({
|
|
type,
|
|
value: value.trim(),
|
|
note: note.trim().length > 0 ? note.trim() : undefined,
|
|
});
|
|
setValue("");
|
|
setNote("");
|
|
setStatusMessage("Eintrag hinzugefügt.");
|
|
} catch {
|
|
setStatusError("Eintrag konnte nicht erstellt werden.");
|
|
} finally {
|
|
setFormBusy(false);
|
|
}
|
|
};
|
|
|
|
const remove = async (id: Id<"blacklistEntries">) => {
|
|
setRowBusyId(id);
|
|
setStatusError(null);
|
|
setStatusMessage(null);
|
|
|
|
try {
|
|
await removeEntry({ id });
|
|
setStatusMessage("Eintrag gelöscht.");
|
|
} catch {
|
|
setStatusError("Eintrag konnte nicht entfernt werden.");
|
|
} finally {
|
|
setRowBusyId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-4 px-4 py-5 sm:px-6 lg:px-8">
|
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-2">
|
|
<p className="text-sm text-muted-foreground">Blacklist-Verwaltung</p>
|
|
<h1 className="text-2xl font-semibold tracking-normal">Sperrliste</h1>
|
|
</div>
|
|
|
|
<div className="mx-auto w-full max-w-7xl">
|
|
<Card className="p-4 space-y-4">
|
|
<h2 className="text-sm font-medium">Neuen Eintrag anlegen</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Neue Einträge wirken sofort: bestehende und neue Leads mit passendem
|
|
Typ werden automatisch blockiert.
|
|
</p>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-[150px_1fr_1fr_auto]">
|
|
<Select
|
|
value={type}
|
|
onValueChange={(nextType) => setType(nextType as BlacklistType)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Typ" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{blacklistTypeOptions.map((item) => (
|
|
<SelectItem value={item} key={item}>
|
|
{labelForType(item)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Input
|
|
value={value}
|
|
onChange={(event) => setValue(event.target.value)}
|
|
placeholder="Wert"
|
|
/>
|
|
|
|
<Input
|
|
value={note}
|
|
onChange={(event) => setNote(event.target.value)}
|
|
placeholder="Notiz (optional)"
|
|
/>
|
|
|
|
<Button
|
|
onClick={submitNew}
|
|
disabled={formBusy || !value.trim()}
|
|
className="justify-start sm:w-auto"
|
|
>
|
|
Eintrag speichern
|
|
</Button>
|
|
</div>
|
|
|
|
{statusError ? (
|
|
<p className="text-sm text-destructive" role="status">
|
|
{statusError}
|
|
</p>
|
|
) : null}
|
|
{statusMessage ? (
|
|
<p className="text-sm text-muted-foreground" role="status">
|
|
{statusMessage}
|
|
</p>
|
|
) : null}
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="mx-auto w-full max-w-7xl">
|
|
<Card>
|
|
<div className="overflow-x-auto">
|
|
<div className="min-w-[880px]">
|
|
<table className="w-full border-separate border-spacing-0 text-sm">
|
|
<thead>
|
|
<tr className="text-left text-xs text-muted-foreground">
|
|
<th className="p-3 font-normal">Typ</th>
|
|
<th className="p-3 font-normal">Wert</th>
|
|
<th className="p-3 font-normal">Notiz</th>
|
|
<th className="p-3 font-normal">Normalisiert</th>
|
|
<th className="p-3 font-normal">Erstellt</th>
|
|
<th className="p-3 font-normal">Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
{entries === undefined ? (
|
|
<tbody>
|
|
<tr>
|
|
<td className="p-3" colSpan={6}>
|
|
<p className="rounded-md bg-muted p-4 text-sm">
|
|
Sperrliste wird geladen…
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
) : entriesSorted.length === 0 ? (
|
|
<tbody>
|
|
<tr>
|
|
<td className="p-3" colSpan={6}>
|
|
<p className="rounded-md border p-4 text-sm text-muted-foreground">
|
|
Noch keine Sperreinträge.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
) : (
|
|
<tbody>
|
|
{entriesSorted.map((entry) => (
|
|
<BlacklistEntryRow
|
|
key={entry._id}
|
|
entry={entry}
|
|
onDelete={remove}
|
|
onUpdate={async (nextEntry) => {
|
|
setRowBusyId(nextEntry.id);
|
|
setStatusError(null);
|
|
setStatusMessage(null);
|
|
|
|
try {
|
|
await updateEntry(nextEntry);
|
|
setStatusMessage("Eintrag aktualisiert.");
|
|
} catch {
|
|
setStatusError("Eintrag konnte nicht gespeichert werden.");
|
|
} finally {
|
|
setRowBusyId(null);
|
|
}
|
|
}}
|
|
isBusy={rowBusyId === entry._id}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
)}
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function BlacklistEntryRow({
|
|
entry,
|
|
onDelete,
|
|
onUpdate,
|
|
isBusy,
|
|
}: {
|
|
entry: BlacklistEntry;
|
|
onDelete: (id: Id<"blacklistEntries">) => Promise<void>;
|
|
onUpdate: (next: {
|
|
id: Id<"blacklistEntries">;
|
|
type?: BlacklistType;
|
|
value?: string;
|
|
note?: string;
|
|
}) => Promise<void>;
|
|
isBusy: boolean;
|
|
}) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [type, setType] = useState<BlacklistType>(entry.type);
|
|
const [value, setValue] = useState(entry.value);
|
|
const [note, setNote] = useState(entry.note ?? "");
|
|
const [rowMessage, setRowMessage] = useState<string | null>(null);
|
|
|
|
const submitUpdate = async () => {
|
|
if (!value.trim()) {
|
|
setRowMessage("Wert darf nicht leer sein.");
|
|
return;
|
|
}
|
|
|
|
setRowMessage(null);
|
|
|
|
await onUpdate({
|
|
id: entry._id,
|
|
type,
|
|
value: value.trim(),
|
|
note: note.trim().length > 0 ? note.trim() : undefined,
|
|
});
|
|
|
|
setIsEditing(false);
|
|
setRowMessage("Gespeichert");
|
|
};
|
|
|
|
return (
|
|
<tr className="border-t">
|
|
<td className="p-3 align-top">
|
|
{isEditing ? (
|
|
<Select value={type} onValueChange={(nextType) => setType(nextType as BlacklistType)}>
|
|
<SelectTrigger className="max-w-[168px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{blacklistTypeOptions.map((item) => (
|
|
<SelectItem value={item} key={item}>
|
|
{labelForType(item)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Badge variant="secondary">{labelForType(entry.type)}</Badge>
|
|
)}
|
|
</td>
|
|
<td className="max-w-[260px] p-3 align-top">
|
|
{isEditing ? (
|
|
<Input value={value} onChange={(event) => setValue(event.target.value)} />
|
|
) : (
|
|
<p className="truncate">{entry.value}</p>
|
|
)}
|
|
</td>
|
|
<td className="max-w-[300px] p-3 align-top">
|
|
{isEditing ? (
|
|
<Input value={note} onChange={(event) => setNote(event.target.value)} />
|
|
) : (
|
|
<p className="truncate text-muted-foreground">
|
|
{entry.note ?? "—"}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="p-3 align-top">
|
|
<p className="truncate">{entry.normalizedValue}</p>
|
|
</td>
|
|
<td className="p-3 align-top">
|
|
<p className="text-muted-foreground">{formatDate(entry.createdAt)}</p>
|
|
</td>
|
|
<td className="p-3 align-top">
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
{isEditing ? (
|
|
<>
|
|
<Button size="sm" onClick={submitUpdate} disabled={isBusy}>
|
|
Speichern
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsEditing(false)}
|
|
disabled={isBusy}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setIsEditing(true)}
|
|
disabled={isBusy}
|
|
>
|
|
Bearbeiten
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => onDelete(entry._id)}
|
|
disabled={isBusy}
|
|
>
|
|
Löschen
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{rowMessage ? (
|
|
<p className="mt-2 text-xs text-muted-foreground">{rowMessage}</p>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|