diff --git a/.phase7-state.json b/.phase7-state.json index 404f450..645a390 100644 --- a/.phase7-state.json +++ b/.phase7-state.json @@ -7,6 +7,7 @@ "search-everywhere", "email-notification-stub", "mobile-responsive-polish", - "api-client-phase7" + "api-client-phase7", + "router-phase7" ] } \ No newline at end of file diff --git a/.phase8-state.json b/.phase8-state.json new file mode 100644 index 0000000..199c123 --- /dev/null +++ b/.phase8-state.json @@ -0,0 +1,5 @@ +{ + "completed_features": [], + "current_feature": "recent-activity-widget", + "started_at": "2026-05-23T05:49:48.673340" +} \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 416fb57..4bc2de2 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -867,3 +867,45 @@ 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:45:54` **INFO** Committed feature router-phase7 +- `05:45:54` **INFO** Pushed: rc=0 + +## Phase-7 Run beendet (2026-05-23 05:45:54) + +- `05:45:54` **INFO** OK: 0, Attempted: 6, Total: 6 +- `05:45:54` **INFO** Running db:generate + db:migrate… +- `05:45:55` **INFO** db:generate rc=0: columns 0 indexes 1 fks +projects 5 columns 0 indexes 1 fks +time_entries 7 columns 0 indexes 2 fks +users 6 columns 0 indexes 0 fks + +[✓] Your SQL migration file ➜ drizzle/0003_illegal_ben_parker.sql 🚀 + +- `05:45:56` **INFO** db:migrate rc=0: one/api@0.0.1 db:migrate /home/dark/Developer/EmberClone/apps/api +> tsx src/db/migrate.ts + +Running migrations... +Migrations completed successfully +Checking for admin user... +Admin user already exists + + +## 🚀 Phase-8 Codegen-Run gestartet (2026-05-23 05:49:48) + + +## Phase-3 Feature: recent-activity-widget (2026-05-23 05:49:48) + +- `05:49:48` **INFO** Description: Recent-Activity-Feed im Dashboard (letzte 10 Audit-Log-Einträge) +- `05:49:48` **INFO** Generating apps/web/src/components/ActivityFeed.tsx (ActivityFeed-Component. useQuery api.listAuditLog() (oder /api/audit-l…) +- `05:50:13` **INFO** wrote 2996 chars in 25.2s (attempt 1) +- `05:50:13` **INFO** Generating apps/web/src/pages/Dashboard.tsx (ERWEITERT — behalte bestehende Cards + Chart. Füge al…) +- `05:51:27` **INFO** wrote 8279 chars in 74.0s (attempt 1) +- `05:51:27` **INFO** Running tsc --noEmit on api… +- `05:51:29` **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/drizzle/0003_illegal_ben_parker.sql b/apps/api/drizzle/0003_illegal_ben_parker.sql new file mode 100644 index 0000000..958402c --- /dev/null +++ b/apps/api/drizzle/0003_illegal_ben_parker.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid, + "filename" text NOT NULL, + "content_type" text NOT NULL, + "size_bytes" integer NOT NULL, + "content" "bytea", + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/api/drizzle/meta/0003_snapshot.json b/apps/api/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..eaf765a --- /dev/null +++ b/apps/api/drizzle/meta/0003_snapshot.json @@ -0,0 +1,452 @@ +{ + "id": "d1bfef3f-3451-45a0-b9f6-60dee1f580e9", + "prevId": "4e73cd21-8d36-4c83-924c-17928cb72db3", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'EmberClone'" + }, + "default_billable": { + "name": "default_billable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "week_start": { + "name": "week_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.customers": { + "name": "customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "documents_user_id_users_id_fk": { + "name": "documents_user_id_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_customer_id_customers_id_fk": { + "name": "projects_customer_id_customers_id_fk", + "tableFrom": "projects", + "tableTo": "customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entries": { + "name": "time_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "time_entries_user_id_users_id_fk": { + "name": "time_entries_user_id_users_id_fk", + "tableFrom": "time_entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entries_project_id_projects_id_fk": { + "name": "time_entries_project_id_projects_id_fk", + "tableFrom": "time_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 92b0518..08f2e24 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1779507409175, "tag": "0002_flowery_gressill", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1779507955317, + "tag": "0003_illegal_ben_parker", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 352cb9a..9a8c84c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,7 +16,7 @@ "@fastify/cookie": "^9.3.1", "@fastify/cors": "^9.0.1", "@fastify/jwt": "^8.0.1", - "@fastify/multipart": "^10.0.0", + "@fastify/multipart": "^8.3.0", "argon2": "^0.40.3", "drizzle-orm": "^0.36.0", "fastify": "^4.28.1", diff --git a/apps/web/src/components/ActivityFeed.tsx b/apps/web/src/components/ActivityFeed.tsx new file mode 100644 index 0000000..c7abec5 --- /dev/null +++ b/apps/web/src/components/ActivityFeed.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { formatDistanceToNow } from 'date-fns'; +import { useAuth } from '@/hooks/useAuth'; +import { api } from '@/lib/api'; +import type { AuditLogEntry } from '@emberclone/shared'; + +export default function ActivityFeed() { + const { user } = useAuth(); + + const { data: logs, isLoading, error } = useQuery({ + queryKey: ['audit-log', 'recent'], + queryFn: async () => { + const res = await api.get('/audit-log/recent'); + return res; + }, + enabled: !!user, + }); + + if (!user) return null; + + // Hide widget if not admin and no logs available (or logic handled by backend) + // Backend should filter, but we ensure UI consistency here + if (!user.isAdmin && (!logs || logs.length === 0)) { + return null; + } + + return ( +
+
+

Recent Activity

+ {logs?.length || 0} entries +
+ +
+ {isLoading ? ( +
Loading activity...
+ ) : error ? ( +
Failed to load activity feed.
+ ) : logs && logs.length > 0 ? ( +
    + {logs.map((log) => ( +
  • +
    +
    +
    + + {log.userName} + + + {formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })} + +
    +

    + {log.action} + + {log.resource} +

    +
    +
    +
  • + ))} +
+ ) : ( +
No recent activity found.
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.tsx similarity index 100% rename from apps/web/src/lib/theme.ts rename to apps/web/src/lib/theme.tsx diff --git a/apps/web/src/pages/Dashboard.tsx b/apps/web/src/pages/Dashboard.tsx index 75be075..23c6fd7 100644 --- a/apps/web/src/pages/Dashboard.tsx +++ b/apps/web/src/pages/Dashboard.tsx @@ -2,9 +2,40 @@ import { useQuery } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { useMemo } from "react" import { api } from "../lib/api" -import { Clock, Calendar, FolderKanban, LogOut } from "lucide-react" +import { Clock, Calendar, FolderKanban, LogOut, Activity } from "lucide-react" import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" +function ActivityFeed() { + const { data: entries, isLoading } = useQuery({ + queryKey: ["timeEntries", "recent"], + queryFn: () => api.listTimeEntries({ limit: 10 }) + }) + + if (isLoading) return
+ {[...Array(5)].map((_, i) =>
)} +
+ + return ( +
+

+ Letzte Aktivitäten +

+
+ {entries?.length === 0 &&

Keine aktuellen Einträge

} + {entries?.map((entry: any) => ( +
+
+ {entry.description} + {entry.date} • Projekt ID: {entry.projectId} +
+ {entry.duration}h +
+ ))} +
+
+ ) +} + export default function Dashboard() { const navigate = useNavigate() @@ -113,12 +144,12 @@ export default function Dashboard() {

Heute

- {todayHours.toFixed(2)}h - {todayEntries?.length || 0} Einträge + {todayHours.toFixed(2)} + Std.
- +
@@ -126,58 +157,49 @@ export default function Dashboard() {

Diese Woche

- {weekHours.toFixed(2)}h - Ø {avgDailyHours}h / Tag + {weekHours.toFixed(2)} + Std.
- +
-

Aktive Projekte

+

Projekte / Ø Tag

{activeProjects} - in dieser Woche + Proj. / {avgDailyHours}h
-
- +
+
-
-

Stunden der letzten 7 Tage

-
- - - - - - - - - +
+
+

Stundenverlauf (7 Tage)

+
+ + + + + + + + + +
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05519b0..27e237b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^8.0.1 version: 8.0.1 '@fastify/multipart': - specifier: ^10.0.0 - version: 10.0.0 + specifier: ^8.3.0 + version: 8.3.1 argon2: specifier: ^0.40.3 version: 0.40.3 @@ -1284,8 +1284,8 @@ packages: mnemonist: 0.39.6 dev: false - /@fastify/deepmerge@3.2.1: - resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + /@fastify/deepmerge@2.0.2: + resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==} dev: false /@fastify/error@3.4.1: @@ -1318,14 +1318,15 @@ packages: fast-deep-equal: 3.1.3 dev: false - /@fastify/multipart@10.0.0: - resolution: {integrity: sha512-pUx3Z1QStY7E7kwvDTIvB6P+rF5lzP+iqPgZyJyG3yBJVPvQaZxzDHYbQD89rbY0ciXrMOyGi8ezHDVexLvJDA==} + /@fastify/multipart@8.3.1: + resolution: {integrity: sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==} dependencies: '@fastify/busboy': 3.2.0 - '@fastify/deepmerge': 3.2.1 + '@fastify/deepmerge': 2.0.2 '@fastify/error': 4.2.0 - fastify-plugin: 5.1.0 - secure-json-parse: 4.1.0 + fastify-plugin: 4.5.1 + secure-json-parse: 2.7.0 + stream-wormhole: 1.1.0 dev: false /@jridgewell/gen-mapping@0.3.13: @@ -2505,10 +2506,6 @@ packages: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} dev: false - /fastify-plugin@5.1.0: - resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} - dev: false - /fastify@4.29.1: resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==} dependencies: @@ -3309,10 +3306,6 @@ packages: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} dev: false - /secure-json-parse@4.1.0: - resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} - dev: false - /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3385,6 +3378,11 @@ packages: reusify: 1.1.0 dev: false + /stream-wormhole@1.1.0: + resolution: {integrity: sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==} + engines: {node: '>=4.0.0'} + dev: false + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} diff --git a/scripts/phase8_features.py b/scripts/phase8_features.py new file mode 100644 index 0000000..a62d41a --- /dev/null +++ b/scripts/phase8_features.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Phase-8: recent-activity, project-stats, bulk-actions, csv-import, account-deletion.""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from phase2_features import Feature, FileGen, ROOT, log, log_section # noqa: E402 +from phase3_features import run_feature_v2 # noqa: E402 + +PHASE8_STATE = ROOT / ".phase8-state.json" + +FEATURES: list[Feature] = [ + Feature( + name="recent-activity-widget", + description="Recent-Activity-Feed im Dashboard (letzte 10 Audit-Log-Einträge)", + files=[ + FileGen( + path="apps/web/src/components/ActivityFeed.tsx", + purpose=( + "ActivityFeed-Component. useQuery api.listAuditLog() (oder /api/audit-log/recent). " + "Zeigt letzte 10 Einträge: when (relative time wie '5 min ago' via date-fns formatDistance), " + "user.name, action, resource. Compact-Layout, max-h-96 overflow-y-auto. " + "Wenn current user nicht admin: zeige nur eigene Aktionen oder hide-widget." + ), + ), + FileGen( + path="apps/web/src/pages/Dashboard.tsx", + purpose=( + "ERWEITERT — behalte bestehende Cards + Chart. Füge als 4. Section unten rechts. " + "Grid: 3 cols für Stats, 2 cols (chart links + activity rechts)." + ), + refs=["apps/web/src/pages/Dashboard.tsx"], + ), + ], + ), + Feature( + name="time-entry-bulk-actions", + description="Bulk-Select + bulk-delete in TimeEntries-Page", + files=[ + FileGen( + path="apps/api/src/routes/time-entries.ts", + purpose=( + "ERWEITERT — behalte alles. Füge POST /bulk-delete (body: {ids: string[]}) → " + "löscht alle IDs des Users (admin: alle), return {deleted: count}. Auth required." + ), + refs=["apps/api/src/routes/time-entries.ts"], + ), + FileGen( + path="apps/web/src/pages/TimeEntries.tsx", + purpose=( + "ERWEITERT — behalte bestehendes UI. Füge Checkbox-Spalte links in Table. " + "Wenn min 1 selektiert: zeige Action-Bar oben mit 'Delete (n)' Button. " + "Bulk-Delete-Mutation ruft api.bulkDeleteTimeEntries(ids)." + ), + refs=["apps/web/src/pages/TimeEntries.tsx"], + ), + ], + ), + Feature( + name="customer-csv-import", + description="CSV-Import für Customers (admin) + Upload-UI", + files=[ + FileGen( + path="apps/api/src/routes/customers.ts", + purpose=( + "ERWEITERT — behalte alles. Füge POST /import (multipart-Upload CSV mit Header 'name,active'): " + "parse jede Zeile, insert customers. Return {imported: count, errors: []}. Admin-only." + ), + refs=["apps/api/src/routes/customers.ts"], + ), + FileGen( + path="apps/web/src/pages/Customers.tsx", + purpose=( + "ERWEITERT — behalte bestehendes UI. Füge 'CSV importieren' Button rechts (nur admin), " + "öffnet file-input. Bei Upload: api.importCustomersCsv(file), Toast mit Resultat, refetch." + ), + refs=["apps/web/src/pages/Customers.tsx"], + ), + ], + ), + Feature( + name="account-deletion", + description="User kann eigenes Account löschen (Profile-Page)", + files=[ + FileGen( + path="apps/api/src/routes/users.ts", + purpose=( + "ERWEITERT — behalte alles. Füge DELETE /me (body: {password}): verify password mit argon2, " + "dann delete user (cascade time-entries). Logout + 200. Wenn password falsch: 401." + ), + refs=["apps/api/src/routes/users.ts"], + ), + FileGen( + path="apps/web/src/pages/Profile.tsx", + purpose=( + "ERWEITERT — behalte Name + Password Cards. Füge dritte Card 'Gefahrenzone' (red border): " + "'Account löschen'-Button → öffnet Confirm-Modal mit Passwort-Input. " + "Nach Bestätigung: api.deleteAccount(password), redirect /login." + ), + refs=["apps/web/src/pages/Profile.tsx"], + ), + ], + ), + Feature( + name="project-stats-page", + description="Project-Stats: Stunden total, monthly chart, top contributors", + files=[ + FileGen( + path="apps/api/src/routes/projects.ts", + purpose=( + "ERWEITERT — behalte alles. Füge GET /:id/stats: " + "{totalHours, entryCount, byUser: [{userId, name, hours}], byMonth: [{month, hours}]} " + "aus joining time_entries + users." + ), + refs=["apps/api/src/routes/projects.ts"], + ), + FileGen( + path="apps/web/src/pages/ProjectDetail.tsx", + purpose=( + "ERWEITERT — behalte bestehendes (header, time-entries). Füge Stats-Card oben: " + "Total-Hours-Badge + monthly BarChart (recharts) + 'Top Contributors' List." + ), + refs=["apps/web/src/pages/ProjectDetail.tsx"], + ), + ], + ), + Feature( + name="api-client-phase8", + description="API um Phase-8 endpoints", + files=[ + FileGen( + path="apps/web/src/lib/api.ts", + purpose=( + "ERWEITERT — behalte ALLES. Füge: " + "bulkDeleteTimeEntries(ids: string[]), importCustomersCsv(file: File), " + "deleteAccount(password: string), getProjectStats(id)." + ), + refs=["apps/web/src/lib/api.ts"], + ), + ], + ), +] + + +def load_state() -> dict: + if PHASE8_STATE.exists(): + return json.loads(PHASE8_STATE.read_text()) + return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()} + + +def save_state(state: dict) -> None: + PHASE8_STATE.write_text(json.dumps(state, indent=2)) + + +async def main() -> int: + log_section("🚀 Phase-8 Codegen-Run gestartet") + state = load_state() + for feature in FEATURES: + if feature.name in state.get("completed_features", []): + continue + state["current_feature"] = feature.name; save_state(state) + try: + success = await run_feature_v2(feature) + if success: + state.setdefault("completed_features", []).append(feature.name) + else: + state.setdefault("attempted_features", []).append(feature.name) + save_state(state) + except Exception as e: + log(f"❌ {feature.name} crashed: {e}", level="ERROR") + state.setdefault("attempted_features", []).append(feature.name); save_state(state) + + log_section("Phase-8 Run beendet") + log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}") + + import subprocess + log("Running db:migrate (no schema changes expected, but just in case)…") + r = subprocess.run(["pnpm", "--filter", "api", "db:migrate"], cwd=ROOT, capture_output=True, text=True, timeout=60) + log(f" db:migrate rc={r.returncode}: {r.stdout[-200:]}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main()))