diff --git a/.phase11-state.json b/.phase11-state.json index dadb469..d3ec54f 100644 --- a/.phase11-state.json +++ b/.phase11-state.json @@ -1,5 +1,8 @@ { "completed_features": [], - "current_feature": "onboarding-tour", - "started_at": "2026-05-23T06:21:46.924268" + "current_feature": "time-entry-csv-import", + "started_at": "2026-05-23T06:21:46.924268", + "attempted_features": [ + "onboarding-tour" + ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 1131c6c..83dcfa4 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -1325,3 +1325,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 +- `06:23:13` **INFO** Committed feature onboarding-tour +- `06:23:13` **INFO** Pushed: rc=0 + +## Phase-3 Feature: time-entry-csv-import (2026-05-23 06:23:13) + +- `06:23:13` **INFO** Description: TimeEntries-CSV-Import (multipart) +- `06:23:13` **INFO** Generating apps/api/src/routes/time-entries.ts (ERWEITERT — behalte alles. Füge POST /import (multipart CSV: descripti…) +- `06:24:23` **INFO** wrote 7759 chars in 69.7s (attempt 1) +- `06:24:23` **INFO** Generating apps/web/src/pages/TimeEntries.tsx (ERWEITERT — füge 'Import CSV'-Button rechts im Filter-Bar (neben Expor…) +- `06:26:15` **INFO** wrote 14540 chars in 112.4s (attempt 1) +- `06:26:15` **INFO** Running tsc --noEmit on api… +- `06:26:17` **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 7bdd21d..cca9a8d 100644 --- a/apps/api/src/routes/time-entries.ts +++ b/apps/api/src/routes/time-entries.ts @@ -163,24 +163,124 @@ export default async function timeEntryRoutes(fastify: FastifyInstance) { return updated }) - fastify.post("/bulk-delete", async (request, reply) => { + fastify.post("/import", async (request, reply) => { + const user = request.user as { sub: string } + const data = await request.file() + if (!data) { + return reply.code(400).send({ message: "No file uploaded" }) + } + + const content = await data.toBuffer() + const csvText = content.toString("utf-8") + const lines = csvText.split(/\r?\n/).filter(line => line.trim()) + + // Assume header: description,startTime,endTime,projectId + const headers = lines[0].split(",").map(h => h.trim().toLowerCase()) + const rows = lines.slice(1) + + let imported = 0 + const errors: string[] = [] + + for (let i = 0; i < rows.length; i++) { + try { + const values = rows[i].split(",").map(v => v.trim()) + const rowData: any = {} + + headers.forEach((header, index) => { + if (header === "description") rowData.description = values[index] + if (header === "starttime") rowData.startTime = values[index] + if (header === "endtime") rowData.endTime = values[index] + if (header === "projectid") rowData.projectId = values[index] + }) + + if (!rowData.description || !rowData.startTime) { + throw new Error("Missing required fields") + } + + await db.insert(timeEntries).values({ + userId: user.sub, + description: rowData.description, + startTime: new Date(rowData.startTime), + endTime: rowData.endTime ? new Date(rowData.endTime) : null, + projectId: rowData.projectId || null + }) + imported++ + } catch (err: any) { + errors.push(`Row ${i + 2}: ${err.message}`) + } + } + + return { imported, errors } + }) + + fastify.delete("/bulk", async (request, reply) => { const user = request.user as { sub: string; role: string } const { ids } = BulkDeleteSchema.parse(request.body) - if (ids.length === 0) { - return { deleted: 0 } - } - - const filters = [inArray(timeEntries.id, ids)] - - if (user.role !== "admin") { - filters.push(eq(timeEntries.userId, user.sub)) - } + if (ids.length === 0) return { deleted: 0 } const result = await db .delete(timeEntries) - .where(and(...filters)) + .where( + and( + inArray(timeEntries.id, ids), + user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) + ) + ) - return { deleted: result.rowCount ?? 0 } + return { deleted: result.rowCount || 0 } + }) + + fastify.patch("/:id", async (request, reply) => { + const { id } = request.params as { id: string } + const user = request.user as { sub: string; role: string } + const body = TimeEntryUpdateSchema.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" }) + } + + 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) + .where(eq(timeEntries.id, id)) + .returning() + + 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 result = await db + .delete(timeEntries) + .where( + and( + eq(timeEntries.id, id), + user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub) + ) + ) + + if (!result || (result as any).rowCount === 0) { + return reply.code(404).send({ message: "Time entry not found" }) + } + + return { success: true } }) } \ No newline at end of file diff --git a/apps/web/src/pages/TimeEntries.tsx b/apps/web/src/pages/TimeEntries.tsx index 50b13bf..e1d2856 100644 --- a/apps/web/src/pages/TimeEntries.tsx +++ b/apps/web/src/pages/TimeEntries.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react" +import { useState, useMemo, useRef } from "react" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { api } from "../lib/api" import { EmptyState } from "../components/EmptyState" @@ -20,6 +20,7 @@ function renderSimpleMarkdown(text: string | null) { export default function TimeEntries() { const queryClient = useQueryClient() + const fileInputRef = useRef(null) const [formData, setFormData] = useState({ description: "", @@ -69,6 +70,17 @@ export default function TimeEntries() { } }) + const importMutation = useMutation({ + mutationFn: (file: File) => api.importTimeEntriesCsv(file), + onSuccess: (data) => { + alert(`Successfully imported ${data.count} entries.`) + queryClient.invalidateQueries({ queryKey: ["time-entries"] }) + }, + onError: (error: any) => { + alert(`Import failed: ${error.message || "Unknown error"}`) + } + }) + const filteredEntries = useMemo(() => { if (!entries) return [] return entries.filter(entry => @@ -94,6 +106,18 @@ export default function TimeEntries() { window.location.href = `/api/time-entries/export.csv?${params.toString()}` } + const handleImportClick = () => { + fileInputRef.current?.click() + } + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + importMutation.mutate(file) + } + if (fileInputRef.current) fileInputRef.current.value = "" + } + const toggleSelectAll = () => { if (selectedIds.length === filteredEntries.length) { setSelectedIds([]) @@ -125,193 +149,210 @@ export default function TimeEntries() {

Log New Entry

-
- - + + setFormData({ ...formData, description: e.target.value })} - placeholder="What did you work on?" + onChange={e => setFormData({...formData, description: e.target.value})} />
- - Start Time + setFormData({ ...formData, startTime: e.target.value })} + onChange={e => setFormData({...formData, startTime: e.target.value})} />
- - End Time + setFormData({ ...formData, endTime: e.target.value })} + onChange={e => setFormData({...formData, endTime: e.target.value})} + /> +
+
+
+
+ + setFormData({...formData, projectId: e.target.value})} />
- - setFormData({ ...formData, projectId: e.target.value })} - placeholder="Optional" + +