Compare commits
No commits in common. "f61fd266623f0d295eb659217787a4d56657e073" and "940e492359a2c739022d9a5a2ef9bb2595fd9f67" have entirely different histories.
f61fd26662
...
940e492359
@ -1,130 +1,3 @@
|
|||||||
# EmberClone — Generation Log
|
# EmberClone — Generation Log
|
||||||
|
|
||||||
Schritt-für-Schritt-Historie aller Gemma-Code-Generierungen.
|
Schritt-für-Schritt-Historie aller Gemma-Code-Generierungen.
|
||||||
|
|
||||||
## EmberClone Codegen-Run gestartet (2026-05-23 04:24:46)
|
|
||||||
|
|
||||||
- `04:24:46` **INFO** Specs: 18 Files zu generieren
|
|
||||||
- `04:24:46` **INFO** vLLM: http://127.0.0.1:8000/v1/chat/completions, Model: gemma-4-31b
|
|
||||||
- `04:24:46` **INFO** Pinging Gemma …
|
|
||||||
- `04:24:46` **INFO** Gemma pong ok: 'pong'
|
|
||||||
|
|
||||||
## Generiere packages/shared/src/schemas.ts (2026-05-23 04:24:46)
|
|
||||||
|
|
||||||
- `04:24:46` **INFO** Attempt 1/3 für packages/shared/src/schemas.ts
|
|
||||||
- `04:25:03` **INFO** wrote 1956 chars in 16.8s
|
|
||||||
- `04:25:03` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere packages/shared/src/index.ts (2026-05-23 04:25:03)
|
|
||||||
|
|
||||||
- `04:25:03` **INFO** Attempt 1/3 für packages/shared/src/index.ts
|
|
||||||
- `04:25:03` **INFO** wrote 25 chars in 0.3s
|
|
||||||
- `04:25:03` **WARN** syntax check failed: too short (25 chars)
|
|
||||||
- `04:25:03` **INFO** Attempt 2/3 für packages/shared/src/index.ts
|
|
||||||
- `04:25:03` **INFO** wrote 25 chars in 0.2s
|
|
||||||
- `04:25:03` **WARN** syntax check failed: too short (25 chars)
|
|
||||||
- `04:25:03` **INFO** Attempt 3/3 für packages/shared/src/index.ts
|
|
||||||
- `04:25:04` **INFO** wrote 25 chars in 0.2s
|
|
||||||
- `04:25:04` **WARN** syntax check failed: too short (25 chars)
|
|
||||||
- `04:25:04` **ERROR** GAVE UP after 3 attempts: too short (25 chars)
|
|
||||||
|
|
||||||
## Generiere apps/api/src/db/schema.ts (2026-05-23 04:25:04)
|
|
||||||
|
|
||||||
- `04:25:04` **INFO** Attempt 1/3 für apps/api/src/db/schema.ts
|
|
||||||
- `04:25:16` **INFO** wrote 1440 chars in 12.2s
|
|
||||||
- `04:25:16` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/api/src/db/index.ts (2026-05-23 04:25:16)
|
|
||||||
|
|
||||||
- `04:25:16` **INFO** Attempt 1/3 für apps/api/src/db/index.ts
|
|
||||||
- `04:25:19` **INFO** wrote 328 chars in 2.8s
|
|
||||||
- `04:25:19` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/api/src/db/migrate.ts (2026-05-23 04:25:19)
|
|
||||||
|
|
||||||
- `04:25:19` **INFO** Attempt 1/3 für apps/api/src/db/migrate.ts
|
|
||||||
- `04:25:28` **INFO** wrote 1105 chars in 9.4s
|
|
||||||
- `04:25:28` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/api/src/routes/auth.ts (2026-05-23 04:25:28)
|
|
||||||
|
|
||||||
- `04:25:28` **INFO** Attempt 1/3 für apps/api/src/routes/auth.ts
|
|
||||||
- `04:25:45` **INFO** wrote 1852 chars in 17.2s
|
|
||||||
- `04:25:45` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/api/src/routes/time-entries.ts (2026-05-23 04:25:45)
|
|
||||||
|
|
||||||
- `04:25:45` **INFO** Attempt 1/3 für apps/api/src/routes/time-entries.ts
|
|
||||||
- `04:26:21` **INFO** wrote 3875 chars in 35.8s
|
|
||||||
- `04:26:21` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/api/src/routes/index.ts (2026-05-23 04:26:21)
|
|
||||||
|
|
||||||
- `04:26:21` **INFO** Attempt 1/3 für apps/api/src/routes/index.ts
|
|
||||||
- `04:26:24` **INFO** wrote 318 chars in 3.0s
|
|
||||||
- `04:26:24` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/api/src/index.ts (2026-05-23 04:26:24)
|
|
||||||
|
|
||||||
- `04:26:24` **INFO** Attempt 1/3 für apps/api/src/index.ts
|
|
||||||
- `04:26:32` **INFO** wrote 806 chars in 8.0s
|
|
||||||
- `04:26:32` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/main.tsx (2026-05-23 04:26:32)
|
|
||||||
|
|
||||||
- `04:26:32` **INFO** Attempt 1/3 für apps/web/src/main.tsx
|
|
||||||
- `04:26:39` **INFO** wrote 855 chars in 7.1s
|
|
||||||
- `04:26:39` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/lib/api.ts (2026-05-23 04:26:39)
|
|
||||||
|
|
||||||
- `04:26:39` **INFO** Attempt 1/3 für apps/web/src/lib/api.ts
|
|
||||||
- `04:26:54` **INFO** wrote 1625 chars in 14.2s
|
|
||||||
- `04:26:54` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/pages/Login.tsx (2026-05-23 04:26:54)
|
|
||||||
|
|
||||||
- `04:26:54` **INFO** Attempt 1/3 für apps/web/src/pages/Login.tsx
|
|
||||||
- `04:27:17` **INFO** wrote 2773 chars in 23.3s
|
|
||||||
- `04:27:17` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/pages/Dashboard.tsx (2026-05-23 04:27:17)
|
|
||||||
|
|
||||||
- `04:27:17` **INFO** Attempt 1/3 für apps/web/src/pages/Dashboard.tsx
|
|
||||||
- `04:27:37` **INFO** wrote 2229 chars in 20.1s
|
|
||||||
- `04:27:37` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/pages/TimeEntries.tsx (2026-05-23 04:27:37)
|
|
||||||
|
|
||||||
- `04:27:37` **INFO** Attempt 1/3 für apps/web/src/pages/TimeEntries.tsx
|
|
||||||
- `04:28:26` **INFO** wrote 6015 chars in 48.7s
|
|
||||||
- `04:28:26` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/App.tsx (2026-05-23 04:28:26)
|
|
||||||
|
|
||||||
- `04:28:26` **INFO** Attempt 1/3 für apps/web/src/App.tsx
|
|
||||||
- `04:28:39` **INFO** wrote 1466 chars in 13.6s
|
|
||||||
- `04:28:39` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/src/index.css (2026-05-23 04:28:39)
|
|
||||||
|
|
||||||
- `04:28:39` **INFO** Attempt 1/3 für apps/web/src/index.css
|
|
||||||
- `04:28:41` **INFO** wrote 149 chars in 1.6s
|
|
||||||
- `04:28:41` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/postcss.config.cjs (2026-05-23 04:28:41)
|
|
||||||
|
|
||||||
- `04:28:41` **INFO** Attempt 1/3 für apps/web/postcss.config.cjs
|
|
||||||
- `04:28:42` **INFO** wrote 81 chars in 0.8s
|
|
||||||
- `04:28:42` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Generiere apps/web/tailwind.config.ts (2026-05-23 04:28:42)
|
|
||||||
|
|
||||||
- `04:28:42` **INFO** Attempt 1/3 für apps/web/tailwind.config.ts
|
|
||||||
- `04:28:45` **INFO** wrote 294 chars in 3.2s
|
|
||||||
- `04:28:45` **INFO** syntax check ok
|
|
||||||
|
|
||||||
## Codegen-Run beendet (2026-05-23 04:28:45)
|
|
||||||
|
|
||||||
- `04:28:45` **INFO** ok: 17/18, fail: 1/18
|
|
||||||
- `04:28:45` **WARN** 1 Files mit final-Fehler. Manuelle Inspektion nötig.
|
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS "customers" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE IF NOT EXISTS "projects" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"customer_id" uuid NOT NULL,
|
|
||||||
"active" boolean DEFAULT true NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE IF NOT EXISTS "time_entries" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" uuid NOT NULL,
|
|
||||||
"project_id" uuid,
|
|
||||||
"description" text NOT NULL,
|
|
||||||
"start_time" timestamp NOT NULL,
|
|
||||||
"end_time" timestamp,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE IF NOT EXISTS "users" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"email" text NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"role" text NOT NULL,
|
|
||||||
"password_hash" text NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
@ -1,262 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "cf31898f-9a96-4dfd-ac9e-23326daf55fb",
|
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"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.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": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1779503479059,
|
|
||||||
"tag": "0000_empty_talon",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -12,7 +12,6 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emberclone/shared": "workspace:*",
|
|
||||||
"@fastify/cookie": "^9.3.1",
|
"@fastify/cookie": "^9.3.1",
|
||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/jwt": "^8.0.1",
|
"@fastify/jwt": "^8.0.1",
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
import { drizzle } from "drizzle-orm/node-postgres"
|
|
||||||
import { Pool } from "pg"
|
|
||||||
import * as schema from "./schema"
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL || "postgresql://emberclone:emberclone@localhost:5433/emberclone"
|
|
||||||
|
|
||||||
export const pool = new Pool({
|
|
||||||
connectionString
|
|
||||||
})
|
|
||||||
|
|
||||||
export const db = drizzle(pool, { schema })
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
|
||||||
import { db } from "./index"
|
|
||||||
import { users } from "./schema"
|
|
||||||
import * as argon2 from "argon2"
|
|
||||||
|
|
||||||
async function runMigration() {
|
|
||||||
try {
|
|
||||||
console.log("Running migrations...")
|
|
||||||
await migrate(db, { migrationsFolder: "./drizzle" })
|
|
||||||
console.log("Migrations completed successfully")
|
|
||||||
|
|
||||||
console.log("Checking for admin user...")
|
|
||||||
const existingAdmin = await db.query.users.findFirst({
|
|
||||||
where: (users, { eq }) => eq(users.email, "admin@emberclone.local")
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!existingAdmin) {
|
|
||||||
console.log("Creating default admin user...")
|
|
||||||
const passwordHash = await argon2.hash("emberclone2026")
|
|
||||||
|
|
||||||
await db.insert(users).values({
|
|
||||||
email: "admin@emberclone.local",
|
|
||||||
passwordHash,
|
|
||||||
name: "Admin",
|
|
||||||
role: "admin"
|
|
||||||
})
|
|
||||||
console.log("Default admin user created: admin@emberclone.local")
|
|
||||||
} else {
|
|
||||||
console.log("Admin user already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Migration failed:", error)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runMigration()
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
import { pgTable, text, timestamp, boolean, uuid } from "drizzle-orm/pg-core"
|
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
email: text("email").notNull().unique(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
role: text("role").notNull(),
|
|
||||||
passwordHash: text("password_hash").notNull(),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const customers = pgTable("customers", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const projects = pgTable("projects", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
customerId: uuid("customer_id").notNull().references(() => customers.id, { onDelete: "cascade" }),
|
|
||||||
active: boolean("active").notNull().default(true),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const timeEntries = pgTable("time_entries", {
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
description: text("description").notNull(),
|
|
||||||
startTime: timestamp("start_time").notNull(),
|
|
||||||
endTime: timestamp("end_time"),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow()
|
|
||||||
})
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import Fastify from "fastify"
|
|
||||||
import cors from "@fastify/cors"
|
|
||||||
import cookie from "@fastify/cookie"
|
|
||||||
import jwt from "@fastify/jwt"
|
|
||||||
import { setupRoutes } from "./routes/index"
|
|
||||||
|
|
||||||
const server = Fastify({
|
|
||||||
logger: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 4001
|
|
||||||
const HOST = "0.0.0.0"
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
await server.register(cors, {
|
|
||||||
origin: "http://localhost:5174",
|
|
||||||
credentials: true
|
|
||||||
})
|
|
||||||
|
|
||||||
await server.register(cookie)
|
|
||||||
|
|
||||||
await server.register(jwt, {
|
|
||||||
secret: process.env.JWT_SECRET || "dev-secret-change-me"
|
|
||||||
})
|
|
||||||
|
|
||||||
server.get("/health", async () => {
|
|
||||||
return { status: "ok" }
|
|
||||||
})
|
|
||||||
|
|
||||||
await setupRoutes(server)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await server.listen({ port: PORT, host: HOST })
|
|
||||||
} catch (err) {
|
|
||||||
server.log.error(err)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start()
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
import { FastifyPluginAsync } from "fastify"
|
|
||||||
import argon2 from "argon2"
|
|
||||||
import { db } from "../db"
|
|
||||||
import { users } from "../db/schema"
|
|
||||||
import { eq } from "drizzle-orm"
|
|
||||||
import { LoginRequestSchema } from "@emberclone/shared"
|
|
||||||
|
|
||||||
export default async function authRoutes(fastify: FastifyPluginAsync) {
|
|
||||||
fastify.post("/login", async (request, reply) => {
|
|
||||||
const body = LoginRequestSchema.parse(request.body)
|
|
||||||
|
|
||||||
const [user] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.email, body.email))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!user || !(await argon2.verify(user.passwordHash, body.password))) {
|
|
||||||
return reply.code(401).send({ message: "Invalid credentials" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = fastify.jwt.sign({
|
|
||||||
sub: user.id,
|
|
||||||
role: user.role
|
|
||||||
})
|
|
||||||
|
|
||||||
reply
|
|
||||||
.setCookie("token", token, {
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
sameSite: "lax"
|
|
||||||
})
|
|
||||||
.send({
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.get("/me", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
await request.jwtVerify()
|
|
||||||
const { sub } = request.user as { sub: string }
|
|
||||||
|
|
||||||
const [user] = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, sub))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return reply.code(404).send({ message: "User not found" })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(401).send({ message: "Unauthorized" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.post("/logout", async (request, reply) => {
|
|
||||||
reply
|
|
||||||
.clearCookie("token", {
|
|
||||||
path: "/"
|
|
||||||
})
|
|
||||||
.send({ message: "Logged out" })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
|
||||||
import authRoutes from "./auth"
|
|
||||||
import timeEntryRoutes from "./time-entries"
|
|
||||||
|
|
||||||
export async function setupRoutes(server: FastifyInstance) {
|
|
||||||
server.register(authRoutes, {
|
|
||||||
prefix: "/api/auth"
|
|
||||||
})
|
|
||||||
|
|
||||||
server.register(timeEntryRoutes, {
|
|
||||||
prefix: "/api/time-entries"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
import { FastifyPluginAsync } from "fastify"
|
|
||||||
import { db } from "../db"
|
|
||||||
import { timeEntries } from "../db/schema"
|
|
||||||
import { eq, and, gte, lte, or } from "drizzle-orm"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
const TimeEntrySchema = z.object({
|
|
||||||
projectId: z.string().uuid().optional(),
|
|
||||||
description: z.string().min(1),
|
|
||||||
startTime: z.string().datetime(),
|
|
||||||
endTime: z.string().datetime().optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
const TimeEntryUpdateSchema = TimeEntrySchema.partial()
|
|
||||||
|
|
||||||
export default async function timeEntryRoutes(fastify: FastifyPluginAsync) {
|
|
||||||
fastify.addHook("preHandler", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
await request.jwtVerify()
|
|
||||||
} catch (err) {
|
|
||||||
return reply.code(401).send({ message: "Unauthorized" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.get("/", async (request, reply) => {
|
|
||||||
const { from, to } = request.query as { from?: string; to?: string }
|
|
||||||
const user = request.user as { sub: string; role: string }
|
|
||||||
|
|
||||||
const filters = []
|
|
||||||
|
|
||||||
if (user.role !== "admin") {
|
|
||||||
filters.push(eq(timeEntries.userId, user.sub))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (from) {
|
|
||||||
filters.push(gte(timeEntries.startTime, new Date(from)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to) {
|
|
||||||
filters.push(lte(timeEntries.startTime, new Date(to)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = await db
|
|
||||||
.select()
|
|
||||||
.from(timeEntries)
|
|
||||||
.where(and(...filters))
|
|
||||||
.orderBy(timeEntries.startTime)
|
|
||||||
|
|
||||||
return entries
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.get("/:id", async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const user = request.user as { sub: string; role: string }
|
|
||||||
|
|
||||||
const [entry] = await db
|
|
||||||
.select()
|
|
||||||
.from(timeEntries)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(timeEntries.id, id),
|
|
||||||
user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return reply.code(404).send({ message: "Time entry not found" })
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.post("/", async (request, reply) => {
|
|
||||||
const user = request.user as { sub: string }
|
|
||||||
const body = TimeEntrySchema.parse(request.body)
|
|
||||||
|
|
||||||
const [entry] = await db
|
|
||||||
.insert(timeEntries)
|
|
||||||
.values({
|
|
||||||
...body,
|
|
||||||
userId: user.sub,
|
|
||||||
startTime: new Date(body.startTime),
|
|
||||||
endTime: body.endTime ? new Date(body.endTime) : null
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return reply.code(201).send(entry)
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.patch("/:id", async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const user = request.user as { sub: string; role: string }
|
|
||||||
const body = TimeEntryUpdateSchema.parse(request.body)
|
|
||||||
|
|
||||||
const [entry] = await db
|
|
||||||
.select()
|
|
||||||
.from(timeEntries)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(timeEntries.id, id),
|
|
||||||
user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return reply.code(404).send({ message: "Time entry not found" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: any = { ...body }
|
|
||||||
if (body.startTime) updateData.startTime = new Date(body.startTime)
|
|
||||||
if (body.endTime) updateData.endTime = body.endTime ? new Date(body.endTime) : null
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(timeEntries)
|
|
||||||
.set(updateData)
|
|
||||||
.where(eq(timeEntries.id, id))
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
return updated
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.delete("/:id", async (request, reply) => {
|
|
||||||
const { id } = request.params as { id: string }
|
|
||||||
const user = request.user as { sub: string; role: string }
|
|
||||||
|
|
||||||
const [entry] = await db
|
|
||||||
.select()
|
|
||||||
.from(timeEntries)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(timeEntries.id, id),
|
|
||||||
user.role === "admin" ? undefined : eq(timeEntries.userId, user.sub)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return reply.code(404).send({ message: "Time entry not found" })
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(timeEntries).where(eq(timeEntries.id, id))
|
|
||||||
|
|
||||||
return reply.code(204).send()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@
|
|||||||
"preview": "vite preview --port 5174"
|
"preview": "vite preview --port 5174"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emberclone/shared": "workspace:*",
|
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tanstack/react-router": "^1.62.7",
|
"@tanstack/react-router": "^1.62.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
import { createRootRoute, createRoute, createRouter, RouterProvider, Outlet, redirect } from "@tanstack/react-router"
|
|
||||||
import Dashboard from "./pages/Dashboard"
|
|
||||||
import Login from "./pages/Login"
|
|
||||||
import TimeEntries from "./pages/TimeEntries"
|
|
||||||
import { api } from "./lib/api"
|
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
|
||||||
component: () => (
|
|
||||||
<div className="min-h-screen bg-slate-50">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/login",
|
|
||||||
component: Login
|
|
||||||
})
|
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/",
|
|
||||||
beforeLoad: async () => {
|
|
||||||
try {
|
|
||||||
await api.getMe()
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.status === 401) {
|
|
||||||
throw redirect({ to: "/login" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: Dashboard
|
|
||||||
})
|
|
||||||
|
|
||||||
const timeEntriesRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/time-entries",
|
|
||||||
beforeLoad: async () => {
|
|
||||||
try {
|
|
||||||
await api.getMe()
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.status === 401) {
|
|
||||||
throw redirect({ to: "/login" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
component: TimeEntries
|
|
||||||
})
|
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
|
||||||
indexRoute,
|
|
||||||
loginRoute,
|
|
||||||
timeEntriesRoute
|
|
||||||
])
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
routeTree,
|
|
||||||
defaultPreload: "intent"
|
|
||||||
})
|
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
|
||||||
interface Register {
|
|
||||||
router: typeof router
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <RouterProvider router={router} />
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
@apply bg-slate-50 text-slate-900 font-sans antialiased;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import type { TimeEntryInsert } from "@emberclone/shared"
|
|
||||||
|
|
||||||
const API_BASE = "/api"
|
|
||||||
|
|
||||||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
|
||||||
const token = localStorage.getItem("auth_token")
|
|
||||||
const headers = new Headers(options.headers)
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers.set("Authorization", `Bearer ${token}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
||||||
...options,
|
|
||||||
headers,
|
|
||||||
credentials: "include"
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}))
|
|
||||||
const err: any = new Error(errorData.message || `API Error: ${response.status}`)
|
|
||||||
err.status = response.status
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
async login(email: string, password: string) {
|
|
||||||
const data = await request<{ token: string }>("/auth/login", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ email, password })
|
|
||||||
})
|
|
||||||
if (data?.token) {
|
|
||||||
localStorage.setItem("auth_token", data.token)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
logout() {
|
|
||||||
localStorage.removeItem("auth_token")
|
|
||||||
return request("/auth/logout", { method: "POST" }).catch(() => {})
|
|
||||||
},
|
|
||||||
|
|
||||||
async getMe() {
|
|
||||||
return request<{ id: string; email: string; name: string; role: "admin" | "user" }>("/auth/me")
|
|
||||||
},
|
|
||||||
|
|
||||||
async listTimeEntries(opts?: Record<string, string>) {
|
|
||||||
const query = opts ? `?${new URLSearchParams(opts).toString()}` : ""
|
|
||||||
return request<any[]>(`/time-entries${query}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
async createTimeEntry(data: Partial<TimeEntryInsert>) {
|
|
||||||
return request("/time-entries", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteTimeEntry(id: string) {
|
|
||||||
return request(`/time-entries/${id}`, {
|
|
||||||
method: "DELETE"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import ReactDOM from "react-dom/client"
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
||||||
import App from "./App"
|
|
||||||
import "./index.css"
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: 1,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</React.StrictMode>
|
|
||||||
)
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
|
||||||
import { api } from "../lib/api"
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const { data: user, isLoading: userLoading } = useQuery({
|
|
||||||
queryKey: ["me"],
|
|
||||||
queryFn: () => api.getMe()
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: entries, isLoading: entriesLoading } = useQuery({
|
|
||||||
queryKey: ["timeEntries", "today"],
|
|
||||||
queryFn: () => api.listTimeEntries({ date: new Date().toISOString().split("T")[0] })
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
api.logout()
|
|
||||||
navigate({ to: "/login" })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userLoading || entriesLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 p-6">
|
|
||||||
<header className="flex justify-between items-center mb-8 max-w-7xl mx-auto">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Dashboard</h1>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition-colors"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="col-span-1 md:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-gray-200">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-700 mb-2">
|
|
||||||
Hallo, {user?.name || "Benutzer"}!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
Willkommen zurück in deinem EmberClone. Hier ist die Übersicht deiner heutigen Aktivitäten.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 flex flex-col justify-center items-center text-center">
|
|
||||||
<span className="text-sm font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Heutige Einträge
|
|
||||||
</span>
|
|
||||||
<span className="text-5xl font-bold text-indigo-600 mt-2">
|
|
||||||
{entries?.length || 0}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { useNavigate } from "@tanstack/react-router"
|
|
||||||
import { api } from "../lib/api"
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const [email, setEmail] = useState("admin@emberclone.local")
|
|
||||||
const [password, setPassword] = useState("emberclone2026")
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setError(null)
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.login(email, password)
|
|
||||||
navigate({ to: "/" })
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || "Login failed")
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
||||||
<div className="w-full max-w-md bg-white rounded-xl shadow-sm border border-slate-200 p-8">
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">EmberClone</h1>
|
|
||||||
<p className="text-slate-500 mt-2">Sign in to your account</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isLoading ? "Signing in..." : "Sign In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
||||||
import { api } from "../lib/api"
|
|
||||||
import type { TimeEntryInsert } from "@emberclone/shared"
|
|
||||||
|
|
||||||
export default function TimeEntries() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
description: "",
|
|
||||||
startTime: "",
|
|
||||||
endTime: "",
|
|
||||||
projectId: ""
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: entries, isLoading, isError } = useQuery({
|
|
||||||
queryKey: ["time-entries"],
|
|
||||||
queryFn: () => api.listTimeEntries()
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: (data: Partial<TimeEntryInsert>) => api.createTimeEntry(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
|
|
||||||
setFormData({ description: "", startTime: "", endTime: "", projectId: "" })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: (id: string) => api.deleteTimeEntry(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["time-entries"] })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
createMutation.mutate({
|
|
||||||
description: formData.description,
|
|
||||||
startTime: new Date(formData.startTime) as any,
|
|
||||||
endTime: new Date(formData.endTime) as any,
|
|
||||||
projectId: formData.projectId || undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) return <div className="p-6 text-gray-500">Loading entries...</div>
|
|
||||||
if (isError) return <div className="p-6 text-red-500">Error loading time entries.</div>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
|
||||||
<header>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Time Tracking</h1>
|
|
||||||
<p className="text-gray-500">Manage your work logs and project hours</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div className="md:col-span-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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.description}
|
|
||||||
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</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</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 className="flex items-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending}
|
|
||||||
className="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 font-medium"
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? "Saving..." : "Add Entry"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="overflow-x-auto bg-white rounded-lg border border-gray-200 shadow-sm">
|
|
||||||
<table className="w-full text-left border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
|
||||||
<th className="px-6 py-3 text-sm font-semibold text-gray-600">Description</th>
|
|
||||||
<th className="px-6 py-3 text-sm font-semibold text-gray-600">Start</th>
|
|
||||||
<th className="px-6 py-3 text-sm font-semibold text-gray-600">End</th>
|
|
||||||
<th className="px-6 py-3 text-sm font-semibold text-gray-600 text-right">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{entries?.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-400">No entries found.</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{entries?.map((entry: any) => (
|
|
||||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-800">{entry.description}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(entry.startTime).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-500">
|
|
||||||
{new Date(entry.endTime).toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm("Delete this entry?")) {
|
|
||||||
deleteMutation.mutate(entry.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="text-red-500 hover:text-red-700 text-sm font-medium disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss"
|
|
||||||
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{ts,tsx}"
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
ember: {
|
|
||||||
500: "#f97316",
|
|
||||||
600: "#ea580c"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: []
|
|
||||||
} satisfies Config
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export * from "./schemas"
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
export const UserRoleSchema = z.enum(["admin", "user"])
|
|
||||||
|
|
||||||
export const UserInsertSchema = z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
name: z.string().min(1),
|
|
||||||
role: UserRoleSchema,
|
|
||||||
passwordHash: z.string().min(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const UserSelectSchema = UserInsertSchema.extend({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
createdAt: z.date()
|
|
||||||
}).omit({ passwordHash: true })
|
|
||||||
|
|
||||||
export const CustomerInsertSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
active: z.boolean().default(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const CustomerSelectSchema = CustomerInsertSchema.extend({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
createdAt: z.date()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ProjectInsertSchema = z.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
customerId: z.string().uuid(),
|
|
||||||
active: z.boolean().default(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ProjectSelectSchema = ProjectInsertSchema.extend({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
createdAt: z.date()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const TimeEntryInsertSchema = z.object({
|
|
||||||
userId: z.string().uuid(),
|
|
||||||
projectId: z.string().uuid().optional(),
|
|
||||||
description: z.string().min(1),
|
|
||||||
startTime: z.date(),
|
|
||||||
endTime: z.date().optional()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const TimeEntrySelectSchema = TimeEntryInsertSchema.extend({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
createdAt: z.date()
|
|
||||||
})
|
|
||||||
|
|
||||||
export const LoginRequestSchema = z.object({
|
|
||||||
email: z.string().email(),
|
|
||||||
password: z.string().min(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
export type UserInsert = z.infer<typeof UserInsertSchema>
|
|
||||||
export type UserSelect = z.infer<typeof UserSelectSchema>
|
|
||||||
export type UserRole = z.infer<typeof UserRoleSchema>
|
|
||||||
|
|
||||||
export type CustomerInsert = z.infer<typeof CustomerInsertSchema>
|
|
||||||
export type CustomerSelect = z.infer<typeof CustomerSelectSchema>
|
|
||||||
|
|
||||||
export type ProjectInsert = z.infer<typeof ProjectInsertSchema>
|
|
||||||
export type ProjectSelect = z.infer<typeof ProjectSelectSchema>
|
|
||||||
|
|
||||||
export type TimeEntryInsert = z.infer<typeof TimeEntryInsertSchema>
|
|
||||||
export type TimeEntrySelect = z.infer<typeof TimeEntrySelectSchema>
|
|
||||||
|
|
||||||
export type LoginRequest = z.infer<typeof LoginRequestSchema>
|
|
||||||
3350
pnpm-lock.yaml
generated
3350
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user