453 lines
19 KiB
TypeScript
453 lines
19 KiB
TypeScript
'use client'
|
|
|
|
import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
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 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} />
|
|
<TaskColumn label="In Arbeit" tasks={inProgress} />
|
|
<TaskColumn label="Review" tasks={review} />
|
|
<TaskColumn label="Erledigt" tasks={done} muted />
|
|
</TabsContent>
|
|
|
|
<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 }: { label: string; tasks: Task[]; muted?: boolean }) {
|
|
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 (
|
|
<div key={t.id} className={cn('flex items-center gap-2.5 rounded-md border border-border p-2.5', 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>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
{tasks.length === 0 && (
|
|
<div className="rounded border border-dashed border-border py-3 text-center text-[10px] text-muted-foreground">—</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|