feat(recent-activity-widget): Recent-Activity-Feed im Dashboard (letzte 10 Audit-Log-Eintr [tsc:fail]
This commit is contained in:
parent
a3c24339ae
commit
38bdd1555d
@ -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
5
.phase8-state.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"completed_features": [],
|
||||
"current_feature": "recent-activity-widget",
|
||||
"started_at": "2026-05-23T05:49:48.673340"
|
||||
}
|
||||
@ -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
|
||||
|
||||
15
apps/api/drizzle/0003_illegal_ben_parker.sql
Normal file
15
apps/api/drizzle/0003_illegal_ben_parker.sql
Normal 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 $$;
|
||||
452
apps/api/drizzle/meta/0003_snapshot.json
Normal file
452
apps/api/drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
70
apps/web/src/components/ActivityFeed.tsx
Normal file
70
apps/web/src/components/ActivityFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,58 +157,49 @@ 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">
|
||||
<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 }}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<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="#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={32} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@ -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
190
scripts/phase8_features.py
Normal 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()))
|
||||
Loading…
Reference in New Issue
Block a user