diff --git a/.phase4-state.json b/.phase4-state.json index e27e6fc..8eabbf7 100644 --- a/.phase4-state.json +++ b/.phase4-state.json @@ -1,5 +1,7 @@ { - "completed_features": [], - "current_feature": "admin-user-management", + "completed_features": [ + "admin-user-management" + ], + "current_feature": "csv-export-time-entries", "started_at": "2026-05-23T05:10:51.482879" } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 844b4a5..a43efe2 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -471,3 +471,15 @@ undefined - `05:12:47` **INFO** wrote 9929 chars in 82.5s (attempt 1) - `05:12:47` **INFO** Running tsc --noEmit on api… - `05:12:48` **INFO** tsc clean ✓ +- `05:12:48` **INFO** Committed feature admin-user-management +- `05:12:49` **INFO** Pushed: rc=0 + +## Phase-3 Feature: csv-export-time-entries (2026-05-23 05:12:49) + +- `05:12:49` **INFO** Description: CSV-Export-Endpoint + Button in TimeEntries-Page +- `05:12:49` **INFO** Generating apps/api/src/routes/time-entries.ts (ERWEITERTE time-entries.ts — behalte alle bestehenden Routes (CRUD + r…) +- `05:13:52` **INFO** wrote 6894 chars in 63.2s (attempt 1) +- `05:13:52` **INFO** Generating apps/web/src/pages/TimeEntries.tsx (ERWEITERT — behalte Form + Filter + Liste. Füge Export-Button im Filte…) +- `05:15:03` **INFO** wrote 8846 chars in 71.0s (attempt 1) +- `05:15:03` **INFO** Running tsc --noEmit on api… +- `05:15:04` **INFO** tsc clean ✓ diff --git a/apps/api/src/routes/time-entries.ts b/apps/api/src/routes/time-entries.ts index b7179c2..ab22d34 100644 --- a/apps/api/src/routes/time-entries.ts +++ b/apps/api/src/routes/time-entries.ts @@ -48,7 +48,7 @@ export default async function timeEntryRoutes(fastify: FastifyInstance) { const entries = await db .select() .from(timeEntries) - .where(and(...filters)) + .where(filters.length ? and(...filters) : undefined as any) .orderBy(timeEntries.startTime) return entries @@ -179,13 +179,13 @@ export default async function timeEntryRoutes(fastify: FastifyInstance) { return reply.code(404).send({ message: "Time entry not found" }) } - const updateData: any = { ...body } - if (body.startTime) updateData.startTime = new Date(body.startTime) - if (body.endTime) updateData.endTime = body.endTime ? new Date(body.endTime) : null - const [updated] = await db .update(timeEntries) - .set(updateData) + .set({ + ...body, + startTime: body.startTime ? new Date(body.startTime) : undefined, + endTime: body.endTime ? new Date(body.endTime) : (body.endTime === null ? null : undefined) + }) .where(eq(timeEntries.id, id)) .returning() @@ -212,7 +212,49 @@ export default async function timeEntryRoutes(fastify: FastifyInstance) { } await db.delete(timeEntries).where(eq(timeEntries.id, id)) - return reply.code(204).send() }) + + fastify.get("/export", async (request, reply) => { + const { from, to } = request.query as { from?: string; to?: string } + const user = request.user as { sub: string; role: string } + + const filters = [] + if (user.role !== "admin") { + filters.push(eq(timeEntries.userId, user.sub)) + } + if (from) { + filters.push(gte(timeEntries.startTime, new Date(from))) + } + if (to) { + filters.push(lte(timeEntries.startTime, new Date(to))) + } + + const entries = await db + .select() + .from(timeEntries) + .where(filters.length ? and(...filters) : undefined as any) + .orderBy(timeEntries.startTime) + + const header = "id,description,projectId,startTime,endTime,durationMinutes\n" + const rows = entries.map(e => { + const start = new Date(e.startTime) + const end = e.endTime ? new Date(e.endTime) : null + const duration = end ? Math.round((end.getTime() - start.getTime()) / 60000) : "" + + return [ + e.id, + `"${e.description.replace(/"/g, '""')}"`, + e.projectId || "", + start.toISOString(), + end ? end.toISOString() : "", + duration + ].join(",") + }).join("\n") + + reply + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=time-entries.csv") + .send(header + rows) + }) } \ No newline at end of file diff --git a/apps/web/src/pages/TimeEntries.tsx b/apps/web/src/pages/TimeEntries.tsx index 29f2aa5..5bf4d20 100644 --- a/apps/web/src/pages/TimeEntries.tsx +++ b/apps/web/src/pages/TimeEntries.tsx @@ -61,6 +61,13 @@ export default function TimeEntries() { }) } + const handleExport = () => { + const params = new URLSearchParams() + if (filters.from) params.append("from", filters.from) + if (filters.to) params.append("to", filters.to) + window.location.href = `/api/time-entries/export.csv?${params.toString()}` + } + if (isError) return
Error loading time entries.
return ( @@ -105,90 +112,95 @@ export default function TimeEntries() { />
-
-
-
- - setFilters({ ...filters, search: e.target.value })} - /> -
-
- - setFilters({ ...filters, from: e.target.value })} - /> -
-
- - setFilters({ ...filters, to: e.target.value })} - /> +
+
+
+ + setFilters({ ...filters, search: e.target.value })} + placeholder="Filter by description..." + /> +
+
+ + setFilters({ ...filters, from: e.target.value })} + /> +
+
+ + setFilters({ ...filters, to: e.target.value })} + /> +
+
{isLoading ? ( -
- -
+
) : filteredEntries.length === 0 ? ( - + ) : ( -
- - - - - - - +
+
DescriptionStartEndActions
+ + + + + + + - {filteredEntries.map((entry) => ( - - - - - - - ))} + {filteredEntries.map((entry) => { + const duration = entry.endTime && entry.startTime + ? (new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime()) / 3600000 + : 0 + return ( + + + + + + + + ) + })}
DescriptionStartEndDurationActions
{entry.description} - {new Date(entry.startTime).toLocaleString()} - - {new Date(entry.endTime).toLocaleString()} - - -
{entry.description}{new Date(entry.startTime).toLocaleString()}{new Date(entry.endTime).toLocaleString()}{duration.toFixed(2)}h + +