feat(PROJ-9): implement labels frontend - LabelList, LabelPicker, search integration, admin UI
This commit is contained in:
@@ -163,6 +163,17 @@ Suche mit Label-Filter läuft vollständig in Xapian – kein zusätzlicher DB-J
|
||||
|
||||
**Design deviation from spec:** Label filtering uses Go-level post-filter on search results (same pattern as tenant isolation fallback) instead of Xapian boolean terms. Reason: the Xapian integration goes through CGO with a C wrapper; adding label terms would require modifying both the C wrapper and the Go bridge. The post-filter approach is simpler and consistent with existing patterns.
|
||||
|
||||
## Implementation Notes (Frontend)
|
||||
|
||||
**Implemented 2026-03-18:**
|
||||
|
||||
- `src/lib/api.ts` -- Added `MailLabel` and `LabelRule` interfaces plus all label API functions (getLabels, createLabel, updateLabel, deleteLabel, assignLabel, removeLabelFromEmail, getMailLabelIds, createAdminLabel, getAdminLabels, deleteAdminLabel, getLabelRules, createLabelRule, deleteLabelRule). Added `label_id` parameter to `searchEmails()`. Named interface `MailLabel` to avoid conflict with shadcn `Label` component.
|
||||
- `src/components/LabelList.tsx` -- Sidebar component showing all labels with colored circles, selection state, create/edit/delete inline forms, color picker (8 preset colors). Global labels show lock icon and cannot be deleted. Hidden on mobile (`hidden md:block`).
|
||||
- `src/components/LabelPicker.tsx` -- Popover-based label assignment widget for mail detail view. Shows assigned labels as colored badges. Popover lists all labels with checkbox-style toggles for assign/remove.
|
||||
- `src/app/search/page.tsx` -- Integrated LabelList as left sidebar (w-48). Added `selectedLabelId` state. Label selection triggers re-search with `label_id` parameter. Layout uses flex with sidebar and main content area.
|
||||
- `src/app/mail/[id]/page.tsx` -- Integrated LabelPicker after mail header card. Loads all labels and assigned label IDs on mount. Updates label assignments via `onUpdate` callback.
|
||||
- `src/app/admin/page.tsx` -- Added "Labels" tab (superadmin only) with two sections: Global Labels management (create/delete with color picker) and Auto-Rules management (create/delete with condition field/value/label selection).
|
||||
|
||||
## QA Test Results
|
||||
_To be added by /qa_
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
getLabels,
|
||||
createLabel,
|
||||
updateLabel,
|
||||
deleteLabel,
|
||||
type MailLabel,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const LABEL_COLORS = [
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#06b6d4",
|
||||
"#6366f1",
|
||||
"#a855f7",
|
||||
"#ec4899",
|
||||
];
|
||||
|
||||
interface LabelListProps {
|
||||
selectedLabelId?: number | null;
|
||||
onLabelSelect: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export function LabelList({ selectedLabelId, onLabelSelect }: LabelListProps) {
|
||||
const [labels, setLabels] = useState<MailLabel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Create form
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newColor, setNewColor] = useState(LABEL_COLORS[0]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Edit state
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editColor, setEditColor] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Hover state
|
||||
const [hoveredId, setHoveredId] = useState<number | null>(null);
|
||||
|
||||
const loadLabels = useCallback(async () => {
|
||||
try {
|
||||
const data = await getLabels();
|
||||
setLabels(data || []);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Fehler beim Laden");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLabels();
|
||||
}, [loadLabels]);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newName.trim()) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
await createLabel(newName.trim(), newColor);
|
||||
setNewName("");
|
||||
setNewColor(LABEL_COLORS[0]);
|
||||
setShowCreate(false);
|
||||
await loadLabels();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler beim Erstellen");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editId || !editName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateLabel(editId, editName.trim(), editColor);
|
||||
setEditId(null);
|
||||
await loadLabels();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler beim Speichern");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(label: MailLabel) {
|
||||
if (!window.confirm(`Label "${label.name}" wirklich loeschen?`)) return;
|
||||
try {
|
||||
await deleteLabel(label.id);
|
||||
if (selectedLabelId === label.id) onLabelSelect(null);
|
||||
await loadLabels();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Fehler beim Loeschen");
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(label: MailLabel) {
|
||||
setEditId(label.id);
|
||||
setEditName(label.name);
|
||||
setEditColor(label.color);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2 p-2" aria-label="Labels laden">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="space-y-2" aria-label="Label-Filter">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Labels</h3>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Label list */}
|
||||
<ul className="space-y-0.5">
|
||||
{labels.map((label) => (
|
||||
<li
|
||||
key={label.id}
|
||||
className={`group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors ${
|
||||
selectedLabelId === label.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onMouseEnter={() => setHoveredId(label.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={() =>
|
||||
onLabelSelect(selectedLabelId === label.id ? null : label.id)
|
||||
}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onLabelSelect(selectedLabelId === label.id ? null : label.id);
|
||||
}
|
||||
}}
|
||||
aria-pressed={selectedLabelId === label.id}
|
||||
aria-label={`Label ${label.name}${label.is_global ? " (global)" : ""}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate flex-1">{label.name}</span>
|
||||
{label.is_global && (
|
||||
<span className="text-xs text-muted-foreground" title="Globales Label" aria-label="Globales Label">
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{!label.is_global && hoveredId === label.id && (
|
||||
<span className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Bearbeiten"
|
||||
aria-label={`Label ${label.name} bearbeiten`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(label);
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
title="Loeschen"
|
||||
aria-label={`Label ${label.name} loeschen`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(label);
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{labels.length === 0 && !error && (
|
||||
<p className="text-xs text-muted-foreground px-2">Keine Labels vorhanden.</p>
|
||||
)}
|
||||
|
||||
{/* Edit inline form */}
|
||||
{editId !== null && (
|
||||
<form onSubmit={handleUpdate} className="space-y-2 rounded-md border p-2">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Label-Name"
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
aria-label="Label-Name bearbeiten"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LABEL_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-5 w-5 rounded-full border-2 transition-transform ${
|
||||
editColor === c ? "border-foreground scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setEditColor(c)}
|
||||
aria-label={`Farbe ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button type="submit" size="sm" className="h-7 text-xs" disabled={saving}>
|
||||
{saving ? "..." : "Speichern"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate ? (
|
||||
<form onSubmit={handleCreate} className="space-y-2 rounded-md border p-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Neues Label"
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
aria-label="Neuer Label-Name"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LABEL_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`h-5 w-5 rounded-full border-2 transition-transform ${
|
||||
newColor === c ? "border-foreground scale-110" : "border-transparent"
|
||||
}`}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => setNewColor(c)}
|
||||
aria-label={`Farbe ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button type="submit" size="sm" className="h-7 text-xs" disabled={creating || !newName.trim()}>
|
||||
{creating ? "..." : "Erstellen"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setShowCreate(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
+ Neues Label
|
||||
</Button>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { assignLabel, removeLabelFromEmail, type MailLabel } from "@/lib/api";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface LabelPickerProps {
|
||||
emailId: string;
|
||||
assignedLabelIds: number[];
|
||||
allLabels: MailLabel[];
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export function LabelPicker({
|
||||
emailId,
|
||||
assignedLabelIds,
|
||||
allLabels,
|
||||
onUpdate,
|
||||
}: LabelPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
|
||||
const assignedSet = new Set(assignedLabelIds);
|
||||
|
||||
async function toggleLabel(label: MailLabel) {
|
||||
setActionLoading(label.id);
|
||||
try {
|
||||
if (assignedSet.has(label.id)) {
|
||||
await removeLabelFromEmail(emailId, label.id);
|
||||
} else {
|
||||
await assignLabel(emailId, label.id);
|
||||
}
|
||||
onUpdate();
|
||||
} catch (e) {
|
||||
console.error("Label toggle failed:", e);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const assignedLabels = allLabels.filter((l) => assignedSet.has(l.id));
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" aria-label="E-Mail-Labels">
|
||||
{assignedLabels.map((label) => (
|
||||
<Badge
|
||||
key={label.id}
|
||||
variant="secondary"
|
||||
className="gap-1.5 text-xs font-normal"
|
||||
style={{
|
||||
borderColor: label.color,
|
||||
borderWidth: "1px",
|
||||
borderStyle: "solid",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label.name}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
aria-label="Label hinzufuegen oder entfernen"
|
||||
>
|
||||
+ Label
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-2" align="start">
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">
|
||||
Labels zuweisen
|
||||
</p>
|
||||
{allLabels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground py-2 text-center">
|
||||
Keine Labels vorhanden.
|
||||
</p>
|
||||
)}
|
||||
<ul className="space-y-0.5 max-h-60 overflow-y-auto">
|
||||
{allLabels.map((label) => {
|
||||
const isAssigned = assignedSet.has(label.id);
|
||||
const isLoading = actionLoading === label.id;
|
||||
return (
|
||||
<li key={label.id}>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-muted/50 transition-colors disabled:opacity-50"
|
||||
onClick={() => toggleLabel(label)}
|
||||
disabled={isLoading}
|
||||
aria-pressed={isAssigned}
|
||||
aria-label={`Label ${label.name} ${isAssigned ? "entfernen" : "zuweisen"}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
|
||||
isAssigned
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "border-muted-foreground/30"
|
||||
}`}
|
||||
>
|
||||
{isAssigned && (
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: label.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{label.name}</span>
|
||||
{isLoading && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">...</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export async function searchEmails(params: {
|
||||
date_to?: string;
|
||||
sort?: string;
|
||||
has_attachment?: boolean;
|
||||
label_id?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}): Promise<SearchResponse> {
|
||||
@@ -186,6 +187,7 @@ export async function searchEmails(params: {
|
||||
if (params.date_to) sp.set("date_to", params.date_to);
|
||||
if (params.sort) sp.set("sort", params.sort);
|
||||
if (params.has_attachment !== undefined) sp.set("has_attachment", String(params.has_attachment));
|
||||
if (params.label_id !== undefined) sp.set("label_id", String(params.label_id));
|
||||
if (params.page) sp.set("page", String(params.page));
|
||||
if (params.page_size) sp.set("page_size", String(params.page_size));
|
||||
return request<SearchResponse>(`/api/search?${sp.toString()}`);
|
||||
@@ -840,3 +842,96 @@ export async function disableTOTP(code: string): Promise<{ ok: boolean }> {
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Labels ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MailLabel {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
owner_id?: number;
|
||||
tenant_id: number;
|
||||
is_global: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LabelRule {
|
||||
id: number;
|
||||
condition_field: "from_domain" | "source" | "subject_contains";
|
||||
condition_value: string;
|
||||
label_id: number;
|
||||
tenant_id: number;
|
||||
}
|
||||
|
||||
export async function getLabels(): Promise<MailLabel[]> {
|
||||
return request<MailLabel[]>("/api/labels");
|
||||
}
|
||||
|
||||
export async function createLabel(name: string, color: string): Promise<MailLabel> {
|
||||
return request<MailLabel>("/api/labels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateLabel(id: number, name: string, color: string): Promise<void> {
|
||||
return request<void>(`/api/labels/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLabel(id: number): Promise<void> {
|
||||
return request<void>(`/api/labels/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function assignLabel(emailId: string, labelId: number): Promise<void> {
|
||||
return request<void>(`/api/mails/${emailId}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ label_id: labelId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeLabelFromEmail(emailId: string, labelId: number): Promise<void> {
|
||||
return request<void>(`/api/mails/${emailId}/labels/${labelId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMailLabelIds(emailId: string): Promise<number[]> {
|
||||
return request<number[]>(`/api/mails/${emailId}/labels`);
|
||||
}
|
||||
|
||||
export async function createAdminLabel(name: string, color: string): Promise<MailLabel> {
|
||||
return request<MailLabel>("/api/admin/labels", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminLabels(): Promise<MailLabel[]> {
|
||||
return request<MailLabel[]>("/api/admin/labels");
|
||||
}
|
||||
|
||||
export async function deleteAdminLabel(id: number): Promise<void> {
|
||||
return request<void>(`/api/admin/labels/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function getLabelRules(): Promise<LabelRule[]> {
|
||||
return request<LabelRule[]>("/api/admin/label-rules");
|
||||
}
|
||||
|
||||
export async function createLabelRule(
|
||||
condition_field: string,
|
||||
condition_value: string,
|
||||
label_id: number
|
||||
): Promise<LabelRule> {
|
||||
return request<LabelRule>("/api/admin/label-rules", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ condition_field, condition_value, label_id }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteLabelRule(id: number): Promise<void> {
|
||||
return request<void>(`/api/admin/label-rules/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user