feat: add lead qualification workflow
This commit is contained in:
376
components/blacklist/blacklist-manager.tsx
Normal file
376
components/blacklist/blacklist-manager.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user