dak.c2s/frontend/src/pages/ReportsPage.tsx
CCS Admin 7ef1fc9335 feat: add checkbox selection and bulk delete for reports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:40:19 +00:00

321 lines
10 KiB
TypeScript

import { useState, useEffect } from 'react'
import { Download, FileSpreadsheet, Loader2, Plus, Trash2 } from 'lucide-react'
import api from '@/services/api'
import type { ReportMeta } from '@/types'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
export function ReportsPage() {
const { isAdmin } = useAuth()
const currentYear = new Date().getFullYear()
const currentKw = getISOWeek(new Date())
const [reports, setReports] = useState<ReportMeta[]>([])
const [totalReports, setTotalReports] = useState(0)
const [loading, setLoading] = useState(true)
// Report generation state
const [genJahr, setGenJahr] = useState(currentYear)
const [genKw, setGenKw] = useState(currentKw)
const [generating, setGenerating] = useState(false)
const [genError, setGenError] = useState('')
const [genSuccess, setGenSuccess] = useState('')
// Selection state for deletion
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [deleting, setDeleting] = useState(false)
// Fetch reports list
const fetchReports = () => {
setLoading(true)
api.get<{ items: ReportMeta[]; total: number }>('/reports/list')
.then((res) => {
setReports(res.data.items)
setTotalReports(res.data.total)
})
.catch(() => {
setReports([])
setTotalReports(0)
})
.finally(() => setLoading(false))
}
useEffect(() => {
fetchReports()
}, [])
const generateReport = async () => {
setGenerating(true)
setGenError('')
setGenSuccess('')
try {
const res = await api.post<ReportMeta>('/reports/generate', null, {
params: { jahr: genJahr, kw: genKw },
})
setGenSuccess(`Bericht für KW ${res.data.kw}/${res.data.jahr} wurde generiert.`)
fetchReports()
} catch {
setGenError('Fehler beim Generieren des Berichts.')
} finally {
setGenerating(false)
}
}
const downloadReport = (reportId: number) => {
const token = localStorage.getItem('access_token')
const url = `/api/reports/download/${reportId}`
api.get(url, { responseType: 'blob' })
.then((res) => {
const blob = new Blob([res.data as BlobPart], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
const contentDisposition = res.headers['content-disposition']
let filename = `bericht_${reportId}.xlsx`
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match) filename = match[1].replace(/['"]/g, '')
}
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
})
.catch(() => {
if (token) {
window.open(`${url}?token=${encodeURIComponent(token)}`, '_blank')
}
})
}
const toggleSelect = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === reports.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(reports.map((r) => r.id)))
}
}
const deleteSelected = async () => {
if (selectedIds.size === 0) return
setDeleting(true)
try {
await api.delete('/reports/delete', { data: Array.from(selectedIds) })
setSelectedIds(new Set())
fetchReports()
} catch {
setGenError('Fehler beim Löschen der Berichte.')
} finally {
setDeleting(false)
}
}
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold">Berichte</h1>
{/* Report generation (admin only) */}
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" />
Bericht generieren
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1">
<Label htmlFor="gen-jahr">Jahr</Label>
<Input
id="gen-jahr"
type="number"
value={genJahr}
onChange={(e) => setGenJahr(Number(e.target.value))}
className="w-28"
min={2020}
max={2030}
/>
</div>
<div className="space-y-1">
<Label htmlFor="gen-kw">Kalenderwoche</Label>
<Input
id="gen-kw"
type="number"
value={genKw}
onChange={(e) => setGenKw(Number(e.target.value))}
className="w-28"
min={1}
max={53}
/>
</div>
<Button onClick={generateReport} disabled={generating}>
{generating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Wird generiert...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Bericht generieren
</>
)}
</Button>
</div>
{genError && (
<Alert variant="destructive">
<AlertDescription>{genError}</AlertDescription>
</Alert>
)}
{genSuccess && (
<Alert>
<AlertDescription>{genSuccess}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
)}
{/* Reports table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
Bisherige Berichte ({totalReports})
</CardTitle>
{isAdmin && selectedIds.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={deleteSelected}
disabled={deleting}
>
{deleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
{selectedIds.size} Bericht{selectedIds.size > 1 ? 'e' : ''} löschen
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : reports.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
{isAdmin && (
<TableHead className="w-10">
<Checkbox
checked={reports.length > 0 && selectedIds.size === reports.length}
onCheckedChange={toggleSelectAll}
/>
</TableHead>
)}
<TableHead>Berichtsdatum</TableHead>
<TableHead>Jahr</TableHead>
<TableHead>KW</TableHead>
<TableHead>Erstellt am</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((r) => (
<TableRow key={r.id}>
{isAdmin && (
<TableCell>
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => toggleSelect(r.id)}
/>
</TableCell>
)}
<TableCell>{formatDate(r.report_date)}</TableCell>
<TableCell>{r.jahr}</TableCell>
<TableCell>KW {r.kw}</TableCell>
<TableCell>{formatDateTime(r.generated_at)}</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => downloadReport(r.id)}
>
<Download className="mr-1.5 h-4 w-4" />
Download
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="py-8 text-center text-muted-foreground">
Keine Berichte vorhanden.
</p>
)}
</CardContent>
</Card>
</div>
)
}
function getISOWeek(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7)
}
function formatDate(dateStr: string): string {
try {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
})
} catch {
return dateStr
}
}
function formatDateTime(dateStr: string): string {
try {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch {
return dateStr
}
}