63ef43ec-90ce-4fcf-82b4-abff91b2280a
This commit is contained in:
1 parent
8fa25577c0
commit
c71c7e3094
12 files changed
+578
-9
No files matched your search
Binary file not shown.
@@ -177,18 +177,30 @@ model Task {
|
|||||||
priority String @default("medium") // low|medium|high
|
priority String @default("medium") // low|medium|high
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model TimeEntry {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
|
taskId String? // optional link to a specific task
|
||||||
userId String?
|
userId String?
|
||||||
description String
|
description String
|
||||||
minutes Int
|
minutes Int
|
||||||
billable Boolean @default(true)
|
billable Boolean @default(true)
|
||||||
rateCentsPerHour Int?
|
rateCentsPerHour Int?
|
||||||
invoiced Boolean @default(false)
|
invoiced Boolean @default(false)
|
||||||
|
locked Boolean @default(false) // locked after PM approval
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function POST() {
|
|||||||
await db.apiToken.deleteMany()
|
await db.apiToken.deleteMany()
|
||||||
await db.portalApproval.deleteMany()
|
await db.portalApproval.deleteMany()
|
||||||
await db.article.deleteMany()
|
await db.article.deleteMany()
|
||||||
|
await db.taskComment.deleteMany()
|
||||||
await db.estimateLine.deleteMany()
|
await db.estimateLine.deleteMany()
|
||||||
await db.estimate.deleteMany()
|
await db.estimate.deleteMany()
|
||||||
await db.ticketMessage.deleteMany()
|
await db.ticketMessage.deleteMany()
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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<string, { id: string; name: string; entries: typeof entries }>()
|
||||||
|
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) })
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { CrmView } from './views/crm'
|
|||||||
import { LeadsView } from './views/leads'
|
import { LeadsView } from './views/leads'
|
||||||
import { EstimatesView } from './views/estimates'
|
import { EstimatesView } from './views/estimates'
|
||||||
import { ProjectsView } from './views/projects'
|
import { ProjectsView } from './views/projects'
|
||||||
|
import { TimesheetView } from './views/timesheet'
|
||||||
import { SupportView } from './views/support'
|
import { SupportView } from './views/support'
|
||||||
import { LicensesView } from './views/licenses'
|
import { LicensesView } from './views/licenses'
|
||||||
import { ContractsView } from './views/contracts'
|
import { ContractsView } from './views/contracts'
|
||||||
@@ -64,6 +65,7 @@ export function AppShell() {
|
|||||||
{view === 'leads' && <LeadsView />}
|
{view === 'leads' && <LeadsView />}
|
||||||
{view === 'estimates' && <EstimatesView />}
|
{view === 'estimates' && <EstimatesView />}
|
||||||
{view === 'projects' && <ProjectsView />}
|
{view === 'projects' && <ProjectsView />}
|
||||||
|
{view === 'timesheet' && <TimesheetView />}
|
||||||
{view === 'support' && <SupportView />}
|
{view === 'support' && <SupportView />}
|
||||||
{view === 'licenses' && <LicensesView />}
|
{view === 'licenses' && <LicensesView />}
|
||||||
{view === 'contracts' && <ContractsView />}
|
{view === 'contracts' && <ContractsView />}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAos, type ViewId } from '@/store/aos'
|
import { useAos, type ViewId } from '@/store/aos'
|
||||||
import { translate } from '@/lib/i18n'
|
import { translate } from '@/lib/i18n'
|
||||||
@@ -55,6 +56,7 @@ const NAV: {
|
|||||||
group: 'nav.delivery',
|
group: 'nav.delivery',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'projects', label: 'Projekte', icon: FolderKanban, permission: 'projects:read' },
|
{ 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' },
|
{ id: 'support', label: 'Support', icon: LifeBuoy, permission: 'support:read' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const TITLES: Record<ViewId, { title: string; crumb: string }> = {
|
|||||||
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
|
leads: { title: 'Leads Finder', crumb: 'Vertrieb / Leads' },
|
||||||
estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' },
|
estimates: { title: 'Angebote', crumb: 'Vertrieb / Angebote' },
|
||||||
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
|
projects: { title: 'Projekte', crumb: 'Lieferung / Projekte' },
|
||||||
|
timesheet: { title: 'Zeiterfassung', crumb: 'Lieferung / Zeiterfassung' },
|
||||||
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
support: { title: 'Support', crumb: 'Lieferung / Tickets' },
|
||||||
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
licenses: { title: 'Lizenzen', crumb: 'Betrieb / Lizenzen' },
|
||||||
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
contracts: { title: 'Verträge', crumb: 'Betrieb / Verträge' },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
@@ -22,7 +22,10 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
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 { Loader2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PageHeader } from '../primitives'
|
import { PageHeader } from '../primitives'
|
||||||
@@ -202,6 +205,7 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: (
|
|||||||
const [timeMin, setTimeMin] = useState('')
|
const [timeMin, setTimeMin] = useState('')
|
||||||
const [savingTask, setSavingTask] = useState(false)
|
const [savingTask, setSavingTask] = useState(false)
|
||||||
const [savingTime, setSavingTime] = useState(false)
|
const [savingTime, setSavingTime] = useState(false)
|
||||||
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
|
||||||
const todo = project.tasks.filter((t) => t.status === 'todo')
|
const todo = project.tasks.filter((t) => t.status === 'todo')
|
||||||
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
|
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
|
||||||
const review = project.tasks.filter((t) => t.status === 'review')
|
const review = project.tasks.filter((t) => t.status === 'review')
|
||||||
@@ -295,12 +299,14 @@ function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: (
|
|||||||
Hinzufügen
|
Hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TaskColumn label="Offen" tasks={todo} />
|
<TaskColumn label="Offen" tasks={todo} onTaskClick={setSelectedTask} />
|
||||||
<TaskColumn label="In Arbeit" tasks={inProgress} />
|
<TaskColumn label="In Arbeit" tasks={inProgress} onTaskClick={setSelectedTask} />
|
||||||
<TaskColumn label="Review" tasks={review} />
|
<TaskColumn label="Review" tasks={review} onTaskClick={setSelectedTask} />
|
||||||
<TaskColumn label="Erledigt" tasks={done} muted />
|
<TaskColumn label="Erledigt" tasks={done} muted onTaskClick={setSelectedTask} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TaskDetailDrawer task={selectedTask} onClose={() => setSelectedTask(null)} onUpdated={onUpdated} />
|
||||||
|
|
||||||
<TabsContent value="time" className="mt-4 space-y-3">
|
<TabsContent value="time" className="mt-4 space-y-3">
|
||||||
{/* Live timer mock */}
|
{/* Live timer mock */}
|
||||||
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 p-3">
|
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/20 p-3">
|
||||||
@@ -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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1.5 flex items-center gap-2">
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
@@ -428,7 +434,11 @@ function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; mut
|
|||||||
{tasks.map((t) => {
|
{tasks.map((t) => {
|
||||||
const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle
|
const Icon = TASK_ICON[t.status as keyof typeof TASK_ICON] ?? Circle
|
||||||
return (
|
return (
|
||||||
<div key={t.id} className={cn('flex items-center gap-2.5 rounded-md border border-border p-2.5', muted && 'opacity-70')}>
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onTaskClick?.(t)}
|
||||||
|
className={cn('flex w-full items-center gap-2.5 rounded-md border border-border p-2.5 text-left transition hover:border-foreground/40', muted && 'opacity-70')}
|
||||||
|
>
|
||||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-xs">{t.title}</p>
|
<p className="truncate text-xs">{t.title}</p>
|
||||||
@@ -440,7 +450,7 @@ function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; mut
|
|||||||
{t.priority === 'high' && !muted && (
|
{t.priority === 'high' && !muted && (
|
||||||
<Badge variant="outline" className="text-[9px]">hoch</Badge>
|
<Badge variant="outline" className="text-[9px]">hoch</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
@@ -450,3 +460,113 @@ function TaskColumn({ label, tasks, muted }: { label: string; tasks: Task[]; mut
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<string, { label: string; cls: string }> = {
|
||||||
|
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 (
|
||||||
|
<Sheet open={!!task} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<SheetContent side="right" className="w-full overflow-hidden p-0 sm:max-w-md">
|
||||||
|
<SheetTitle className="sr-only">Aufgabendetails</SheetTitle>
|
||||||
|
{task && (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-border p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h2 className="text-base font-semibold leading-tight">{task.title}</h2>
|
||||||
|
<Badge variant="outline" className={cn('shrink-0 text-[10px]', (statusMeta[task.status] ?? statusMeta.todo).cls)}>
|
||||||
|
{(statusMeta[task.status] ?? statusMeta.todo).label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{task.assignee && <p className="mt-1 text-xs text-muted-foreground">Zugewiesen an: {task.assignee}</p>}
|
||||||
|
{task.estimateMin && <p className="mt-0.5 text-xs text-muted-foreground">Schätzung: ~{formatMinutes(task.estimateMin)}</p>}
|
||||||
|
{task.priority === 'high' && <Badge variant="outline" className="mt-2 text-[10px]">Hohe Priorität</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">Kommentare ({comments.length})</p>
|
||||||
|
{loadingComments && <Skeleton className="h-16 w-full" />}
|
||||||
|
{comments.map((c) => (
|
||||||
|
<div key={c.id} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">{c.author}</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground">{new Date(c.createdAt).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!loadingComments && comments.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-xs text-muted-foreground">Noch keine Kommentare.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Add comment */}
|
||||||
|
<div className="border-t border-border p-3 space-y-2">
|
||||||
|
<Textarea
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
placeholder="Kommentar hinzufügen…"
|
||||||
|
className="min-h-[60px] text-xs"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) addComment() }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-muted-foreground">⌘+Enter zum Senden</span>
|
||||||
|
<Button size="sm" onClick={addComment} disabled={posting || !newComment.trim()} className="gap-1.5">
|
||||||
|
{posting ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Senden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from '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 { PageHeader } from '../primitives'
|
||||||
|
import { formatMinutes } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { ChevronLeft, ChevronRight, Lock, Clock, TrendingUp, CheckCircle2, Calendar } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DayData {
|
||||||
|
date: string
|
||||||
|
minutes: number
|
||||||
|
locked: boolean
|
||||||
|
entries: { id: string; description: string; minutes: number; billable: boolean; locked: boolean }[]
|
||||||
|
}
|
||||||
|
interface GridRow {
|
||||||
|
project: { id: string; name: string }
|
||||||
|
days: DayData[]
|
||||||
|
totalMinutes: number
|
||||||
|
}
|
||||||
|
interface TimesheetData {
|
||||||
|
week: { start: string; end: string; label: string }
|
||||||
|
grid: GridRow[]
|
||||||
|
dayTotals: { date: string; label: string; dayNum: number; minutes: number }[]
|
||||||
|
stats: { grandTotalMinutes: number; billableMinutes: number; lockedCount: number; totalEntries: number; allLocked: boolean; projectCount: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
const WEEKEND = [5, 6] // Saturday, Sunday
|
||||||
|
|
||||||
|
export function TimesheetView() {
|
||||||
|
const now = new Date()
|
||||||
|
const [weekParam, setWeekParam] = useState(`${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`)
|
||||||
|
const { data, loading, refetch } = useFetch<TimesheetData>(`/api/aos/timesheet?week=${weekParam}`)
|
||||||
|
const [locking, setLocking] = useState(false)
|
||||||
|
|
||||||
|
const navigateWeek = (delta: number) => {
|
||||||
|
const d = new Date(weekParam + 'T00:00:00Z')
|
||||||
|
d.setDate(d.getDate() + delta * 7)
|
||||||
|
setWeekParam(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockWeek = async () => {
|
||||||
|
setLocking(true)
|
||||||
|
const t = toast.loading('Woche wird gesperrt (PM-Freigabe)…')
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/aos/timesheet', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ week: weekParam }),
|
||||||
|
})
|
||||||
|
const res = await r.json()
|
||||||
|
if (!r.ok) throw new Error(res.error || 'lock failed')
|
||||||
|
toast.success(`${res.locked} Einträge gesperrt (Audit protokolliert)`, { id: t })
|
||||||
|
refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Sperren fehlgeschlagen', { id: t })
|
||||||
|
} finally {
|
||||||
|
setLocking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading || !data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<Skeleton className="h-9 w-40" />
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { grid, dayTotals, stats, week } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Zeiterfassung"
|
||||||
|
description="Wochen-basierte Zeiterfassung mit PM-Freigabe (Lock). Nach Freigabe können Einträge nicht mehr geändert werden — GoBD-konform."
|
||||||
|
actions={
|
||||||
|
!stats.allLocked && stats.totalEntries > 0 ? (
|
||||||
|
<Button size="sm" onClick={lockWeek} disabled={locking} className="gap-1.5">
|
||||||
|
<Lock className="size-4" />
|
||||||
|
Woche sperren
|
||||||
|
</Button>
|
||||||
|
) : stats.allLocked ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<CheckCircle2 className="size-3" />Gesperrt
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Week navigation */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigateWeek(-1)}>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">{week.label}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigateWeek(1)}>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="ml-2 h-8 text-xs" onClick={() => {
|
||||||
|
const d = new Date()
|
||||||
|
setWeekParam(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||||
|
}}>
|
||||||
|
Diese Woche
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPIs */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Gesamt</p><Clock className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(stats.grandTotalMinutes)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Abrechenbar</p><TrendingUp className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatMinutes(stats.billableMinutes)}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Projekte</p><CheckCircle2 className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.projectCount}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between"><p className="text-xs text-muted-foreground">Gesperrt</p><Lock className="size-3.5 text-muted-foreground" /></div>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{stats.lockedCount}/{stats.totalEntries}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timesheet grid */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="border-b border-border bg-muted/40">
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 z-10 bg-muted/40 px-4 py-2 text-left text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Projekt</th>
|
||||||
|
{dayTotals.map((d, i) => (
|
||||||
|
<th key={i} className={cn('px-3 py-2 text-center text-[11px] font-medium', WEEKEND.includes(i) && 'text-muted-foreground/50')}>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<span className="uppercase tracking-wide">{d.label}</span>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{d.dayNum}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-4 py-2 text-right text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Σ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{grid.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="py-10 text-center text-xs text-muted-foreground">
|
||||||
|
Keine Zeiteinträge in dieser Woche. Erfasse Zeit unter „Projekte".
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{grid.map((row) => (
|
||||||
|
<tr key={row.project.id} className="transition hover:bg-muted/30">
|
||||||
|
<td className="sticky left-0 z-10 bg-background px-4 py-3">
|
||||||
|
<p className="truncate text-xs font-medium">{row.project.name}</p>
|
||||||
|
</td>
|
||||||
|
{row.days.map((day, i) => (
|
||||||
|
<td key={i} className={cn('px-3 py-3 text-center', WEEKEND.includes(i) && 'bg-muted/20')}>
|
||||||
|
{day.minutes > 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<span className={cn('text-xs font-semibold tabular-nums', day.locked && 'text-muted-foreground')}>
|
||||||
|
{Math.floor(day.minutes / 60)}:{String(day.minutes % 60).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
{day.locked && <Lock className="size-2.5 text-muted-foreground/60" />}
|
||||||
|
{day.entries.length > 1 && <span className="text-[9px] text-muted-foreground">{day.entries.length}× Einträge</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/30">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-4 py-3 text-right text-xs font-bold tabular-nums">
|
||||||
|
{Math.floor(row.totalMinutes / 60)}:{String(row.totalMinutes % 60).padStart(2, '0')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="border-t-2 border-border bg-muted/40">
|
||||||
|
<tr>
|
||||||
|
<td className="sticky left-0 z-10 bg-muted/40 px-4 py-2.5 text-xs font-semibold">Tagessumme</td>
|
||||||
|
{dayTotals.map((d, i) => (
|
||||||
|
<td key={i} className={cn('px-3 py-2.5 text-center text-xs font-semibold tabular-nums', WEEKEND.includes(i) && 'text-muted-foreground/50')}>
|
||||||
|
{d.minutes > 0 ? `${Math.floor(d.minutes / 60)}:${String(d.minutes % 60).padStart(2, '0')}` : '—'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-4 py-2.5 text-right text-sm font-bold tabular-nums">
|
||||||
|
{Math.floor(stats.grandTotalMinutes / 60)}:{String(stats.grandTotalMinutes % 60).padStart(2, '0')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Entry details */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm">Einträge dieser Woche ({stats.totalEntries})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1.5">
|
||||||
|
{grid.flatMap((row) => row.days.flatMap((day) => day.entries.map((e) => ({ ...e, projectName: row.project.name, date: day.date })))).map((e) => (
|
||||||
|
<div key={e.id} className="flex items-center gap-3 rounded-md border border-border p-2">
|
||||||
|
{e.locked ? <Lock className="size-3 text-muted-foreground" /> : <Clock className="size-3 text-muted-foreground" />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium">{e.description}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{e.projectName} · {new Date(e.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{e.billable && <Badge variant="outline" className="text-[9px]">billable</Badge>}
|
||||||
|
<span className="text-xs font-semibold tabular-nums">{formatMinutes(e.minutes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stats.totalEntries === 0 && <p className="py-4 text-center text-xs text-muted-foreground">Keine Einträge.</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Lock info */}
|
||||||
|
<div className="rounded-md border border-dashed border-border p-3 text-[11px] text-muted-foreground">
|
||||||
|
<p className="flex items-center gap-1.5 font-medium text-foreground"><Lock className="size-3.5" />Wochen-Sperre (PM-Freigabe)</p>
|
||||||
|
<p className="mt-1">Nach der Freigabe durch den Projektmanager werden alle Zeiteinträge der Woche gesperrt (locked=true). Gesperrte Einträge können nicht mehr geändert werden — GoBD-konform. Die Sperrung wird im Audit-Log protokolliert.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+8
-1
@@ -315,7 +315,14 @@ async function main() {
|
|||||||
]
|
]
|
||||||
for (const t of tasks) {
|
for (const t of tasks) {
|
||||||
const { project, ...rest } = t
|
const { project, ...rest } = t
|
||||||
await db.task.create({ data: { ...rest, projectId: projectMap[project] } })
|
const created = await db.task.create({ data: { ...rest, projectId: projectMap[project] } })
|
||||||
|
// add a comment to some tasks
|
||||||
|
if (t.title.includes('SSO') || t.title.includes('Stripe') || t.title.includes('BFSG')) {
|
||||||
|
await db.taskComment.create({ data: { taskId: created.id, author: 'Aylin Demir', body: 'Habe das mal priorisiert — Kunde drängt auf Abschluss diese Woche.' } })
|
||||||
|
}
|
||||||
|
if (t.title.includes('Onboarding')) {
|
||||||
|
await db.taskComment.create({ data: { taskId: created.id, author: 'Tom Becker', body: 'Onboarding-Flow steht, brauchen noch Feedback zum Design.' } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Time entries -------------------------------------------------------
|
// --- Time entries -------------------------------------------------------
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ViewId =
|
|||||||
| 'leads'
|
| 'leads'
|
||||||
| 'estimates'
|
| 'estimates'
|
||||||
| 'projects'
|
| 'projects'
|
||||||
|
| 'timesheet'
|
||||||
| 'support'
|
| 'support'
|
||||||
| 'licenses'
|
| 'licenses'
|
||||||
| 'contracts'
|
| 'contracts'
|
||||||
|
|||||||
Reference in new issue
Block a user