feat(recent-activity-widget): Recent-Activity-Feed im Dashboard (letzte 10 Audit-Log-Eintr [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 05:51:29 +02:00
parent a3c24339ae
commit 38bdd1555d
12 changed files with 861 additions and 59 deletions

View File

@ -7,6 +7,7 @@
"search-everywhere",
"email-notification-stub",
"mobile-responsive-polish",
"api-client-phase7"
"api-client-phase7",
"router-phase7"
]
}

5
.phase8-state.json Normal file
View File

@ -0,0 +1,5 @@
{
"completed_features": [],
"current_feature": "recent-activity-widget",
"started_at": "2026-05-23T05:49:48.673340"
}

View File

@ -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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, 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 <ActivityFeed /> 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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, 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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy

View File

@ -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 $$;

View File

@ -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": {}
}
}

View File

@ -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
}
]
}

View File

@ -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",

View File

@ -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<AuditLogEntry[]>('/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 (
<div className="flex flex-col w-full max-w-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg shadow-sm">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Recent Activity</h3>
<span className="text-xs text-slate-500">{logs?.length || 0} entries</span>
</div>
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-sm text-slate-500">Loading activity...</div>
) : error ? (
<div className="p-4 text-center text-sm text-red-500">Failed to load activity feed.</div>
) : logs && logs.length > 0 ? (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{logs.map((log) => (
<li key={log.id} className="px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{log.userName}
</span>
<span className="text-xs text-slate-500 shrink-0">
{formatDistanceToNow(new Date(log.createdAt), { addSuffix: true })}
</span>
</div>
<p className="text-xs text-slate-600 dark:text-slate-400 truncate">
<span className="font-medium">{log.action}</span>
<span className="text-slate-400 mx-1"></span>
<span className="italic">{log.resource}</span>
</p>
</div>
</div>
</li>
))}
</ul>
) : (
<div className="p-8 text-center text-sm text-slate-500">No recent activity found.</div>
)}
</div>
</div>
);
}

View File

@ -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 <div className="animate-pulse space-y-3">
{[...Array(5)].map((_, i) => <div key={i} className="h-12 bg-gray-100 rounded-lg" />)}
</div>
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider flex items-center gap-2">
<Activity size={14} /> Letzte Aktivitäten
</h3>
<div className="space-y-3">
{entries?.length === 0 && <p className="text-sm text-gray-400 italic">Keine aktuellen Einträge</p>}
{entries?.map((entry: any) => (
<div key={entry.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-800">{entry.description}</span>
<span className="text-xs text-gray-500">{entry.date} Projekt ID: {entry.projectId}</span>
</div>
<span className="text-sm font-semibold text-indigo-600">{entry.duration}h</span>
</div>
))}
</div>
</div>
)
}
export default function Dashboard() {
const navigate = useNavigate()
@ -113,12 +144,12 @@ export default function Dashboard() {
<div>
<p className="text-sm font-medium text-gray-500 uppercase tracking-wider">Heute</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900">{todayHours.toFixed(2)}h</span>
<span className="text-sm text-gray-400">{todayEntries?.length || 0} Einträge</span>
<span className="text-3xl font-bold text-gray-900">{todayHours.toFixed(2)}</span>
<span className="text-sm text-gray-500">Std.</span>
</div>
</div>
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-lg">
<Clock size={24} />
<Clock size={20} />
</div>
</div>
@ -126,60 +157,51 @@ export default function Dashboard() {
<div>
<p className="text-sm font-medium text-gray-500 uppercase tracking-wider">Diese Woche</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900">{weekHours.toFixed(2)}h</span>
<span className="text-sm text-gray-400">Ø {avgDailyHours}h / Tag</span>
<span className="text-3xl font-bold text-gray-900">{weekHours.toFixed(2)}</span>
<span className="text-sm text-gray-500">Std.</span>
</div>
</div>
<div className="p-3 bg-green-50 text-green-600 rounded-lg">
<Calendar size={24} />
<Calendar size={20} />
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 flex items-start justify-between">
<div>
<p className="text-sm font-medium text-gray-500 uppercase tracking-wider">Aktive Projekte</p>
<p className="text-sm font-medium text-gray-500 uppercase tracking-wider">Projekte / Ø Tag</p>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-3xl font-bold text-gray-900">{activeProjects}</span>
<span className="text-sm text-gray-400">in dieser Woche</span>
<span className="text-sm text-gray-500">Proj. / {avgDailyHours}h</span>
</div>
</div>
<div className="p-3 bg-amber-50 text-amber-600 rounded-lg">
<FolderKanban size={24} />
<div className="p-3 bg-orange-50 text-orange-600 rounded-lg">
<FolderKanban size={20} />
</div>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<h2 className="text-lg font-semibold text-gray-800 mb-6">Stunden der letzten 7 Tage</h2>
<div className="h-80 w-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-6">Stundenverlauf (7 Tage)</h3>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
<XAxis
dataKey="name"
axisLine={false}
tickLine={false}
tick={{ fill: '#9ca3af', fontSize: 12 }}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: '#9ca3af', fontSize: 12 }}
/>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#9ca3af', fontSize: 12}} />
<YAxis axisLine={false} tickLine={false} tick={{fill: '#9ca3af', fontSize: 12}} />
<Tooltip
cursor={{ fill: '#f9fafb' }}
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
/>
<Bar
dataKey="hours"
fill="#4f46e5"
radius={[4, 4, 0, 0]}
barSize={40}
cursor={{fill: '#f9fafb'}}
contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'}}
/>
<Bar dataKey="hours" fill="#4f46e5" radius={[4, 4, 0, 0]} barSize={32} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
<ActivityFeed />
</div>
</div>
</main>
</div>
)

32
pnpm-lock.yaml generated
View File

@ -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'}

190
scripts/phase8_features.py Normal file
View File

@ -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 <ActivityFeed /> 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()))