Files
pitchfast/components/blacklist/blacklist-manager.tsx

382 lines
12 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"
| "source_business_id";
const blacklistTypeOptions: BlacklistType[] = [
"domain",
"email",
"phone",
"company",
"source_business_id",
"google_place_id",
];
function labelForType(type: BlacklistType): string {
if (type === "source_business_id") {
return "Source Business ID";
}
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>
);
}