diff --git a/.phase8-state.json b/.phase8-state.json index 199c123..ee3b3a5 100644 --- a/.phase8-state.json +++ b/.phase8-state.json @@ -1,5 +1,8 @@ { "completed_features": [], - "current_feature": "recent-activity-widget", - "started_at": "2026-05-23T05:49:48.673340" + "current_feature": "time-entry-bulk-actions", + "started_at": "2026-05-23T05:49:48.673340", + "attempted_features": [ + "recent-activity-widget" + ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 4bc2de2..66834da 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -909,3 +909,22 @@ src/index.ts(27,25): error TS2769: No overload matches this call. Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy +- `05:51:29` **INFO** Committed feature recent-activity-widget +- `05:51:29` **INFO** Pushed: rc=0 + +## Phase-3 Feature: time-entry-bulk-actions (2026-05-23 05:51:29) + +- `05:51:29` **INFO** Description: Bulk-Select + bulk-delete in TimeEntries-Page +- `05:51:29` **INFO** Generating apps/api/src/routes/time-entries.ts (ERWEITERT — behalte alles. Füge POST /bulk-delete (body: {ids: string[…) +- `05:52:12` **INFO** wrote 4619 chars in 42.5s (attempt 1) +- `05:52:12` **INFO** Generating apps/web/src/pages/TimeEntries.tsx (ERWEITERT — behalte bestehendes UI. Füge Checkbox-Spalte links in Tabl…) +- `05:53:40` **INFO** wrote 11075 chars in 88.5s (attempt 1) +- `05:53:40` **INFO** Running tsc --noEmit on api… +- `05:53:42` **WARN** tsc errors: +src/index.ts(27,25): error TS2769: No overload matches this call. + Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'. + Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy diff --git a/apps/api/src/routes/time-entries.ts b/apps/api/src/routes/time-entries.ts index ab22d34..7bdd21d 100644 --- a/apps/api/src/routes/time-entries.ts +++ b/apps/api/src/routes/time-entries.ts @@ -1,7 +1,7 @@ import { FastifyInstance } from "fastify" import { db } from "../db" import { timeEntries } from "../db/schema" -import { eq, and, gte, lte, isNull } from "drizzle-orm" +import { eq, and, gte, lte, isNull, inArray } from "drizzle-orm" import { z } from "zod" const TimeEntrySchema = z.object({ @@ -18,6 +18,10 @@ const StartEntrySchema = z.object({ description: z.string().min(1) }) +const BulkDeleteSchema = z.object({ + ids: z.array(z.string().uuid()) +}) + export default async function timeEntryRoutes(fastify: FastifyInstance) { fastify.addHook("preHandler", async (request, reply) => { try { @@ -159,102 +163,24 @@ export default async function timeEntryRoutes(fastify: FastifyInstance) { return updated }) - fastify.patch("/:id", async (request, reply) => { - const { id } = request.params as { id: string } + fastify.post("/bulk-delete", async (request, reply) => { const user = request.user as { sub: string; role: string } - const body = TimeEntryUpdateSchema.parse(request.body) + const { ids } = BulkDeleteSchema.parse(request.body) - const [entry] = await db - .select() - .from(timeEntries) - .where( - and( - eq(timeEntries.id, id), - user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) - ) - ) - .limit(1) - - if (!entry) { - return reply.code(404).send({ message: "Time entry not found" }) + if (ids.length === 0) { + return { deleted: 0 } } - const [updated] = await db - .update(timeEntries) - .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() + const filters = [inArray(timeEntries.id, ids)] - return updated - }) - - fastify.delete("/:id", async (request, reply) => { - const { id } = request.params as { id: string } - const user = request.user as { sub: string; role: string } - - const [entry] = await db - .select() - .from(timeEntries) - .where( - and( - eq(timeEntries.id, id), - user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) - ) - ) - .limit(1) - - if (!entry) { - return reply.code(404).send({ message: "Time entry not found" }) - } - - 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 result = await db + .delete(timeEntries) + .where(and(...filters)) - 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) + return { deleted: result.rowCount ?? 0 } }) } \ No newline at end of file diff --git a/apps/web/src/pages/TimeEntries.tsx b/apps/web/src/pages/TimeEntries.tsx index 5bf4d20..1952a22 100644 --- a/apps/web/src/pages/TimeEntries.tsx +++ b/apps/web/src/pages/TimeEntries.tsx @@ -21,6 +21,8 @@ export default function TimeEntries() { to: "" }) + const [selectedIds, setSelectedIds] = useState([]) + const { data: entries, isLoading, isError } = useQuery({ queryKey: ["time-entries", filters.from, filters.to], queryFn: () => api.listTimeEntries({ @@ -44,6 +46,14 @@ export default function TimeEntries() { } }) + const bulkDeleteMutation = useMutation({ + mutationFn: (ids: string[]) => api.bulkDeleteTimeEntries(ids), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["time-entries"] }) + setSelectedIds([]) + } + }) + const filteredEntries = useMemo(() => { if (!entries) return [] return entries.filter(entry => @@ -68,6 +78,20 @@ export default function TimeEntries() { window.location.href = `/api/time-entries/export.csv?${params.toString()}` } + const toggleSelectAll = () => { + if (selectedIds.length === filteredEntries.length) { + setSelectedIds([]) + } else { + setSelectedIds(filteredEntries.map(e => e.id)) + } + } + + const toggleSelect = (id: string) => { + setSelectedIds(prev => + prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id] + ) + } + if (isError) return
Error loading time entries.
return ( @@ -111,89 +135,121 @@ export default function TimeEntries() { onChange={(e) => setFormData({ ...formData, endTime: e.target.value })} /> -
+
+ + setFormData({ ...formData, projectId: e.target.value })} + placeholder="Optional" + /> +
+
-
-
-
- - setFilters({ ...filters, search: e.target.value })} - placeholder="Filter by description..." - /> -
-
- - setFilters({ ...filters, from: e.target.value })} - /> -
-
- - setFilters({ ...filters, to: e.target.value })} - /> -
+
+
+ setFilters({ ...filters, search: e.target.value })} + /> + setFilters({ ...filters, from: e.target.value })} + /> + setFilters({ ...filters, to: e.target.value })} + />
+ {selectedIds.length > 0 && ( +
+ + {selectedIds.length} entries selected + + +
+ )} + {isLoading ? (
) : filteredEntries.length === 0 ? ( - + ) : ( -
+
- + + - + - + {filteredEntries.map((entry) => { - const duration = entry.endTime && entry.startTime - ? (new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime()) / 3600000 - : 0 + const durationMs = new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime() + const durationHrs = (durationMs / (1000 * 60 * 60)).toFixed(2) + return ( - + + - +
+ 0} + onChange={toggleSelectAll} + /> + Description Start EndDurationDuration Actions
+ toggleSelect(entry.id)} + /> + {entry.description} {new Date(entry.startTime).toLocaleString()} {new Date(entry.endTime).toLocaleString()}{duration.toFixed(2)}h{durationHrs}h