b6167bcb-7363-4695-9481-addd81de4367
This commit is contained in:
1 parent
fdc3b82d10
commit
d226dabba3
136 files changed
+13904
-2
No files matched your search
@@ -0,0 +1,350 @@
|
||||
'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 { 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 } = 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} /> : (
|
||||
<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 }: { project: Project }) {
|
||||
const [running, setRunning] = 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')
|
||||
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user