'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 = { planning: 'Planung', active: 'Aktiv', review: 'Review', done: 'Abgeschlossen', 'on-hold': 'Pausiert', cancelled: 'Abgebrochen', } const TYPE_LABELS: Record = { 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('/api/aos/projects') const [selectedId, setSelectedId] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(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 (
{Array.from({ length: 3 }).map((_, i) => )}
) } 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 (
{ setEditTarget(null); setCreateOpen(true) }}> Projekt } />

Aktive Projekte

{projects.filter((p) => p.status === 'active').length}

Stunden gesamt

{totalHours}

Diese Woche

{formatMinutes(minutesThisWeek)}

Fixed-Price-Volumen

{formatEuro(totalBudget)}

{/* Project list */}
{projects.map((p) => { const due = p.dueDate ? daysUntil(p.dueDate) : null const overBudget = p.budgetHours && p.spentHours > p.budgetHours return ( setSelectedId(p.id)} >

{p.name}

{p.company.name}

{STATUS_LABELS[p.status] ?? p.status}
{TYPE_LABELS[p.type] ?? p.type} {p.budgetType} {p.fixedPriceCents && ( {formatEuro(p.fixedPriceCents)} )}
Fortschritt {p.progress}%
{p.spentHours}{p.budgetHours ? ` / ${p.budgetHours} h` : ' h'} {overBudget && · über Budget} {due !== null && ( {due < 0 ? `${Math.abs(due)} d überfällig` : `in ${due} d`} )}
) })}
{/* Detail */}
{selected ? : (

Wähle ein Projekt.

)}
{deleteTarget && ( !o && setDeleteTarget(null)} entityLabel="Projekt" entityName={deleteTarget.name} endpoint={`/api/aos/projects/${deleteTarget.id}`} onDeleted={() => { if (selectedId === deleteTarget.id) setSelectedId(null) setDeleteTarget(null) refetch() }} /> )}
) } 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(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 (
{project.name}

{project.company.name}

{STATUS_LABELS[project.status]}
Aufgaben Zeit Budget {/* Add task inline */}
setNewTask(e.target.value)} placeholder="Neue Aufgabe…" className="h-8 flex-1 text-xs" onKeyDown={(e) => e.key === 'Enter' && addTask()} />
setSelectedTask(null)} onUpdated={onUpdated} /> {/* Live timer mock */}
{running ? '00:42:18' : '00:00:00'}
An: {project.name}
{project.timeEntries.map((te) => (

{te.description}

{formatDate(te.date)}

{formatMinutes(te.minutes)}

{te.billable && te.rateCentsPerHour && (

{formatEuro(te.rateCentsPerHour)}/h

)}
))} {project.timeEntries.length === 0 && (

Keine Zeiteinträge.

)}
{/* Log time inline */}
setTimeDesc(e.target.value)} placeholder="z.B. SSO-Integration" className="h-8 text-xs" />
setTimeMin(e.target.value)} placeholder="90" inputMode="numeric" className="h-8 text-xs" />

Typ

{project.budgetType.replace('-', ' & ')}

{project.fixedPriceCents && (

Fixed Price

{formatEuro(project.fixedPriceCents)}

)} {project.budgetHours && (

Budget-Stunden

{project.budgetHours} h

)}

Verbraucht

{project.spentHours} h

{project.budgetHours && (
Budget-Auslastung {Math.round((project.spentHours / project.budgetHours) * 100)}%
{project.spentHours > project.budgetHours && (

⚠ Über Budget — Change Request prüfen.

)}
)}

Change Requests

Scope-Änderung → bepreistes Angebot → Freigabe → Aufgaben hinzugefügt. (v1 Demo)

) } // 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 (
{label} ({tasks.length})
{tasks.map((t) => { const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle return ( ) })} {tasks.length === 0 && (
—
)}
) } // 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 = { 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 ( !o && onClose()}> Aufgabendetails {task && (
{/* Header */}

{task.title}

{(statusMeta[task.status] ?? statusMeta.todo).label}
{task.assignee &&

Zugewiesen an: {task.assignee}

} {task.estimateMin &&

Schätzung: ~{formatMinutes(task.estimateMin)}

} {task.priority === 'high' && Hohe Priorität}
{/* Comments */}

Kommentare ({comments.length})

{loadingComments && } {comments.map((c) => (
{c.author} {new Date(c.createdAt).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}

{c.body}

))} {!loadingComments && comments.length === 0 && (

Noch keine Kommentare.

)}
{/* Add comment */}