diff --git a/.phase10-state.json b/.phase10-state.json index f39a336..8e24fcc 100644 --- a/.phase10-state.json +++ b/.phase10-state.json @@ -1,8 +1,9 @@ { "completed_features": [], - "current_feature": "customer-tags", + "current_feature": "project-templates", "started_at": "2026-05-23T06:10:51.530595", "attempted_features": [ - "markdown-notes-time-entry" + "markdown-notes-time-entry", + "customer-tags" ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index dec1b3f..5c283a3 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -1189,3 +1189,24 @@ 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:14:38` **INFO** Committed feature customer-tags +- `06:14:38` **INFO** Pushed: rc=0 + +## Phase-3 Feature: project-templates (2026-05-23 06:14:38) + +- `06:14:38` **INFO** Description: Wiederverwendbare Project-Templates (admin) +- `06:14:38` **INFO** Generating apps/api/src/db/schema.ts (ERWEITERT — füge `projectTemplates` Tabelle: id (uuid pk), name (text)…) +- `06:15:07` **INFO** wrote 3376 chars in 28.9s (attempt 1) +- `06:15:07` **INFO** Generating apps/api/src/routes/project-templates.ts (Fastify-Plugin /api/project-templates. CRUD, admin-only via preHandler…) +- `06:15:29` **INFO** wrote 2492 chars in 22.0s (attempt 1) +- `06:15:29` **INFO** Generating apps/web/src/pages/ProjectTemplates.tsx (ProjectTemplates-Page (admin-only). Liste + Create-Form (name, default…) +- `06:16:19` **INFO** wrote 6105 chars in 50.4s (attempt 1) +- `06:16:19` **INFO** Running tsc --noEmit on api… +- `06:16:21` **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/db/schema.ts b/apps/api/src/db/schema.ts index c194cb0..7cced23 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -31,6 +31,14 @@ export const projects = pgTable("projects", { createdAt: timestamp("created_at").notNull().defaultNow() }) +export const projectTemplates = pgTable("project_templates", { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + defaultBillable: boolean("default_billable").notNull().default(true), + estimatedHours: integer("estimated_hours"), + createdAt: timestamp("created_at").notNull().defaultNow() +}) + export const timeEntries = pgTable("time_entries", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), diff --git a/apps/api/src/routes/project-templates.ts b/apps/api/src/routes/project-templates.ts new file mode 100644 index 0000000..4d6f2d5 --- /dev/null +++ b/apps/api/src/routes/project-templates.ts @@ -0,0 +1,98 @@ +import { FastifyInstance } from "fastify" +import { db } from "../db" +import { projectTemplates } from "../db/schema" +import { eq } from "drizzle-orm" +import { z } from "zod" + +const ProjectTemplateSchema = z.object({ + name: z.string().min(1), + description: z.string().optional(), +}) + +const ProjectTemplateUpdateSchema = ProjectTemplateSchema.partial() + +export default async function projectTemplateRoutes(fastify: FastifyInstance) { + fastify.addHook("preHandler", async (request, reply) => { + try { + await request.jwtVerify() + } catch (err) { + return reply.code(401).send({ message: "Unauthorized" }) + } + + const user = request.user as any + if (user?.role !== "admin") { + return reply.code(403).send({ message: "Admin privileges required" }) + } + }) + + fastify.get("/", async (request, reply) => { + const results = await db + .select() + .from(projectTemplates) + .orderBy(projectTemplates.name) + + return results + }) + + fastify.get("/:id", async (request, reply) => { + const { id } = request.params as { id: string } + + const [template] = await db + .select() + .from(projectTemplates) + .where(eq(projectTemplates.id, id)) + .limit(1) + + if (!template) { + return reply.code(404).send({ message: "Project template not found" }) + } + + return template + }) + + fastify.post("/", async (request, reply) => { + const body = ProjectTemplateSchema.parse(request.body) + + const [template] = await db + .insert(projectTemplates) + .values({ + name: body.name, + description: body.description, + }) + .returning() + + return reply.code(201).send(template) + }) + + fastify.patch("/:id", async (request, reply) => { + const { id } = request.params as { id: string } + const body = ProjectTemplateUpdateSchema.parse(request.body) + + const [template] = await db + .update(projectTemplates) + .set(body) + .where(eq(projectTemplates.id, id)) + .returning() + + if (!template) { + return reply.code(404).send({ message: "Project template not found" }) + } + + return template + }) + + fastify.delete("/:id", async (request, reply) => { + const { id } = request.params as { id: string } + + const [template] = await db + .delete(projectTemplates) + .where(eq(projectTemplates.id, id)) + .returning() + + if (!template) { + return reply.code(404).send({ message: "Project template not found" }) + } + + return reply.code(204).send() + }) +} \ No newline at end of file diff --git a/apps/web/src/pages/ProjectTemplates.tsx b/apps/web/src/pages/ProjectTemplates.tsx new file mode 100644 index 0000000..ed18649 --- /dev/null +++ b/apps/web/src/pages/ProjectTemplates.tsx @@ -0,0 +1,144 @@ +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { api } from "../lib/api" + +export default function ProjectTemplates() { + const queryClient = useQueryClient() + const [name, setName] = useState("") + const [defaultBillable, setDefaultBillable] = useState(true) + const [estimatedHours, setEstimatedHours] = useState("") + + const { data: templates, isLoading, isError } = useQuery({ + queryKey: ["projectTemplates"], + queryFn: () => api.listProjectTemplates() + }) + + const createMutation = useMutation({ + mutationFn: (payload: { name: string; defaultBillable: boolean; estimatedHours: number }) => + api.createProjectTemplate(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projectTemplates"] }) + setName("") + setDefaultBillable(true) + setEstimatedHours("") + } + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => api.deleteProjectTemplate(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projectTemplates"] }) + } + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + createMutation.mutate({ + name, + defaultBillable, + estimatedHours: parseFloat(estimatedHours) || 0 + }) + } + + if (isLoading) return
Loading templates...
+ if (isError) return
Error loading templates.
+ + return ( +
+
+

Project Templates

+

Define standard project configurations for quick setup

+
+ +
+

Create New Template

+
+
+ + setName(e.target.value)} + placeholder="e.g. Standard Web Project" + /> +
+
+ + setEstimatedHours(e.target.value)} + placeholder="0" + /> +
+
+ setDefaultBillable(e.target.checked)} + /> + +
+ +
+
+ +
+ + + + + + + + + + + {templates?.length === 0 && ( + + + + )} + {templates?.map((t) => ( + + + + + + + ))} + +
NameEst. HoursBillableActions
No templates found.
{t.name}{t.estimatedHours}h + {t.defaultBillable ? ( + Yes + ) : ( + No + )} + + +
+
+
+ ) +} \ No newline at end of file