API routes (new + modified): - POST /api/aos/crm/companies (create company) - PATCH/DELETE /api/aos/crm/companies/[id] (edit/delete with dependency check) - PATCH/DELETE /api/aos/crm/deals/[id] (edit/delete deal) - POST /api/aos/projects (create project) - PATCH/DELETE /api/aos/projects/[id] (edit/delete with dependency check) - POST /api/aos/expenses (create expense) - PATCH/DELETE /api/aos/expenses/[id] (edit/delete with GoBD lock) - PATCH/DELETE /api/aos/tickets/[id] (edit/delete with message check) - DELETE /api/aos/licenses/[id] (delete only if cancelled + no deps) - NO invoice delete endpoint (GoBD retention — storno only) UI components (new): - DeleteDialog: reusable AlertDialog with dependency list - CompanyFormDialog: create + edit (12 fields) - DealFormDialog: create + edit (replaces CreateDealDialog) - ProjectFormDialog: create + edit - ExpenseFormDialog: create + edit - TicketEditDialog: edit status/priority/category/assignee UI views (modified): - CRM: 'Neue Firma' + 'Neuer Deal' buttons, edit/delete per row - Projects: 'Projekt' button, edit/delete on cards - Expenses: 'Ausgabe' button, edit/delete per row (hidden if DATEV-locked) - Support: edit/delete in inbox header - Licenses: delete button (only if cancelled/revoked/expired) Cascade policy: - Every DELETE counts FK relations, returns 409 with dependency list - Hard delete only when no dependencies exist - GoBD: expenses locked if DATEV-exported or invoiced - Audit log on every create/update/delete
647 lines
28 KiB
TypeScript
Executable File
647 lines
28 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import {
|
|
Plus,
|
|
FolderKanban,
|
|
Clock,
|
|
Calendar,
|
|
CheckCircle2,
|
|
Circle,
|
|
PlayCircle,
|
|
Eye,
|
|
Timer,
|
|
Play,
|
|
Pause,
|
|
Pencil,
|
|
Trash2,
|
|
} 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'
|
|
import { ProjectFormDialog, type ProjectFormValue } from '../crud/project-form-dialog'
|
|
import { DeleteDialog } from '../crud/delete-dialog'
|
|
|
|
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)
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [editTarget, setEditTarget] = useState<ProjectFormValue | null>(null)
|
|
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
|
|
|
// Fetch companies for the create/edit dialog (CRM endpoint already returns them).
|
|
const { data: crmData } = useFetch<{ companies: { id: string; name: string }[] } | null>('/api/aos/crm')
|
|
const companies = crmData?.companies ?? []
|
|
|
|
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" onClick={() => { setEditTarget(null); setCreateOpen(true) }}>
|
|
<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('group 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>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="shrink-0 text-[10px]">{STATUS_LABELS[p.status] ?? p.status}</Badge>
|
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); setEditTarget(projectToForm(p)); setCreateOpen(true) }}
|
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
title="Bearbeiten"
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); setDeleteTarget(p) }}
|
|
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
|
|
<ProjectFormDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
companies={companies}
|
|
project={editTarget}
|
|
onSaved={refetch}
|
|
/>
|
|
|
|
{deleteTarget && (
|
|
<DeleteDialog
|
|
open={!!deleteTarget}
|
|
onOpenChange={(o) => !o && setDeleteTarget(null)}
|
|
entityLabel="Projekt"
|
|
entityName={deleteTarget.name}
|
|
endpoint={`/api/aos/projects/${deleteTarget.id}`}
|
|
onDeleted={() => {
|
|
if (selectedId === deleteTarget.id) setSelectedId(null)
|
|
setDeleteTarget(null)
|
|
refetch()
|
|
}}
|
|
/>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// Convert a Project (view shape) into the form's value shape.
|
|
function projectToForm(p: Project): ProjectFormValue {
|
|
return {
|
|
id: p.id,
|
|
companyId: p.company.id,
|
|
name: p.name,
|
|
type: p.type,
|
|
budgetType: p.budgetType,
|
|
budgetHours: p.budgetHours ? String(p.budgetHours) : '',
|
|
fixedPriceEuro: p.fixedPriceCents ? (p.fixedPriceCents / 100).toString().replace('.', ',') : '',
|
|
startDate: p.startDate ? p.startDate.slice(0, 10) : '',
|
|
dueDate: p.dueDate ? p.dueDate.slice(0, 10) : '',
|
|
}
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|