63ef43ec-90ce-4fcf-82b4-abff91b2280a
This commit is contained in:
1 parent
8fa25577c0
commit
c71c7e3094
12 files changed
+578
-9
No files matched your search
@@ -0,0 +1,239 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user