feat(PROJ-9): implement labels frontend - LabelList, LabelPicker, search integration, admin UI
This commit is contained in:
@@ -38,6 +38,12 @@ import {
|
||||
saveAdminTenantLDAPConfig,
|
||||
deleteAdminTenantLDAPConfig,
|
||||
testAdminTenantLDAPConfig,
|
||||
getAdminLabels,
|
||||
createAdminLabel,
|
||||
deleteAdminLabel,
|
||||
getLabelRules,
|
||||
createLabelRule,
|
||||
deleteLabelRule,
|
||||
type User,
|
||||
type AuditEntry,
|
||||
type SMTPStatus,
|
||||
@@ -52,6 +58,8 @@ import {
|
||||
type TenantLDAPConfig,
|
||||
type Tenant,
|
||||
type TenantDomain,
|
||||
type MailLabel,
|
||||
type LabelRule,
|
||||
} from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -225,6 +233,20 @@ export default function AdminPage() {
|
||||
// Superadmin: tenant LDAP dialog
|
||||
const [tenantLdapDialogId, setTenantLdapDialogId] = useState<number | null>(null);
|
||||
|
||||
// Labels state
|
||||
const [adminLabels, setAdminLabels] = useState<MailLabel[]>([]);
|
||||
const [adminLabelsLoading, setAdminLabelsLoading] = useState(false);
|
||||
const [adminLabelsError, setAdminLabelsError] = useState("");
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
const [newLabelColor, setNewLabelColor] = useState("#ef4444");
|
||||
const [labelCreating, setLabelCreating] = useState(false);
|
||||
const [labelRules, setLabelRules] = useState<LabelRule[]>([]);
|
||||
const [labelRulesLoading, setLabelRulesLoading] = useState(false);
|
||||
const [newRuleField, setNewRuleField] = useState("from_domain");
|
||||
const [newRuleValue, setNewRuleValue] = useState("");
|
||||
const [newRuleLabelId, setNewRuleLabelId] = useState<number | null>(null);
|
||||
const [ruleCreating, setRuleCreating] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setDashLoading(true);
|
||||
try {
|
||||
@@ -556,6 +578,88 @@ export default function AdminPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAdminLabels = useCallback(async () => {
|
||||
setAdminLabelsLoading(true);
|
||||
setAdminLabelsError("");
|
||||
try {
|
||||
const data = await getAdminLabels();
|
||||
setAdminLabels(data || []);
|
||||
} catch {
|
||||
setAdminLabelsError("Labels konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setAdminLabelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadLabelRules = useCallback(async () => {
|
||||
setLabelRulesLoading(true);
|
||||
try {
|
||||
const data = await getLabelRules();
|
||||
setLabelRules(data || []);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLabelRulesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function loadLabelsTab() {
|
||||
loadAdminLabels();
|
||||
loadLabelRules();
|
||||
}
|
||||
|
||||
async function handleCreateAdminLabel(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newLabelName.trim()) return;
|
||||
setLabelCreating(true);
|
||||
try {
|
||||
await createAdminLabel(newLabelName.trim(), newLabelColor);
|
||||
setNewLabelName("");
|
||||
setNewLabelColor("#ef4444");
|
||||
await loadAdminLabels();
|
||||
} catch (err) {
|
||||
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
|
||||
} finally {
|
||||
setLabelCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAdminLabel(id: number, name: string) {
|
||||
if (!window.confirm(`Globales Label "${name}" wirklich loeschen?`)) return;
|
||||
try {
|
||||
await deleteAdminLabel(id);
|
||||
await loadAdminLabels();
|
||||
await loadLabelRules();
|
||||
} catch (err) {
|
||||
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRule(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newRuleValue.trim() || !newRuleLabelId) return;
|
||||
setRuleCreating(true);
|
||||
try {
|
||||
await createLabelRule(newRuleField, newRuleValue.trim(), newRuleLabelId);
|
||||
setNewRuleValue("");
|
||||
await loadLabelRules();
|
||||
} catch (err) {
|
||||
setAdminLabelsError(err instanceof Error ? err.message : "Fehler");
|
||||
} finally {
|
||||
setRuleCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRule(id: number) {
|
||||
if (!window.confirm("Regel wirklich loeschen?")) return;
|
||||
try {
|
||||
await deleteLabelRule(id);
|
||||
await loadLabelRules();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateTenant(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setTenantCreateLoading(true);
|
||||
@@ -730,6 +834,7 @@ export default function AdminPage() {
|
||||
{!isSuperAdmin && user?.role === "domain_admin" && (
|
||||
<TabsTrigger value="tenant-ldap" onClick={loadTenantLDAP}>LDAP</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="tenants" onClick={loadTenants}>Mandanten</TabsTrigger>}
|
||||
{isSuperAdmin && <TabsTrigger value="modules">Module</TabsTrigger>}
|
||||
@@ -2422,6 +2527,215 @@ export default function AdminPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Labels (Admin) ── */}
|
||||
<TabsContent value="labels" className="mt-4 space-y-6">
|
||||
{adminLabelsError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{adminLabelsError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Globale Labels */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold">Globale Labels</h3>
|
||||
<form onSubmit={handleCreateAdminLabel} className="flex items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="label-name" className="text-xs">Name</Label>
|
||||
<Input
|
||||
id="label-name"
|
||||
value={newLabelName}
|
||||
onChange={(e) => setNewLabelName(e.target.value)}
|
||||
placeholder="Label-Name"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Farbe</Label>
|
||||
<div className="flex gap-1.5">
|
||||
{["#ef4444","#f97316","#eab308","#22c55e","#06b6d4","#6366f1","#a855f7","#ec4899"].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-6 w-6 rounded-full border-2 transition-transform ${
|
||||
newLabelColor === c ? "border-foreground scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewLabelColor(c)}
|
||||
aria-label={`Farbe ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={labelCreating || !newLabelName.trim()}>
|
||||
{labelCreating ? "..." : "Anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{adminLabelsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : adminLabels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine globalen Labels vorhanden.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-24">Farbe</TableHead>
|
||||
<TableHead className="w-24">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminLabels.map((label) => (
|
||||
<TableRow key={label.id}>
|
||||
<TableCell className="font-medium">{label.name}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className="inline-block h-4 w-4 rounded-full"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-label={`Farbe ${label.color}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-7"
|
||||
onClick={() => handleDeleteAdminLabel(label.id, label.name)}
|
||||
>
|
||||
Loeschen
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auto-Regeln */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<h3 className="text-sm font-semibold">Auto-Regeln</h3>
|
||||
<form onSubmit={handleCreateRule} className="flex items-end gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rule-field" className="text-xs">Bedingung</Label>
|
||||
<Select value={newRuleField} onValueChange={setNewRuleField}>
|
||||
<SelectTrigger id="rule-field" className="h-8 w-44 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="from_domain">Absender-Domain</SelectItem>
|
||||
<SelectItem value="source">Import-Quelle</SelectItem>
|
||||
<SelectItem value="subject_contains">Betreff enthaelt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rule-value" className="text-xs">Wert</Label>
|
||||
<Input
|
||||
id="rule-value"
|
||||
value={newRuleValue}
|
||||
onChange={(e) => setNewRuleValue(e.target.value)}
|
||||
placeholder="z.B. example.com"
|
||||
className="h-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rule-label" className="text-xs">Label</Label>
|
||||
<Select
|
||||
value={newRuleLabelId !== null ? String(newRuleLabelId) : ""}
|
||||
onValueChange={(v) => setNewRuleLabelId(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="rule-label" className="h-8 w-44 text-xs">
|
||||
<SelectValue placeholder="Label waehlen..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{adminLabels.map((l) => (
|
||||
<SelectItem key={l.id} value={String(l.id)}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: l.color }}
|
||||
/>
|
||||
{l.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={ruleCreating || !newRuleValue.trim() || !newRuleLabelId}>
|
||||
{ruleCreating ? "..." : "Regel anlegen"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{labelRulesLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : labelRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine Regeln vorhanden.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Bedingung</TableHead>
|
||||
<TableHead>Wert</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead className="w-24">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{labelRules.map((rule) => {
|
||||
const condLabels: Record<string, string> = {
|
||||
from_domain: "Absender-Domain",
|
||||
source: "Import-Quelle",
|
||||
subject_contains: "Betreff enthaelt",
|
||||
};
|
||||
const matchLabel = adminLabels.find((l) => l.id === rule.label_id);
|
||||
return (
|
||||
<TableRow key={rule.id}>
|
||||
<TableCell className="text-sm">{condLabels[rule.condition_field] || rule.condition_field}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rule.condition_value}</TableCell>
|
||||
<TableCell>
|
||||
{matchLabel ? (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: matchLabel.color }}
|
||||
/>
|
||||
{matchLabel.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">ID {rule.label_id}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-7"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
Loeschen
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modules" className="mt-4">
|
||||
<ModulesTab />
|
||||
</TabsContent>
|
||||
|
||||
Reference in New Issue
Block a user