feat(PROJ-12): E-Mail Export EML/PDF/ZIP
- GET /api/export/pdf/{id}: PDF-Generierung (stdlib, kein ext. Paket)
- POST /api/export/zip: Streaming-ZIP mit manifest.csv, Anhänge optional
- Max. 500 Mails pro Export, Zugriffscheck per Rolle
- Audit-Log für jeden Export
- Frontend: PDF-Button in Mail-Ansicht
- Frontend: Checkboxen + ZIP-Export-Dialog in Suchergebnissen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+102
-1
@@ -3,11 +3,20 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { searchEmails, type SearchHit } from "@/lib/api";
|
||||
import { searchEmails, exportMailsZIP, type SearchHit } from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -18,6 +27,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -37,6 +47,17 @@ export default function SearchPage() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
// Selection state
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportAttachments, setExportAttachments] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Clear selection when results change
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
}, [results]);
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (p: number) => {
|
||||
setSearching(true);
|
||||
@@ -87,7 +108,27 @@ export default function SearchPage() {
|
||||
doSearch(1);
|
||||
}
|
||||
|
||||
async function handleExportZIP() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const { blob } = await exportMailsZIP(Array.from(selected), exportAttachments);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "archivmail-export.zip";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportOpen(false);
|
||||
setSelected(new Set());
|
||||
} catch (e) {
|
||||
alert(`Export fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
const allSelected = results.length > 0 && results.every((h) => selected.has(h.id));
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
@@ -189,10 +230,29 @@ export default function SearchPage() {
|
||||
? `${total} Ergebnis${total !== 1 ? "se" : ""} gefunden`
|
||||
: `${total} E-Mail${total !== 1 ? "s" : ""} im Archiv`}
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{selected.size} ausgewählt</span>
|
||||
<Button size="sm" onClick={() => setExportOpen(true)}>Als ZIP exportieren</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())}>Auswahl aufheben</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelected(new Set(results.map((h) => h.id)));
|
||||
else setSelected(new Set());
|
||||
}}
|
||||
aria-label="Alle auswählen"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-32">Datum</TableHead>
|
||||
<TableHead className="w-56">Von</TableHead>
|
||||
<TableHead>Betreff</TableHead>
|
||||
@@ -212,6 +272,20 @@ export default function SearchPage() {
|
||||
}}
|
||||
aria-label={`E-Mail von ${hit.from || "unbekannt"}: ${hit.subject || "Kein Betreff"}`}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selected.has(hit.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(hit.id);
|
||||
else next.delete(hit.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
aria-label="Mail auswählen"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm text-muted-foreground">
|
||||
{hit.date
|
||||
? new Date(hit.date).toLocaleDateString("de-DE")
|
||||
@@ -252,6 +326,33 @@ export default function SearchPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-Mails exportieren</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selected.size} E-Mail{selected.size !== 1 ? "s" : ""} als ZIP herunterladen
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Switch
|
||||
id="attachments"
|
||||
checked={exportAttachments}
|
||||
onCheckedChange={setExportAttachments}
|
||||
/>
|
||||
<Label htmlFor="attachments">Anhänge einschließen</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setExportOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleExportZIP} disabled={exporting}>
|
||||
{exporting ? "Wird exportiert..." : "ZIP herunterladen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user