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:
1 parent
969202764a
commit
fa8dd46571
175 files changed
+24741
-2
No files matched your search
Executable
+577
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user