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 })}
- />
+
{isLoading ? (
-
-
-
+
) : filteredEntries.length === 0 ? (
-
+
) : (
-
-
-
-
- | Description |
- Start |
- End |
- Actions |
+
+
+
+
+ | Description |
+ Start |
+ End |
+ Duration |
+ Actions |
- {filteredEntries.map((entry) => (
-
- | {entry.description} |
-
- {new Date(entry.startTime).toLocaleString()}
- |
-
- {new Date(entry.endTime).toLocaleString()}
- |
-
-
- |
-
- ))}
+ {filteredEntries.map((entry) => {
+ const duration = entry.endTime && entry.startTime
+ ? (new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime()) / 3600000
+ : 0
+ return (
+
+ | {entry.description} |
+ {new Date(entry.startTime).toLocaleString()} |
+ {new Date(entry.endTime).toLocaleString()} |
+ {duration.toFixed(2)}h |
+
+
+ |
+
+ )
+ })}