diff --git a/db/custom.db b/db/custom.db index 4dcad71..1d304c5 100644 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf10a07..c6ba8bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -177,18 +177,30 @@ model Task { priority String @default("medium") // low|medium|high createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + comments TaskComment[] +} + +model TaskComment { + id String @id @default(cuid()) + taskId String + task Task @relation(fields: [taskId], references: [id]) + author String + body String + createdAt DateTime @default(now()) } model TimeEntry { id String @id @default(cuid()) projectId String project Project @relation(fields: [projectId], references: [id]) + taskId String? // optional link to a specific task userId String? description String minutes Int billable Boolean @default(true) rateCentsPerHour Int? invoiced Boolean @default(false) + locked Boolean @default(false) // locked after PM approval date DateTime @default(now()) createdAt DateTime @default(now()) } diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index a78fb2a..0b088f3 100644 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -22,6 +22,7 @@ export async function POST() { await db.apiToken.deleteMany() await db.portalApproval.deleteMany() await db.article.deleteMany() + await db.taskComment.deleteMany() await db.estimateLine.deleteMany() await db.estimate.deleteMany() await db.ticketMessage.deleteMany() diff --git a/src/app/api/aos/tasks/[id]/comments/route.ts b/src/app/api/aos/tasks/[id]/comments/route.ts new file mode 100644 index 0000000..b064826 --- /dev/null +++ b/src/app/api/aos/tasks/[id]/comments/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/tasks/[id]/comments — list comments on a task +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const comments = await db.taskComment.findMany({ + where: { taskId: id }, + orderBy: { createdAt: 'asc' }, + }) + return NextResponse.json({ comments }) +} + +// POST /api/aos/tasks/[id]/comments — add a comment +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const { body, author } = (await req.json()) as { body: string; author?: string } + if (!body?.trim()) return NextResponse.json({ error: 'body required' }, { status: 400 }) + + const comment = await db.taskComment.create({ + data: { taskId: id, body: body.trim(), author: author ?? 'Max Brandt' }, + }) + + await db.auditLog.create({ + data: { + actor: author ?? 'max@nordlicht.digital', + action: 'task.comment:create', + entity: 'TaskComment', + entityId: comment.id, + after: JSON.stringify({ taskId: id, length: body.length }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ comment }) +} diff --git a/src/app/api/aos/timesheet/route.ts b/src/app/api/aos/timesheet/route.ts new file mode 100644 index 0000000..3dfa768 --- /dev/null +++ b/src/app/api/aos/timesheet/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/timesheet?week=YYYY-MM-DD +// Returns a weekly timesheet grid: projects × days (Mon-Sun) +// with time entries per cell + totals + lock status. +export async function GET(req: Request) { + const { searchParams } = new URL(req.url) + const weekParam = searchParams.get('week') // a date within the target week (YYYY-MM-DD) + const now = new Date() + + let refDate = now + if (weekParam) { + const d = new Date(weekParam + 'T00:00:00Z') + if (!isNaN(d.getTime())) refDate = d + } + + // Calculate the Monday of the target week (ISO week, Monday start) + const day = refDate.getDay() + const offset = day === 0 ? -6 : 1 - day // Sunday=0 → -6; Monday=1 → 0; etc. + const monday = new Date(refDate) + monday.setDate(refDate.getDate() + offset) + monday.setHours(0, 0, 0, 0) + + const days: Date[] = [] + for (let i = 0; i < 7; i++) { + const d = new Date(monday) + d.setDate(monday.getDate() + i) + days.push(d) + } + const sunday = new Date(days[6]!) + sunday.setHours(23, 59, 59, 999) + + // Fetch all time entries in this week + const entries = await db.timeEntry.findMany({ + where: { date: { gte: monday, lte: sunday } }, + include: { project: { select: { id: true, name: true } } }, + orderBy: { date: 'asc' }, + }) + + // Group by project + const projectMap = new Map() + for (const e of entries) { + if (!projectMap.has(e.project.id)) { + projectMap.set(e.project.id, { id: e.project.id, name: e.project.name, entries: [] }) + } + projectMap.get(e.project.id)!.entries.push(e) + } + + // Build grid: per project, per day, sum minutes + const grid = Array.from(projectMap.values()).map((p) => { + const dayData = days.map((d) => { + const dayEntries = p.entries.filter((e) => { + const ed = new Date(e.date) + return ed.getDate() === d.getDate() && ed.getMonth() === d.getMonth() && ed.getFullYear() === d.getFullYear() + }) + const minutes = dayEntries.reduce((s, e) => s + e.minutes, 0) + const locked = dayEntries.every((e) => e.locked) + return { + date: d.toISOString(), + minutes, + locked, + entries: dayEntries.map((e) => ({ id: e.id, description: e.description, minutes: e.minutes, billable: e.billable, locked: e.locked })), + } + }) + const totalMinutes = dayData.reduce((s, d) => s + d.minutes, 0) + return { + project: { id: p.id, name: p.name }, + days: dayData, + totalMinutes, + } + }) + + // Day totals + const dayTotals = days.map((d, i) => ({ + date: d.toISOString(), + label: d.toLocaleDateString('de-DE', { weekday: 'short' }), + dayNum: d.getDate(), + minutes: grid.reduce((s, p) => s + p.days[i]!.minutes, 0), + })) + + const grandTotalMinutes = grid.reduce((s, p) => s + p.totalMinutes, 0) + const billableMinutes = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0) + const lockedCount = entries.filter((e) => e.locked).length + const allLocked = entries.length > 0 && entries.every((e) => e.locked) + + const weekLabel = `${monday.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })} – ${sunday.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })}` + + return NextResponse.json({ + week: { start: monday.toISOString(), end: sunday.toISOString(), label: weekLabel }, + grid, + dayTotals, + stats: { + grandTotalMinutes, + billableMinutes, + lockedCount, + totalEntries: entries.length, + allLocked, + projectCount: grid.length, + }, + }) +} + +// PATCH /api/aos/timesheet — lock all entries in a week (PM approval) +export async function PATCH(req: Request) { + const { week } = (await req.json()) as { week?: string } + if (!week) return NextResponse.json({ error: 'week required (YYYY-MM-DD)' }, { status: 400 }) + + const refDate = new Date(week + 'T00:00:00Z') + if (isNaN(refDate.getTime())) return NextResponse.json({ error: 'invalid date' }, { status: 400 }) + + const day = refDate.getDay() + const offset = day === 0 ? -6 : 1 - day + const monday = new Date(refDate) + monday.setDate(refDate.getDate() + offset) + monday.setHours(0, 0, 0, 0) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + sunday.setHours(23, 59, 59, 999) + + const result = await db.timeEntry.updateMany({ + where: { date: { gte: monday, lte: sunday }, locked: false }, + data: { locked: true }, + }) + + await db.auditLog.create({ + data: { + actor: 'aylin@nordlicht.digital', + action: 'timesheet.lock:week', + entity: 'TimeEntry', + entityId: 'batch', + after: JSON.stringify({ week: monday.toISOString().slice(0, 10), locked: result.count }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ locked: result.count, week: monday.toISOString().slice(0, 10) }) +} diff --git a/src/components/aos/app-shell.tsx b/src/components/aos/app-shell.tsx index 53585c8..61a2545 100644 --- a/src/components/aos/app-shell.tsx +++ b/src/components/aos/app-shell.tsx @@ -14,6 +14,7 @@ import { CrmView } from './views/crm' import { LeadsView } from './views/leads' import { EstimatesView } from './views/estimates' import { ProjectsView } from './views/projects' +import { TimesheetView } from './views/timesheet' import { SupportView } from './views/support' import { LicensesView } from './views/licenses' import { ContractsView } from './views/contracts' @@ -64,6 +65,7 @@ export function AppShell() { {view === 'leads' && } {view === 'estimates' && } {view === 'projects' && } + {view === 'timesheet' && } {view === 'support' && } {view === 'licenses' && } {view === 'contracts' && } diff --git a/src/components/aos/sidebar-nav.tsx b/src/components/aos/sidebar-nav.tsx index e7eeebc..0b2edae 100644 --- a/src/components/aos/sidebar-nav.tsx +++ b/src/components/aos/sidebar-nav.tsx @@ -24,6 +24,7 @@ import { SlidersHorizontal, HelpCircle, CalendarDays, + Clock, } from 'lucide-react' import { useAos, type ViewId } from '@/store/aos' import { translate } from '@/lib/i18n' @@ -55,6 +56,7 @@ const NAV: { group: 'nav.delivery', items: [ { id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' }, + { id: 'timesheet', label: 'Zeiterfassung', icon: Clock, permission: 'projects:read' }, { id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' }, ], }, diff --git a/src/components/aos/top-bar.tsx b/src/components/aos/top-bar.tsx index 0616e9e..81a01fb 100644 --- a/src/components/aos/top-bar.tsx +++ b/src/components/aos/top-bar.tsx @@ -25,6 +25,7 @@ const TITLES: Record = { leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' }, estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' }, projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' }, + timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' }, support: { title: 'Support', crumb: 'Lieferung / Tickets' }, licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' }, contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' }, diff --git a/src/components/aos/views/projects.tsx b/src/components/aos/views/projects.tsx index 5b58ccf..ca02139 100644 --- a/src/components/aos/views/projects.tsx +++ b/src/components/aos/views/projects.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Plus, FolderKanban, @@ -22,7 +22,10 @@ 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' @@ -202,6 +205,7 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: ( 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') @@ -295,12 +299,14 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: ( Hinzufügen - - - - + + + + + setSelectedTask(null)} onUpdated={onUpdated} /> + {/* Live timer mock */}
@@ -417,7 +423,7 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: ( ) } -function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; muted?: boolean }) { +function TaskColumn({ label, tasks, muted, onTaskClick }: { label: string; tasks: Task[]; muted?: boolean; onTaskClick?: (task: Task) => void }) { return (
@@ -428,7 +434,11 @@ function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; mut {tasks.map((t) => { const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle return ( -
+ ) })} {tasks.length === 0 && ( @@ -450,3 +460,113 @@ function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; mut
) } + +// 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 + setLoadingComments(true) + fetch(`/api/aos/tasks/${task.id}/comments`) + .then((r) => r.json()) + .then((d) => setComments(d.comments ?? [])) + .catch(() => {}) + .finally(() => setLoadingComments(false)) + }, [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 */} +
+