refactor: professional code quality audit

- Fix all TypeScript compilation errors (0 errors in src/)
- Add try/catch error handling to ALL API routes (38 handlers)
- Fix empty catch blocks (api-auth.ts now logs warnings)
- Add aria-labels to all icon-only buttons (WCAG 2.1 AA)
- Fix Prisma type mismatches (OR filter, Person.companyId, Estimate.projectId, Ticket.createdAt)
- Fix webhook PATCH route (moved to [id]/route.ts)
- Fix mobile-bottom-nav type safety
- Fix KB route where-clause type
- Fix test-mail banner null vs undefined
- Fix task comment fetch: add cancellation + error toast
- ESLint: 0 errors, 0 warnings
- 175 files, 24.741 lines of code
This commit is contained in:
Z User committed 2026-10-27 12:23:32 +00:00
1 parent 969202764a
commit fa8dd46571
175 files changed
+24741 -2

No files matched your search

+577
View File
@@ -0,0 +1,577 @@
'use client'
import { useEffect, useState } from 'react'
import {
Plus,
FolderKanban,
Clock,
Calendar,
CheckCircle2,
Circle,
PlayCircle,
Eye,
Timer,
Play,
Pause,
} from 'lucide-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 { Progress } from '@/components/ui/progress'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { PageHeader } from '../primitives'
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
import { cn } from '@/lib/utils'
interface Project {
id: string
name: string
type: string
status: string
budgetType: string
budgetHours: number | null
spentHours: number
fixedPriceCents: number | null
startDate: string | null
dueDate: string | null
progress: number
company: { id: string; name: string }
tasks: Task[]
timeEntries: TimeEntry[]
}
interface Task { id: string; title: string; status: string; assignee: string | null; estimateMin: number | null; priority: string }
interface TimeEntry { id: string; description: string; minutes: number; billable: boolean; rateCentsPerHour: number | null; date: string }
interface ProjectsData {
projects: Project[]
minutesThisWeek: number
}
const STATUS_LABELS: Record<string, string> = {
planning: 'Planung',
active: 'Aktiv',
review: 'Review',
done: 'Abgeschlossen',
'on-hold': 'Pausiert',
cancelled: 'Abgebrochen',
}
const TYPE_LABELS: Record<string, string> = {
website: 'Website',
shop: 'Shop',
app: 'App',
retainer: 'Retainer',
}
const TASK_ICON = {
todo: Circle,
'in-progress': PlayCircle,
review: Eye,
done: CheckCircle2,
}
export function ProjectsView() {
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
const [selectedId, setSelectedId] = useState<string | null>(null)
if (loading || !data) {
return (
<div className="space-y-6 p-4 md:p-6">
<Skeleton className="h-9 w-32" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
</div>
</div>
)
}
const { projects, minutesThisWeek } = data
const selected = projects.find((p) => p.id === selectedId) ?? projects[0] ?? null
const totalHours = projects.reduce((s, p) => s + p.spentHours, 0)
const totalBudget = projects.filter((p) => p.fixedPriceCents).reduce((s, p) => s + (p.fixedPriceCents ?? 0), 0)
return (
<div className="space-y-6 p-4 md:p-6">
<PageHeader
title="Projekte"
description="Projekte, Aufgaben, Zeitverfolgung, Budgets und Change Requests. Mit Client-Portal-Freigaben."
actions={
<Button size="sm">
<Plus className="size-4" />
Projekt
</Button>
}
/>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card className="p-4">
<p className="text-xs text-muted-foreground">Aktive Projekte</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{projects.filter((p) => p.status === 'active').length}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Stunden gesamt</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{totalHours}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Diese Woche</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(minutesThisWeek)}</p>
</Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">Fixed-Price-Volumen</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatEuro(totalBudget)}</p>
</Card>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
{/* Project list */}
<div className="space-y-3 lg:col-span-2">
{projects.map((p) => {
const due = p.dueDate ? daysUntil(p.dueDate) : null
const overBudget = p.budgetHours && p.spentHours > p.budgetHours
return (
<Card
key={p.id}
className={cn('cursor-pointer p-4 transition hover:border-foreground/40', selected?.id === p.id && 'border-foreground/40 ring-1 ring-foreground/20')}
onClick={() => setSelectedId(p.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{p.name}</p>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{p.company.name}</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<Badge variant="secondary" className="text-[10px]">{TYPE_LABELS[p.type] ?? p.type}</Badge>
<Badge variant="outline" className="text-[10px] capitalize">{p.budgetType}</Badge>
{p.fixedPriceCents && (
<span className="text-[11px] tabular-nums text-muted-foreground">{formatEuro(p.fixedPriceCents)}</span>
)}
</div>
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Fortschritt</span>
<span className="font-medium tabular-nums">{p.progress}%</span>
</div>
<Progress value={p.progress} className="h-1.5" />
</div>
<div className="mt-3 flex items-center justify-between text-[11px] text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="size-3" />
{p.spentHours}{p.budgetHours ? ` / ${p.budgetHours} h` : ' h'}
{overBudget && <span className="text-foreground"> · über Budget</span>}
</span>
{due !== null && (
<span className={cn('flex items-center gap-1', due < 0 && 'text-foreground')}>
<Calendar className="size-3" />
{due < 0 ? `${Math.abs(due)} d überfällig` : `in ${due} d`}
</span>
)}
</div>
</Card>
)
})}
</div>
{/* Detail */}
<div className="lg:col-span-3">
{selected ? <ProjectDetail project={selected} onUpdated={refetch} /> : (
<Card className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Wähle ein Projekt.</p>
</Card>
)}
</div>
</div>
</div>
)
}
function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: () => void }) {
const [running, setRunning] = useState(false)
const [newTask, setNewTask] = useState('')
const [taskPriority, setTaskPriority] = useState('medium')
const [timeDesc, setTimeDesc] = useState('')
const [timeMin, setTimeMin] = useState('')
const [savingTask, setSavingTask] = useState(false)
const [savingTime, setSavingTime] = useState(false)
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
const todo = project.tasks.filter((t) => t.status === 'todo')
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
const review = project.tasks.filter((t) => t.status === 'review')
const done = project.tasks.filter((t) => t.status === 'done')
const addTask = async () => {
if (!newTask.trim()) return
setSavingTask(true)
const t = toast.loading('Aufgabe wird hinzugefügt…')
try {
const r = await fetch(`/api/aos/projects/${project.id}/tasks`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: newTask.trim(), priority: taskPriority }),
})
if (!r.ok) throw new Error('task failed')
toast.success('Aufgabe hinzugefügt', { id: t })
setNewTask('')
onUpdated?.()
} catch {
toast.error('Speichern fehlgeschlagen', { id: t })
} finally {
setSavingTask(false)
}
}
const logTime = async () => {
const minutes = parseInt(timeMin, 10)
if (!timeDesc.trim() || !minutes || minutes < 1) return
setSavingTime(true)
const t = toast.loading('Zeiteintrag wird gespeichert…')
try {
const r = await fetch(`/api/aos/projects/${project.id}/time`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ description: timeDesc.trim(), minutes, billable: true, rateCentsPerHour: 9500 }),
})
if (!r.ok) throw new Error('time failed')
toast.success(`${minutes} min gebucht`, { id: t })
setTimeDesc(''); setTimeMin('')
onUpdated?.()
} catch {
toast.error('Buchen fehlgeschlagen', { id: t })
} finally {
setSavingTime(false)
}
}
return (
<Card>
<CardHeader className="border-b border-border pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle className="text-base">{project.name}</CardTitle>
<p className="mt-0.5 text-[11px] text-muted-foreground">{project.company.name}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px]">{STATUS_LABELS[project.status]}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="p-4">
<Tabs defaultValue="tasks">
<TabsList className="w-full justify-start">
<TabsTrigger value="tasks">Aufgaben</TabsTrigger>
<TabsTrigger value="time">Zeit</TabsTrigger>
<TabsTrigger value="budget">Budget</TabsTrigger>
</TabsList>
<TabsContent value="tasks" className="mt-4 space-y-4">
{/* Add task inline */}
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-2">
<Input
value={newTask}
onChange={(e) => setNewTask(e.target.value)}
placeholder="Neue Aufgabe…"
className="h-8 flex-1 text-xs"
onKeyDown={(e) => e.key === 'Enter' && addTask()}
/>
<Select value={taskPriority} onValueChange={setTaskPriority}>
<SelectTrigger className="h-8 w-28 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="low">Niedrig</SelectItem>
<SelectItem value="medium">Mittel</SelectItem>
<SelectItem value="high">Hoch</SelectItem>
</SelectContent>
</Select>
<Button size="sm" className="h-8 gap-1.5" onClick={addTask} disabled={savingTask || !newTask.trim()}>
{savingTask ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Hinzufügen
</Button>
</div>
<TaskColumn label="Offen" tasks={todo} onTaskClick={setSelectedTask} />
<TaskColumn label="In Arbeit" tasks={inProgress} onTaskClick={setSelectedTask} />
<TaskColumn label="Review" tasks={review} onTaskClick={setSelectedTask} />
<TaskColumn label="Erledigt" tasks={done} muted onTaskClick={setSelectedTask} />
</TabsContent>
<TaskDetailDrawer task={selectedTask} onClose={() => setSelectedTask(null)} onUpdated={onUpdated} />
<TabsContent value="time" className="mt-4 space-y-3">
{/* Live timer mock */}
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 p-3">
<Button size="sm" variant={running ? 'default' : 'outline'} className="gap-1.5" onClick={() => setRunning((r) => !r)}>
{running ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{running ? 'Stop' : 'Timer starten'}
</Button>
<div className="flex items-center gap-1.5 text-sm">
<Timer className="size-4 text-muted-foreground" />
<span className="font-mono tabular-nums">{running ? '00:42:18' : '00:00:00'}</span>
</div>
<span className="ml-auto text-[11px] text-muted-foreground">An: {project.name}</span>
</div>
<div className="space-y-2">
{project.timeEntries.map((te) => (
<div key={te.id} className="flex items-center gap-3 rounded-md border border-border p-2.5">
<Clock className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{te.description}</p>
<p className="text-[10px] text-muted-foreground">{formatDate(te.date)}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold tabular-nums">{formatMinutes(te.minutes)}</p>
{te.billable && te.rateCentsPerHour && (
<p className="text-[10px] text-muted-foreground">{formatEuro(te.rateCentsPerHour)}/h</p>
)}
</div>
</div>
))}
{project.timeEntries.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">Keine Zeiteinträge.</p>
)}
</div>
{/* Log time inline */}
<div className="flex items-end gap-2 rounded-md border border-border bg-muted/20 p-2">
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Beschreibung</label>
<Input
value={timeDesc}
onChange={(e) => setTimeDesc(e.target.value)}
placeholder="z.B. SSO-Integration"
className="h-8 text-xs"
/>
</div>
<div className="w-24 space-y-1">
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Minuten</label>
<Input
value={timeMin}
onChange={(e) => setTimeMin(e.target.value)}
placeholder="90"
inputMode="numeric"
className="h-8 text-xs"
/>
</div>
<Button size="sm" className="h-8 gap-1.5" onClick={logTime} disabled={savingTime || !timeDesc.trim() || !timeMin}>
{savingTime ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
Buchen
</Button>
</div>
</TabsContent>
<TabsContent value="budget" className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Typ</p>
<p className="mt-1 text-sm font-medium capitalize">{project.budgetType.replace('-', ' & ')}</p>
</div>
{project.fixedPriceCents && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Fixed Price</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{formatEuro(project.fixedPriceCents)}</p>
</div>
)}
{project.budgetHours && (
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Budget-Stunden</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{project.budgetHours} h</p>
</div>
)}
<div className="rounded-md border border-border p-3">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Verbraucht</p>
<p className="mt-1 text-sm font-semibold tabular-nums">{project.spentHours} h</p>
</div>
</div>
{project.budgetHours && (
<div>
<div className="mb-1 flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">Budget-Auslastung</span>
<span className="font-medium tabular-nums">
{Math.round((project.spentHours / project.budgetHours) * 100)}%
</span>
</div>
<Progress
value={(project.spentHours / project.budgetHours) * 100}
className="h-2"
/>
{project.spentHours > project.budgetHours && (
<p className="mt-2 text-[11px] text-foreground">⚠ Über Budget — Change Request prüfen.</p>
)}
</div>
)}
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">Change Requests</p>
<p className="mt-1">Scope-Änderung → bepreistes Angebot → Freigabe → Aufgaben hinzugefügt. (v1 Demo)</p>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}
function TaskColumn({ label, tasks, muted, onTaskClick }: { label: string; tasks: Task[]; muted?: boolean; onTaskClick?: (task: Task) => void }) {
return (
<div>
<div className="mb-1.5 flex items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-[10px] text-muted-foreground">({tasks.length})</span>
</div>
<div className="space-y-1.5">
{tasks.map((t) => {
const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle
return (
<button
key={t.id}
onClick={() => onTaskClick?.(t)}
className={cn('flex w-full items-center gap-2.5 rounded-md border border-border p-2.5 text-left transition hover:border-foreground/40', muted && 'opacity-70')}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs">{t.title}</p>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted-foreground">
{t.assignee && <span>{t.assignee}</span>}
{t.estimateMin && <span>· ~{formatMinutes(t.estimateMin)}</span>}
</div>
</div>
{t.priority === 'high' && !muted && (
<Badge variant="outline" className="text-[9px]">hoch</Badge>
)}
</button>
)
})}
{tasks.length === 0 && (
<div className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted-foreground">—</div>
)}
</div>
</div>
)
}
// Task detail drawer with comments
function TaskDetailDrawer({ task, onClose, onUpdated }: { task: Task | null; onClose: () => void; onUpdated?: () => void }) {
const [comments, setComments] = useState<{ id: string; author: string; body: string; createdAt: string }[]>([])
const [newComment, setNewComment] = useState('')
const [posting, setPosting] = useState(false)
const [loadingComments, setLoadingComments] = useState(false)
// fetch comments when task changes
useEffect(() => {
if (!task) return
let cancelled = false
setLoadingComments(true)
fetch(`/api/aos/tasks/${task.id}/comments`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json()
})
.then((d) => { if (!cancelled) setComments(d.comments ?? []) })
.catch((err) => { if (!cancelled) toast.error('Kommentare konnten nicht geladen werden') })
.finally(() => { if (!cancelled) setLoadingComments(false) })
return () => { cancelled = true }
}, [task?.id])
const addComment = async () => {
if (!task || !newComment.trim()) return
setPosting(true)
try {
const r = await fetch(`/api/aos/tasks/${task.id}/comments`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: newComment }),
})
const d = await r.json()
if (!r.ok) throw new Error(d.error)
setComments((c) => [...c, d.comment])
setNewComment('')
toast.success('Kommentar hinzugefügt')
} catch {
toast.error('Fehler beim Speichern')
} finally {
setPosting(false)
}
}
const statusMeta: Record<string, { label: string; cls: string }> = {
todo: { label: 'Offen', cls: 'bg-muted text-foreground' },
'in-progress': { label: 'In Arbeit', cls: 'bg-foreground/50 text-background' },
review: { label: 'Review', cls: 'bg-foreground/70 text-background' },
done: { label: 'Erledigt', cls: 'bg-foreground text-background' },
}
return (
<Sheet open={!!task} onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full overflow-hidden p-0 sm:max-w-md">
<SheetTitle className="sr-only">Aufgabendetails</SheetTitle>
{task && (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b border-border p-4">
<div className="flex items-start justify-between gap-2">
<h2 className="text-base font-semibold leading-tight">{task.title}</h2>
<Badge variant="outline" className={cn('shrink-0 text-[10px]', (statusMeta[task.status] ?? statusMeta.todo).cls)}>
{(statusMeta[task.status] ?? statusMeta.todo).label}
</Badge>
</div>
{task.assignee && <p className="mt-1 text-xs text-muted-foreground">Zugewiesen an: {task.assignee}</p>}
{task.estimateMin && <p className="mt-0.5 text-xs text-muted-foreground">Schätzung: ~{formatMinutes(task.estimateMin)}</p>}
{task.priority === 'high' && <Badge variant="outline" className="mt-2 text-[10px]">Hohe Priorität</Badge>}
</div>
{/* Comments */}
<ScrollArea className="flex-1">
<div className="space-y-3 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Kommentare ({comments.length})</p>
{loadingComments && <Skeleton className="h-16 w-full" />}
{comments.map((c) => (
<div key={c.id} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">{c.author}</span>
<span className="text-[9px] text-muted-foreground">{new Date(c.createdAt).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
</div>
<p className="mt-1 text-xs leading-relaxed">{c.body}</p>
</div>
))}
{!loadingComments && comments.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">Noch keine Kommentare.</p>
)}
</div>
</ScrollArea>
{/* Add comment */}
<div className="border-t border-border p-3 space-y-2">
<Textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Kommentar hinzufügen…"
className="min-h-[60px] text-xs"
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) addComment() }}
/>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">⌘+Enter zum Senden</span>
<Button size="sm" onClick={addComment} disabled={posting || !newComment.trim()} className="gap-1.5">
{posting ? <Loader2 className="size-3.5 animate-spin" /> : null}
Senden
</Button>
</div>
</div>
</div>
)}
</SheetContent>
</Sheet>
)
}