feat(markdown-notes-time-entry): Markdown-Notes-Feld pro Time-Entry + Render in Liste [tsc:fail]
This commit is contained in:
parent
c438b8c95e
commit
ff82a45c65
5
.phase10-state.json
Normal file
5
.phase10-state.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"completed_features": [],
|
||||||
|
"current_feature": "markdown-notes-time-entry",
|
||||||
|
"started_at": "2026-05-23T06:10:51.530595"
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
"two-factor-auth-stub",
|
"two-factor-auth-stub",
|
||||||
"billing-stub",
|
"billing-stub",
|
||||||
"integrations-page",
|
"integrations-page",
|
||||||
"api-client-phase9"
|
"api-client-phase9",
|
||||||
|
"router-phase9"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -1128,3 +1128,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.
|
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>'.
|
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
|
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
||||||
|
- `06:08:29` **INFO** Committed feature router-phase9
|
||||||
|
- `06:08:29` **INFO** Pushed: rc=0
|
||||||
|
|
||||||
|
## Phase-9 Run beendet (2026-05-23 06:08:29)
|
||||||
|
|
||||||
|
- `06:08:29` **INFO** OK: 0, Attempted: 6, Total: 6
|
||||||
|
- `06:08:29` **INFO** Running db:generate + db:migrate…
|
||||||
|
- `06:08:30` **INFO** db:generate rc=0: cts 5 columns 0 indexes 1 fks
|
||||||
|
time_entries 7 columns 0 indexes 2 fks
|
||||||
|
users 6 columns 0 indexes 0 fks
|
||||||
|
webhooks 6 columns 0 indexes 1 fks
|
||||||
|
|
||||||
|
[✓] Your SQL migration file ➜ drizzle/0004_clammy_random.sql 🚀
|
||||||
|
|
||||||
|
- `06:08:30` **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-10 Codegen-Run gestartet (2026-05-23 06:10:51)
|
||||||
|
|
||||||
|
|
||||||
|
## Phase-3 Feature: markdown-notes-time-entry (2026-05-23 06:10:51)
|
||||||
|
|
||||||
|
- `06:10:51` **INFO** Description: Markdown-Notes-Feld pro Time-Entry + Render in Liste
|
||||||
|
- `06:10:51` **INFO** Generating apps/api/src/db/schema.ts (ERWEITERT — füge `notes: text('notes')` Spalte (nullable) zu time_entr…)
|
||||||
|
- `06:11:17` **INFO** wrote 3000 chars in 25.8s (attempt 1)
|
||||||
|
- `06:11:17` **INFO** Generating apps/web/src/pages/TimeEntries.tsx (ERWEITERT — behalte alles. Füge `notes` Textarea (optional, expandable…)
|
||||||
|
- `06:13:04` **INFO** wrote 13463 chars in 107.5s (attempt 1)
|
||||||
|
- `06:13:04` **INFO** Running tsc --noEmit on api…
|
||||||
|
- `06:13:06` **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
|
||||||
|
|||||||
14
apps/api/drizzle/0004_clammy_random.sql
Normal file
14
apps/api/drizzle/0004_clammy_random.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS "webhooks" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"event" text NOT NULL,
|
||||||
|
"active" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"created_by" uuid
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
518
apps/api/drizzle/meta/0004_snapshot.json
Normal file
518
apps/api/drizzle/meta/0004_snapshot.json
Normal file
@ -0,0 +1,518 @@
|
|||||||
|
{
|
||||||
|
"id": "a4312ab5-9818-4a13-a906-33971e96f38a",
|
||||||
|
"prevId": "d1bfef3f-3451-45a0-b9f6-60dee1f580e9",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"public.webhooks": {
|
||||||
|
"name": "webhooks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"name": "event",
|
||||||
|
"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()"
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"webhooks_created_by_users_id_fk": {
|
||||||
|
"name": "webhooks_created_by_users_id_fk",
|
||||||
|
"tableFrom": "webhooks",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"created_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -29,6 +29,13 @@
|
|||||||
"when": 1779507955317,
|
"when": 1779507955317,
|
||||||
"tag": "0003_illegal_ben_parker",
|
"tag": "0003_illegal_ben_parker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779509310016,
|
||||||
|
"tag": "0004_clammy_random",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -35,6 +35,7 @@ export const timeEntries = pgTable("time_entries", {
|
|||||||
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
|
notes: text("notes"),
|
||||||
startTime: timestamp("start_time").notNull(),
|
startTime: timestamp("start_time").notNull(),
|
||||||
endTime: timestamp("end_time"),
|
endTime: timestamp("end_time"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
createdAt: timestamp("created_at").notNull().defaultNow()
|
||||||
|
|||||||
@ -5,6 +5,19 @@ import { EmptyState } from "../components/EmptyState"
|
|||||||
import { LoadingSpinner } from "../components/LoadingSpinner"
|
import { LoadingSpinner } from "../components/LoadingSpinner"
|
||||||
import type { TimeEntryInsert } from "@emberclone/shared"
|
import type { TimeEntryInsert } from "@emberclone/shared"
|
||||||
|
|
||||||
|
function renderSimpleMarkdown(text: string | null) {
|
||||||
|
if (!text) return null
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line, i) => (
|
||||||
|
<div key={i} className="mb-1">
|
||||||
|
{line
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
export default function TimeEntries() {
|
export default function TimeEntries() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@ -12,7 +25,8 @@ export default function TimeEntries() {
|
|||||||
description: "",
|
description: "",
|
||||||
startTime: "",
|
startTime: "",
|
||||||
endTime: "",
|
endTime: "",
|
||||||
projectId: ""
|
projectId: "",
|
||||||
|
notes: ""
|
||||||
})
|
})
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
@ -22,6 +36,7 @@ export default function TimeEntries() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const { data: entries, isLoading, isError } = useQuery({
|
const { data: entries, isLoading, isError } = useQuery({
|
||||||
queryKey: ["time-entries", filters.from, filters.to],
|
queryKey: ["time-entries", filters.from, filters.to],
|
||||||
@ -35,7 +50,7 @@ export default function TimeEntries() {
|
|||||||
mutationFn: (data: Partial<TimeEntryInsert>) => api.createTimeEntry(data),
|
mutationFn: (data: Partial<TimeEntryInsert>) => api.createTimeEntry(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
|
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
|
||||||
setFormData({ description: "", startTime: "", endTime: "", projectId: "" })
|
setFormData({ description: "", startTime: "", endTime: "", projectId: "", notes: "" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -67,7 +82,8 @@ export default function TimeEntries() {
|
|||||||
description: formData.description,
|
description: formData.description,
|
||||||
startTime: new Date(formData.startTime) as any,
|
startTime: new Date(formData.startTime) as any,
|
||||||
endTime: new Date(formData.endTime) as any,
|
endTime: new Date(formData.endTime) as any,
|
||||||
projectId: formData.projectId || undefined
|
projectId: formData.projectId || undefined,
|
||||||
|
notes: formData.notes || undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,6 +108,10 @@ export default function TimeEntries() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleRow = (id: string) => {
|
||||||
|
setExpandedRows(prev => ({ ...prev, [id]: !prev[id] }))
|
||||||
|
}
|
||||||
|
|
||||||
if (isError) return <div className="p-6 text-red-500">Error loading time entries.</div>
|
if (isError) return <div className="p-6 text-red-500">Error loading time entries.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -103,55 +123,66 @@ export default function TimeEntries() {
|
|||||||
|
|
||||||
<section className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
<section className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
|
||||||
<h2 className="text-lg font-semibold mb-4">Log New Entry</h2>
|
<h2 className="text-lg font-semibold mb-4">Log New Entry</h2>
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="md:col-span-1">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
<div className="md:col-span-1">
|
||||||
<input
|
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
type="text"
|
<input
|
||||||
required
|
type="text"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
required
|
||||||
value={formData.description}
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
value={formData.description}
|
||||||
placeholder="What did you work on?"
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
/>
|
placeholder="What did you work on?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Start Time</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
value={formData.startTime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">End Time</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
value={formData.endTime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Project ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
|
value={formData.projectId}
|
||||||
|
onChange={(e) => setFormData({ ...formData, projectId: e.target.value })}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes (Markdown)</label>
|
||||||
<input
|
<textarea
|
||||||
type="datetime-local"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none min-h-[80px]"
|
||||||
required
|
value={formData.notes}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
value={formData.startTime}
|
placeholder="Additional details..."
|
||||||
onChange={(e) => setFormData({ ...formData, startTime: e.target.value })}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex justify-end">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">End</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
|
||||||
value={formData.endTime}
|
|
||||||
onChange={(e) => setFormData({ ...formData, endTime: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Project ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
|
||||||
value={formData.projectId}
|
|
||||||
onChange={(e) => setFormData({ ...formData, projectId: e.target.value })}
|
|
||||||
placeholder="Optional"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="md:col-span-4 flex justify-end">
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? "Saving..." : "Add Entry"}
|
{createMutation.isPending ? "Saving..." : "Save Entry"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -159,104 +190,126 @@ export default function TimeEntries() {
|
|||||||
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search descriptions..."
|
placeholder="Search entries..."
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
className="px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
className="px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
value={filters.from}
|
value={filters.from}
|
||||||
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, from: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="px-3 py-2 border border-gray-300 rounded-md text-sm outline-none focus:ring-2 focus:ring-blue-500"
|
className="px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
value={filters.to}
|
value={filters.to}
|
||||||
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, to: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={handleExport}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedIds.length > 0 && (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-blue-50 border border-blue-200 rounded-lg animate-in fade-in slide-in-from-top-2">
|
|
||||||
<span className="text-sm font-medium text-blue-800">
|
|
||||||
{selectedIds.length} entries selected
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
|
onClick={handleExport}
|
||||||
disabled={bulkDeleteMutation.isPending}
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||||
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
>
|
||||||
{bulkDeleteMutation.isPending ? "Deleting..." : `Delete (${selectedIds.length})`}
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => bulkDeleteMutation.mutate(selectedIds)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete Selected ({selectedIds.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-12"><LoadingSpinner /></div>
|
<div className="flex justify-center py-12"><LoadingSpinner /></div>
|
||||||
) : filteredEntries.length === 0 ? (
|
) : filteredEntries.length === 0 ? (
|
||||||
<EmptyState message="No time entries found matching your filters." />
|
<EmptyState message="No time entries found." />
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto border border-gray-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 w-10">
|
<th className="p-4 w-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300"
|
||||||
checked={selectedIds.length === filteredEntries.length && filteredEntries.length > 0}
|
|
||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
|
checked={selectedIds.length === filteredEntries.length && filteredEntries.length > 0}
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3">Description</th>
|
<th className="p-4 text-sm font-semibold text-gray-600">Description</th>
|
||||||
<th className="px-4 py-3">Start</th>
|
<th className="p-4 text-sm font-semibold text-gray-600">Start</th>
|
||||||
<th className="px-4 py-3">End</th>
|
<th className="p-4 text-sm font-semibold text-gray-600">End</th>
|
||||||
<th className="px-4 py-3">Duration</th>
|
<th className="p-4 text-sm font-semibold text-gray-600">Project</th>
|
||||||
<th className="px-4 py-3 text-right">Actions</th>
|
<th className="p-4 text-sm font-semibold text-gray-600 w-20"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{filteredEntries.map((entry) => {
|
{filteredEntries.map((entry) => (
|
||||||
const durationMs = new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime()
|
<React.Fragment key={entry.id}>
|
||||||
const durationHrs = (durationMs / (1000 * 60 * 60)).toFixed(2)
|
<tr className="hover:bg-gray-50 group">
|
||||||
|
<td className="p-4">
|
||||||
return (
|
|
||||||
<tr key={entry.id} className={`hover:bg-gray-50 transition-colors ${selectedIds.includes(entry.id) ? 'bg-blue-50/50' : ''}`}>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
className="rounded border-gray-300"
|
||||||
checked={selectedIds.includes(entry.id)}
|
checked={selectedIds.includes(entry.id)}
|
||||||
onChange={() => toggleSelect(entry.id)}
|
onChange={() => toggleSelect(entry.id)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium text-gray-900">{entry.description}</td>
|
<td className="p-4">
|
||||||
<td className="px-4 py-3 text-gray-500">{new Date(entry.startTime).toLocaleString()}</td>
|
<div className="flex items-center gap-2">
|
||||||
<td className="px-4 py-3 text-gray-500">{new Date(entry.endTime).toLocaleString()}</td>
|
<button
|
||||||
<td className="px-4 py-3 text-gray-500">{durationHrs}h</td>
|
onClick={() => toggleRow(entry.id)}
|
||||||
<td className="px-4 py-3 text-right">
|
className="p-1 hover:bg-gray-200 rounded text-gray-400 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 transition-transform ${expandedRows[entry.id] ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-900">{entry.description}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm text-gray-500">
|
||||||
|
{new Date(entry.startTime).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm text-gray-500">
|
||||||
|
{new Date(entry.endTime).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-sm text-gray-500">
|
||||||
|
{entry.projectId || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteMutation.mutate(entry.id)}
|
onClick={() => deleteMutation.mutate(entry.id)}
|
||||||
className="text-red-600 hover:text-red-800 font-medium"
|
className="p-2 text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
Delete
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
{expandedRows[entry.id] && (
|
||||||
})}
|
<tr>
|
||||||
|
<td colSpan={6} className="p-4 bg-gray-50 border-l-4 border-blue-500">
|
||||||
|
<div className="text-sm text-gray-600 prose prose-sm max-w-none">
|
||||||
|
{renderSimpleMarkdown(entry.notes)}
|
||||||
|
{!entry.notes && <span className="italic text-gray-400">No notes provided.</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
211
scripts/phase10_features.py
Normal file
211
scripts/phase10_features.py
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Phase-10: markdown-notes, customer-tags, project-templates, language-toggle, keyboard-help."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
PHASE10_STATE = ROOT / ".phase10-state.json"
|
||||||
|
|
||||||
|
FEATURES: list[Feature] = [
|
||||||
|
Feature(
|
||||||
|
name="markdown-notes-time-entry",
|
||||||
|
description="Markdown-Notes-Feld pro Time-Entry + Render in Liste",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/api/src/db/schema.ts",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — füge `notes: text('notes')` Spalte (nullable) zu time_entries Tabelle. "
|
||||||
|
"Behalte alle bestehenden Spalten + Tabellen."
|
||||||
|
),
|
||||||
|
refs=["apps/api/src/db/schema.ts"],
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/pages/TimeEntries.tsx",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — behalte alles. Füge `notes` Textarea (optional, expandable) zum Create-Form. "
|
||||||
|
"In der Liste: kleines Expand-Icon pro row, beim Klick zeigt notes als Markdown gerendert "
|
||||||
|
"(simple: nur \\n→<br/>, **bold** → <strong>, *italic* → <em>; oder benutze 'marked' npm dep wenn installiert)."
|
||||||
|
),
|
||||||
|
refs=["apps/web/src/pages/TimeEntries.tsx"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Feature(
|
||||||
|
name="customer-tags",
|
||||||
|
description="Tags-Feld bei Customers + Filter-by-Tag",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/api/src/db/schema.ts",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — füge `tags: text('tags').array().notNull().default([])` Spalte zu customers. "
|
||||||
|
"Behalte alles."
|
||||||
|
),
|
||||||
|
refs=["apps/api/src/db/schema.ts"],
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/pages/Customers.tsx",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — füge Tags-Input (kommagetrennt) zum Create-Form. "
|
||||||
|
"Zeige Tags als Chips in der Tabelle. Filter oben: 'Filter by tag…' input, "
|
||||||
|
"filtert client-side die Liste auf Customers wo tag.includes(filter)."
|
||||||
|
),
|
||||||
|
refs=["apps/web/src/pages/Customers.tsx"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Feature(
|
||||||
|
name="project-templates",
|
||||||
|
description="Wiederverwendbare Project-Templates (admin)",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/api/src/db/schema.ts",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — füge `projectTemplates` Tabelle: id (uuid pk), name (text), defaultBillable (boolean), "
|
||||||
|
"estimatedHours (integer nullable), createdAt (timestamp). Behalte alles."
|
||||||
|
),
|
||||||
|
refs=["apps/api/src/db/schema.ts"],
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/api/src/routes/project-templates.ts",
|
||||||
|
purpose=(
|
||||||
|
"Fastify-Plugin /api/project-templates. CRUD, admin-only via preHandler+role-check. "
|
||||||
|
"GET/POST/PATCH/DELETE."
|
||||||
|
),
|
||||||
|
refs=["apps/api/src/routes/customers.ts"],
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/pages/ProjectTemplates.tsx",
|
||||||
|
purpose=(
|
||||||
|
"ProjectTemplates-Page (admin-only). Liste + Create-Form (name, defaultBillable checkbox, estimatedHours number). "
|
||||||
|
"Beim Project-Create (in Projects.tsx) optional: 'aus Template'-Dropdown — wenn gewählt, pre-fillen."
|
||||||
|
),
|
||||||
|
refs=["apps/web/src/pages/Customers.tsx"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Feature(
|
||||||
|
name="language-toggle",
|
||||||
|
description="i18n-Stub mit DE/EN-Toggle (localStorage)",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/lib/i18n.tsx",
|
||||||
|
purpose=(
|
||||||
|
"Mini-i18n. useTranslation()-Hook: returns {t, setLang, lang}. "
|
||||||
|
"Dictionary inline mit ~20 wichtigsten Keys ('login', 'dashboard', 'customers', 'projects', "
|
||||||
|
"'time_entries', 'logout', 'save', 'delete', etc.) in DE+EN. "
|
||||||
|
"Persist in localStorage 'lang'."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/components/Nav.tsx",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — füge Sprach-Toggle (DE/EN Button) rechts neben Theme-Toggle. "
|
||||||
|
"Behalte alle bestehenden Links."
|
||||||
|
),
|
||||||
|
refs=["apps/web/src/components/Nav.tsx"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Feature(
|
||||||
|
name="keyboard-help-modal",
|
||||||
|
description="Help-Modal mit Keyboard-Shortcuts (?-Hotkey)",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/components/KeyboardHelp.tsx",
|
||||||
|
purpose=(
|
||||||
|
"Help-Modal. Triggered by '?'-Taste (window-keydown). Liste der Shortcuts: "
|
||||||
|
"Cmd+K = Command Palette, ? = Diese Hilfe, T = Theme-Toggle, G then D = Dashboard, "
|
||||||
|
"G then C = Customers, G then P = Projects, G then T = TimeEntries. "
|
||||||
|
"Escape schließt. Tailwind centered modal mit kbd-styled keys."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/App.tsx",
|
||||||
|
purpose="ERWEITERT — mounte <KeyboardHelp /> global im Root-Route. Behalte alles.",
|
||||||
|
refs=["apps/web/src/App.tsx"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Feature(
|
||||||
|
name="api-client-phase10",
|
||||||
|
description="API um project-templates erweitern",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/lib/api.ts",
|
||||||
|
purpose=(
|
||||||
|
"ERWEITERT — behalte ALLES. Füge: listProjectTemplates(), createProjectTemplate(data), "
|
||||||
|
"updateProjectTemplate(id, data), deleteProjectTemplate(id)."
|
||||||
|
),
|
||||||
|
refs=["apps/web/src/lib/api.ts"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Feature(
|
||||||
|
name="router-phase10",
|
||||||
|
description="App + routes/index für /project-templates",
|
||||||
|
files=[
|
||||||
|
FileGen(
|
||||||
|
path="apps/api/src/routes/index.ts",
|
||||||
|
purpose="ERWEITERT — füge projectTemplateRoutes ('/api/project-templates').",
|
||||||
|
refs=["apps/api/src/routes/index.ts"],
|
||||||
|
),
|
||||||
|
FileGen(
|
||||||
|
path="apps/web/src/App.tsx",
|
||||||
|
purpose="ERWEITERT — füge /project-templates (admin-only). Behalte alles.",
|
||||||
|
refs=["apps/web/src/App.tsx"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
if PHASE10_STATE.exists():
|
||||||
|
return json.loads(PHASE10_STATE.read_text())
|
||||||
|
return {"completed_features": [], "current_feature": None, "started_at": datetime.datetime.now().isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict) -> None:
|
||||||
|
PHASE10_STATE.write_text(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
log_section("🚀 Phase-10 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-10 Run beendet")
|
||||||
|
log(f"OK: {len(state.get('completed_features', []))}, Attempted: {len(state.get('attempted_features', []))}, Total: {len(FEATURES)}")
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
r = subprocess.run(["pnpm", "--filter", "api", "db:generate"], cwd=ROOT, capture_output=True, text=True, timeout=60)
|
||||||
|
log(f" db:generate rc={r.returncode}: {r.stdout[-200:]}")
|
||||||
|
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