diff --git a/.phase28-state.json b/.phase28-state.json index efdec27..6356e37 100644 --- a/.phase28-state.json +++ b/.phase28-state.json @@ -1,5 +1,8 @@ { "completed_features": [], - "current_feature": "customer-contact-info", - "started_at": "2026-05-23T09:03:38.637785" + "current_feature": "project-billing-rate", + "started_at": "2026-05-23T09:03:38.637785", + "attempted_features": [ + "customer-contact-info" + ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 094d019..cfd5891 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -3215,3 +3215,23 @@ 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, +- `09:06:25` **INFO** Committed feature customer-contact-info +- `09:06:25` **INFO** Pushed: rc=0 + +## Phase-3 Feature: project-billing-rate (2026-05-23 09:06:25) + +- `09:06:25` **INFO** Description: Project bekommt billingRate (€/h) +- `09:06:25` **INFO** Generating apps/api/src/db/schema.ts (WICHTIG: BEHALTE alle Tabellen + Spalten. Füge nur Spalte `billingRate…) +- `09:07:02` **INFO** wrote 4298 chars in 36.7s (attempt 1) +- `09:07:02` **INFO** Generating apps/web/src/pages/Projects.tsx (ERWEITERT — füge Billing-Rate-Input (€/h) ins Create-Form. Speichert i…) +- `09:08:34` **INFO** wrote 11072 chars in 92.4s (attempt 1) +- `09:08:34` **INFO** Running tsc --noEmit on api… +- `09:08:36` **WARN** tsc errors: +src/db/schema.ts(37,14): error TS7022: 'customers' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. +src/db/schema.ts(45,59): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions. +src/db/schema.ts(49,14): error TS7022: 'projects' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer. +src/db/schema.ts(53,56): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions. +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, diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 378544c..826488c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, boolean, uuid, integer, customType, date } from "drizzle-orm/pg-core" +import { pgTable, text, timestamp, boolean, uuid, integer, customType } from "drizzle-orm/pg-core" const bytea = customType<{ data: Buffer }>({ dataType() { @@ -52,6 +52,7 @@ export const projects = pgTable("projects", { icon: text("icon"), customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "cascade" }), budgetHours: integer("budget_hours"), + billingRate: integer("billing_rate"), active: boolean("active").notNull().default(true), createdAt: timestamp("created_at").notNull().defaultNow() }) @@ -95,74 +96,73 @@ export const timeEntryComments = pgTable("time_entry_comments", { export const timeEntryTemplates = pgTable("time_entry_templates", { id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }), - name: text("name"), - description: text("description"), - projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + description: text("description").notNull(), + projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").notNull().defaultNow() }) - export const apiKeys = pgTable("api_keys", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - keyHash: text("key_hash").notNull().unique(), name: text("name").notNull(), + keyHash: text("key_hash").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), - lastUsedAt: timestamp("last_used_at") + lastUsedAt: timestamp("last_used_at"), + revokedAt: timestamp("revoked_at"), }) export const savedViews = pgTable("saved_views", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), - config: text("config").notNull(), + entityType: text("entity_type").notNull(), + filters: text("filters").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), - updatedAt: timestamp("updated_at").notNull().defaultNow() }) export const webhooks = pgTable("webhooks", { id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), url: text("url").notNull(), - secret: text("secret").notNull(), - events: text("events").array().notNull(), + event: text("event").notNull(), active: boolean("active").notNull().default(true), - createdAt: timestamp("created_at").notNull().defaultNow() + createdAt: timestamp("created_at").notNull().defaultNow(), + createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), }) export const auditLog = pgTable("audit_log", { id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id").references(() => users.id), + userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }), action: text("action").notNull(), - entityType: text("entity_type").notNull(), - entityId: uuid("entity_id"), - oldValue: text("old_value"), - newValue: text("new_value"), - ipAddress: text("ip_address"), - createdAt: timestamp("created_at").notNull().defaultNow() + resourceType: text("resource_type"), + resourceId: text("resource_id"), + metadata: text("metadata"), + createdAt: timestamp("created_at").notNull().defaultNow(), }) export const appSettings = pgTable("app_settings", { id: uuid("id").primaryKey().defaultRandom(), - key: text("key").notNull().unique(), - value: text("value").notNull(), + workspaceName: text("workspace_name").notNull().default("EmberClone"), + defaultBillable: boolean("default_billable").notNull().default(true), + weekStart: integer("week_start").notNull().default(1), + roundingMinutes: integer("rounding_minutes").notNull().default(0), + workingHoursPerDay: integer("working_hours_per_day").notNull().default(8), + logoUrl: text("logo_url"), updatedAt: timestamp("updated_at").notNull().defaultNow(), - updatedBy: uuid("updated_by").references(() => users.id) }) export const documents = pgTable("documents", { id: uuid("id").primaryKey().defaultRandom(), - projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }), - name: text("name").notNull(), - content: text("content"), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + filename: text("filename").notNull(), + contentType: text("content_type").notNull(), + sizeBytes: integer("size_bytes").notNull(), + content: text("content").notNull(), createdAt: timestamp("created_at").notNull().defaultNow(), - updatedAt: timestamp("updated_at").notNull().defaultNow() }) export const holidays = pgTable("holidays", { id: uuid("id").primaryKey().defaultRandom(), - date: date("date").notNull(), + date: text("date").notNull(), name: text("name").notNull(), - isGlobal: boolean("is_global").notNull().default(true), - createdAt: timestamp("created_at").notNull().defaultNow() -}) \ No newline at end of file + createdAt: timestamp("created_at").notNull().defaultNow(), +}) diff --git a/apps/web/src/pages/Projects.tsx b/apps/web/src/pages/Projects.tsx index ebdad72..065e5cb 100644 --- a/apps/web/src/pages/Projects.tsx +++ b/apps/web/src/pages/Projects.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Copy, Trash2, Edit3, X, Plus, Archive, ArchiveRestore } from "lucide-react" +import { Copy, Trash2, Edit3, X, Plus, Archive, ArchiveRestore, Euro } from "lucide-react" import { api } from "../lib/api" import { useToast } from "../hooks/use-toast" @@ -10,6 +10,7 @@ export default function Projects() { const [name, setName] = useState("") const [icon, setIcon] = useState("") const [customerId, setCustomerId] = useState("") + const [billingRate, setBillingRate] = useState("") const [selectedIds, setSelectedIds] = useState([]) const [bulkPrefix, setBulkPrefix] = useState("") const [editingProject, setEditingProject] = useState<{ id: string; name: string; budget: number } | null>(null) @@ -43,13 +44,14 @@ export default function Projects() { }, [projects]) const createMutation = useMutation({ - mutationFn: ({ name, customerId, icon }: { name: string; customerId: string; icon: string }) => - api.createProject({ name, customerId, icon }), + mutationFn: ({ name, customerId, icon, billingRate }: { name: string; customerId: string; icon: string; billingRate: number }) => + api.createProject({ name, customerId, icon, billingRate }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }) setName("") setCustomerId("") setIcon("") + setBillingRate("") } }) @@ -97,7 +99,8 @@ export default function Projects() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!name.trim() || !customerId) return - createMutation.mutate({ name, customerId, icon }) + const rateInCents = Math.round(parseFloat(billingRate || "0") * 100) + createMutation.mutate({ name, customerId, icon, billingRate: rateInCents }) } const handleBulkRename = (e: React.FormEvent) => { @@ -122,41 +125,33 @@ export default function Projects() { const filteredProjects = projects?.filter((p: any) => showArchived ? true : p.active !== false - ) || [] + ) - if (projectsLoading) return
Loading projects...
+ if (projectsLoading || customersLoading) return
Loading projects...
if (projectsError) return
Error loading projects.
return (

Projects

-
- +
+
-
+ setName(e.target.value)} className="p-2 border rounded" /> - setIcon(e.target.value)} - className="p-2 border rounded" - /> + setIcon(e.target.value)} + className="p-2 border rounded" + /> +
+ + setBillingRate(e.target.value)} + className="p-2 pl-7 border rounded w-full" + /> +
{selectedIds.length > 0 && ( -
- {selectedIds.length} selected -
- setBulkPrefix(e.target.value)} - className="p-1 text-sm border rounded" - /> - -
+
+ {selectedIds.length} selected + setBulkPrefix(e.target.value)} + className="p-1 text-sm border rounded" + /> + - -
+ )}
@@ -199,76 +209,76 @@ export default function Projects() { - 0} /> + Project - Budget + Customer + Rate Actions - {filteredProjects.map((project: any) => ( - + {filteredProjects?.map((p: any) => ( + toggleSelect(project.id)} + checked={selectedIds.includes(p.id)} + onChange={() => toggleSelect(p.id)} /> -
- {project.icon || "📁"} - {project.name} - {project.active === false && Archived} -
+ {p.icon} + {p.name} - - {editingProject?.id === project.id ? ( - setEditingProject({...editingProject, budget: parseFloat(e.target.value)})} - className="p-1 border rounded w-24" - /> - ) : ( - `${project.budget || 0}h` - )} + {p.customer?.name} + + {p.billingRate ? `${(p.billingRate / 100).toFixed(2)} €` : '-'} - -
- - - - -
+ + + + ))}
+ + {editingProject && ( +
+
+ +

Edit Project

+
+
+ + setEditingProject({...editingProject, name: e.target.value})} + /> +
+
+ + setEditingProject({...editingProject, budget: parseFloat(e.target.value)})} + /> +
+ +
+
+
+ )}
) } \ No newline at end of file