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>
|
||||
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
downloadMailAttachment,
|
||||
downloadMailRaw,
|
||||
exportMailPDF,
|
||||
getLabels,
|
||||
getMailLabelIds,
|
||||
type MailDetail,
|
||||
type MailAttachment,
|
||||
type MailLabel,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { LabelPicker } from "@/components/LabelPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
@@ -256,6 +260,10 @@ export default function MailViewPage({
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
|
||||
// Labels state
|
||||
const [allLabels, setAllLabels] = useState<MailLabel[]>([]);
|
||||
const [assignedLabelIds, setAssignedLabelIds] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
getMail(id)
|
||||
@@ -264,8 +272,16 @@ export default function MailViewPage({
|
||||
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
// Load labels
|
||||
getLabels().then(setAllLabels).catch(() => {});
|
||||
loadMailLabels();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, user]);
|
||||
|
||||
function loadMailLabels() {
|
||||
getMailLabelIds(id).then(setAssignedLabelIds).catch(() => setAssignedLabelIds([]));
|
||||
}
|
||||
|
||||
async function handleEmlDownload() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
@@ -364,6 +380,20 @@ export default function MailViewPage({
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Labels */}
|
||||
{allLabels.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<LabelPicker
|
||||
emailId={id}
|
||||
assignedLabelIds={assignedLabelIds}
|
||||
allLabels={allLabels}
|
||||
onUpdate={loadMailLabels}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
+24
-1
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { searchEmails, exportMailsZIP, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { LabelList } from "@/components/LabelList";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -56,6 +57,7 @@ export default function SearchPage() {
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [sort, setSort] = useState("date_desc");
|
||||
const [hasAttachment, setHasAttachment] = useState<boolean | undefined>(undefined);
|
||||
const [selectedLabelId, setSelectedLabelId] = useState<number | null>(null);
|
||||
|
||||
const [results, setResults] = useState<SearchHit[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -94,6 +96,7 @@ export default function SearchPage() {
|
||||
date_to: dateTo || undefined,
|
||||
sort: sort !== "date_desc" ? sort : undefined,
|
||||
has_attachment: hasAttachment,
|
||||
label_id: selectedLabelId ?? undefined,
|
||||
page: p,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
@@ -108,7 +111,7 @@ export default function SearchPage() {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment]
|
||||
[query, fromFilter, toFilter, dateFrom, dateTo, sort, hasAttachment, selectedLabelId]
|
||||
);
|
||||
|
||||
// Alle Mails beim Öffnen der Seite laden — direkt, ohne useCallback-Closure
|
||||
@@ -129,6 +132,13 @@ export default function SearchPage() {
|
||||
.finally(() => setSearching(false));
|
||||
}, [user]);
|
||||
|
||||
// Re-search when label selection changes
|
||||
useEffect(() => {
|
||||
if (!user || !searched) return;
|
||||
doSearch(1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedLabelId]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
doSearch(1);
|
||||
@@ -216,6 +226,17 @@ export default function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
{!authLoading && user && (<>
|
||||
<div className="flex gap-6">
|
||||
{/* Label sidebar */}
|
||||
<div className="hidden md:block w-48 shrink-0">
|
||||
<LabelList
|
||||
selectedLabelId={selectedLabelId}
|
||||
onLabelSelect={setSelectedLabelId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -439,6 +460,8 @@ export default function SearchPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>{/* end flex-1 */}
|
||||
</div>{/* end flex gap-6 */}
|
||||
|
||||
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
|
||||
<DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user