Files
AOS/src/components/aos/views/timesheet.tsx
T

240 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import { useFetch } from '@/hooks/use-fetch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { PageHeader } from '../primitives'
import { formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Lock, Clock, TrendingUp, CheckCircle2, Calendar } from 'lucide-react'
interface DayData {
date: string
minutes: number
locked: boolean
entries: { id: string; description: string; minutes: number; billable: boolean; locked: boolean }[]
}
interface GridRow {
project: { id: string; name: string }
days: DayData[]
totalMinutes: number
}
interface TimesheetData {
week: { start: string; end: string; label: string }
grid: GridRow[]
dayTotals: { date: string; label: string; dayNum: number; minutes: number }[]
stats: { grandTotalMinutes: number; billableMinutes: number; lockedCount: number; totalEntries: number; allLocked: boolean; projectCount: number }
}
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const WEEKEND = [5, 6] // Saturday, Sunday
export function TimesheetView() {
const now = new Date()
const [weekParam, setWeekParam] = useState(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`)
const { data, loading, refetch } = useFetch<TimesheetData>(`/api/aos/timesheet?week=${weekParam}`)
const [locking, setLocking] = useState(false)
const navigateWeek = (delta: number) => {
const d = new Date(weekParam + 'T00:00:00Z')
d.setDate(d.getDate() + delta * 7)
setWeekParam(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}
const lockWeek = async () => {
setLocking(true)
const t = toast.loading('Woche wird gesperrt (PM-Freigabe)…')
try {
const r = await fetch('/api/aos/timesheet', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ week: weekParam }),
})
const res = await r.json()
if (!r.ok) throw new Error(res.error || 'lock failed')
toast.success(`${res.locked} Einträge gesperrt (Audit protokolliert)`, { id: t })
refetch()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Sperren fehlgeschlagen', { id: t })
} finally {
setLocking(false)
}
}
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-64" />
</div>
)
}
const { grid, dayTotals, stats, week } = data
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Zeiterfassung"
description="Wochen-basierte Zeiterfassung mit PM-Freigabe (Lock). Nach Freigabe können Einträge nicht mehr geändert werden — GoBD-konform."
actions={
!stats.allLocked && stats.totalEntries > 0 ? (
<Button size="sm" onClick={lockWeek} disabled={locking} className="gap-1.5">
<Lock className="size-4" />
Woche sperren
</Button>
) : stats.allLocked ? (
<Badge variant="outline" className="gap-1">
<CheckCircle2 className="size-3" />Gesperrt
</Badge>
) : undefined
}
/>
{/* Week navigation */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigateWeek(-1)}>
<ChevronLeft className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Calendar className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">{week.label}</span>
</div>
<Button variant="ghost" size="icon" onClick={() => navigateWeek(1)}>
<ChevronRight className="size-4" />
</Button>
<Button variant="ghost" size="sm" className="ml-2 h-8 text-xs" onClick={() => {
const d = new Date()
setWeekParam(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}}>
Diese Woche
</Button>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Gesamt</p><Clock className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(stats.grandTotalMinutes)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Abrechenbar</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(stats.billableMinutes)}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Projekte</p><CheckCircle2 className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.projectCount}</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Gesperrt</p><Lock className="size-3.5 text-muted-foreground" /></div>
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.lockedCount}/{stats.totalEntries}</p>
</Card>
</div>
{/* Timesheet grid */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-border bg-muted/40">
<tr>
<th className="sticky left-0 z-10 bg-muted/40 px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Projekt</th>
{dayTotals.map((d, i) => (
<th key={i} className={cn('px-3 py-2 text-center text-[11px] font-medium', WEEKEND.includes(i) && 'text-muted-foreground/50')}>
<div className="flex flex-col items-center gap-0.5">
<span className="uppercase tracking-wide">{d.label}</span>
<span className="text-sm font-semibold text-foreground">{d.dayNum}</span>
</div>
</th>
))}
<th className="px-4 py-2 text-right text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Σ</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{grid.length === 0 && (
<tr>
<td colSpan={9} className="py-10 text-center text-xs text-muted-foreground">
Keine Zeiteinträge in dieser Woche. Erfasse Zeit unter „Projekte".
</td>
</tr>
)}
{grid.map((row) => (
<tr key={row.project.id} className="transition hover:bg-muted/30">
<td className="sticky left-0 z-10 bg-background px-4 py-3">
<p className="truncate text-xs font-medium">{row.project.name}</p>
</td>
{row.days.map((day, i) => (
<td key={i} className={cn('px-3 py-3 text-center', WEEKEND.includes(i) && 'bg-muted/20')}>
{day.minutes > 0 ? (
<div className="flex flex-col items-center gap-0.5">
<span className={cn('text-xs font-semibold tabular-nums', day.locked && 'text-muted-foreground')}>
{Math.floor(day.minutes / 60)}:{String(day.minutes % 60).padStart(2, '0')}
</span>
{day.locked && <Lock className="size-2.5 text-muted-foreground/60" />}
{day.entries.length > 1 && <span className="text-[9px] text-muted-foreground">{day.entries.length}× Einträge</span>}
</div>
) : (
<span className="text-muted-foreground/30">—</span>
)}
</td>
))}
<td className="px-4 py-3 text-right text-xs font-bold tabular-nums">
{Math.floor(row.totalMinutes / 60)}:{String(row.totalMinutes % 60).padStart(2, '0')}
</td>
</tr>
))}
</tbody>
<tfoot className="border-t-2 border-border bg-muted/40">
<tr>
<td className="sticky left-0 z-10 bg-muted/40 px-4 py-2.5 text-xs font-semibold">Tagessumme</td>
{dayTotals.map((d, i) => (
<td key={i} className={cn('px-3 py-2.5 text-center text-xs font-semibold tabular-nums', WEEKEND.includes(i) && 'text-muted-foreground/50')}>
{d.minutes > 0 ? `${Math.floor(d.minutes / 60)}:${String(d.minutes % 60).padStart(2, '0')}` : '—'}
</td>
))}
<td className="px-4 py-2.5 text-right text-sm font-bold tabular-nums">
{Math.floor(stats.grandTotalMinutes / 60)}:{String(stats.grandTotalMinutes % 60).padStart(2, '0')}
</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
</Card>
{/* Entry details */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Einträge dieser Woche ({stats.totalEntries})</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
{grid.flatMap((row) => row.days.flatMap((day) => day.entries.map((e) => ({ ...e, projectName: row.project.name, date: day.date })))).map((e) => (
<div key={e.id} className="flex items-center gap-3 rounded-md border border-border p-2">
{e.locked ? <Lock className="size-3 text-muted-foreground" /> : <Clock className="size-3 text-muted-foreground" />}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{e.description}</p>
<p className="text-[10px] text-muted-foreground">{e.projectName} · {new Date(e.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}</p>
</div>
<div className="flex items-center gap-2">
{e.billable && <Badge variant="outline" className="text-[9px]">billable</Badge>}
<span className="text-xs font-semibold tabular-nums">{formatMinutes(e.minutes)}</span>
</div>
</div>
))}
{stats.totalEntries === 0 && <p className="py-4 text-center text-xs text-muted-foreground">Keine Einträge.</p>}
</CardContent>
</Card>
{/* Lock info */}
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="flex items-center gap-1.5 font-medium text-foreground"><Lock className="size-3.5" />Wochen-Sperre (PM-Freigabe)</p>
<p className="mt-1">Nach der Freigabe durch den Projektmanager werden alle Zeiteinträge der Woche gesperrt (locked=true). Gesperrte Einträge können nicht mehr geändert werden — GoBD-konform. Die Sperrung wird im Audit-Log protokolliert.</p>
</div>
</div>
)
}