mirror of
https://github.com/complexcaresolutions/dak.c2s.git
synced 2026-03-18 01:23:41 +00:00
321 lines
10 KiB
TypeScript
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
|
|
}
|
|
}
|