From c71c7e3094cc38c2ebe18c0296f2d5b2d99c8405 Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 01:02:02 +0000 Subject: [PATCH] 63ef43ec-90ce-4fcf-82b4-abff91b2280a --- db/custom.db | Bin 323584 -> 331776 bytes prisma/schema.prisma | 12 + src/app/api/aos/seed/route.ts | 1 + src/app/api/aos/tasks/[id]/comments/route.ts | 44 ++++ src/app/api/aos/timesheet/route.ts | 140 +++++++++++ src/components/aos/app-shell.tsx | 2 + src/components/aos/sidebar-nav.tsx | 2 + src/components/aos/top-bar.tsx | 1 + src/components/aos/views/projects.tsx | 136 ++++++++++- src/components/aos/views/timesheet.tsx | 239 +++++++++++++++++++ src/lib/seed.ts | 9 +- src/store/aos.ts | 1 + 12 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 src/app/api/aos/tasks/[id]/comments/route.ts create mode 100644 src/app/api/aos/timesheet/route.ts create mode 100644 src/components/aos/views/timesheet.tsx diff --git a/db/custom.db b/db/custom.db index 4dcad710b7733d02a2f3a4237cd7f697a844ae9a..1d304c5d9891b3bfd0cf96221edcffe9c5439161 100644 GIT binary patch delta 28886 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 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 */} +
+