From ad7dc187803a84a4a493176c28d5d0ad76afddd4 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Tue, 20 Jan 2026 11:09:09 -0700 Subject: [PATCH] Switch to tags --- drizzle/0006_good_malcolm_colcord.sql | 2 + drizzle/meta/0006_snapshot.json | 1266 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/components/CategoryChart.vue | 64 - src/components/ManualEntry.vue | 68 +- src/components/Timer.vue | 70 +- src/db/schema.ts | 29 - src/lib/validation.ts | 37 +- src/pages/api/categories/[id]/delete.ts | 67 - src/pages/api/categories/[id]/update.ts | 72 - src/pages/api/categories/create.ts | 59 - src/pages/api/invoices/[id]/generate.ts | 178 ++- src/pages/api/reports/export.ts | 151 +- src/pages/api/time-entries/manual.ts | 11 +- src/pages/api/time-entries/start.ts | 7 - src/pages/dashboard/categories.astro | 62 - .../dashboard/categories/[id]/edit.astro | 99 -- src/pages/dashboard/categories/new.astro | 54 - src/pages/dashboard/team/settings.astro | 61 +- .../team/settings/categories/[id]/edit.astro | 93 -- .../team/settings/categories/new.astro | 53 - src/pages/dashboard/tracker.astro | 63 +- 22 files changed, 1516 insertions(+), 1057 deletions(-) create mode 100644 drizzle/0006_good_malcolm_colcord.sql create mode 100644 drizzle/meta/0006_snapshot.json delete mode 100644 src/components/CategoryChart.vue delete mode 100644 src/pages/api/categories/[id]/delete.ts delete mode 100644 src/pages/api/categories/[id]/update.ts delete mode 100644 src/pages/api/categories/create.ts delete mode 100644 src/pages/dashboard/categories.astro delete mode 100644 src/pages/dashboard/categories/[id]/edit.astro delete mode 100644 src/pages/dashboard/categories/new.astro delete mode 100644 src/pages/dashboard/team/settings/categories/[id]/edit.astro delete mode 100644 src/pages/dashboard/team/settings/categories/new.astro diff --git a/drizzle/0006_good_malcolm_colcord.sql b/drizzle/0006_good_malcolm_colcord.sql new file mode 100644 index 0000000..affb1dc --- /dev/null +++ b/drizzle/0006_good_malcolm_colcord.sql @@ -0,0 +1,2 @@ +DROP TABLE `categories`;--> statement-breakpoint +ALTER TABLE `time_entries` DROP COLUMN `category_id`; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..b103426 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1266 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ce0dba35-7c48-46a5-acf1-436c46a6ffd2", + "prevId": "ab39405a-a606-451d-b41a-8cca25983f9d", + "tables": { + "api_tokens": { + "name": "api_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'*'" + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_tokens_token_unique": { + "name": "api_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "api_tokens_user_id_idx": { + "name": "api_tokens_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_tokens_user_id_users_id_fk": { + "name": "api_tokens_user_id_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "clients": { + "name": "clients", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "street": { + "name": "street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "zip": { + "name": "zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "clients_organization_id_idx": { + "name": "clients_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "clients_organization_id_organizations_id_fk": { + "name": "clients_organization_id_organizations_id_fk", + "tableFrom": "clients", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoice_items": { + "name": "invoice_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "unit_price": { + "name": "unit_price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "invoice_items_invoice_id_idx": { + "name": "invoice_items_invoice_id_idx", + "columns": [ + "invoice_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoice_items_invoice_id_invoices_id_fk": { + "name": "invoice_items_invoice_id_invoices_id_fk", + "tableFrom": "invoice_items", + "tableTo": "invoices", + "columnsFrom": [ + "invoice_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'invoice'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "issue_date": { + "name": "issue_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "subtotal": { + "name": "subtotal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "discount_value": { + "name": "discount_value", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'percentage'" + }, + "discount_amount": { + "name": "discount_amount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "tax_rate": { + "name": "tax_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invoices_organization_id_idx": { + "name": "invoices_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "invoices_client_id_idx": { + "name": "invoices_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_organization_id_organizations_id_fk": { + "name": "invoices_organization_id_organizations_id_fk", + "tableFrom": "invoices", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_client_id_clients_id_fk": { + "name": "invoices_client_id_clients_id_fk", + "tableFrom": "invoices", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "members": { + "name": "members", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "members_user_id_organization_id_pk": { + "columns": [ + "user_id", + "organization_id" + ], + "name": "members_user_id_organization_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "street": { + "name": "street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "zip": { + "name": "zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_tax_rate": { + "name": "default_tax_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "default_currency": { + "name": "default_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'USD'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey_challenges": { + "name": "passkey_challenges", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "passkey_challenges_challenge_unique": { + "name": "passkey_challenges_challenge_unique", + "columns": [ + "challenge" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkeys": { + "name": "passkeys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkeys_user_id_idx": { + "name": "passkeys_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkeys_user_id_users_id_fk": { + "name": "passkeys_user_id_users_id_fk", + "tableFrom": "passkeys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_settings": { + "name": "site_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "site_settings_key_unique": { + "name": "site_settings_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rate": { + "name": "rate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tags_organization_id_idx": { + "name": "tags_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tags_organization_id_organizations_id_fk": { + "name": "tags_organization_id_organizations_id_fk", + "tableFrom": "tags", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "time_entries": { + "name": "time_entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "time_entries_user_id_idx": { + "name": "time_entries_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "time_entries_organization_id_idx": { + "name": "time_entries_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "time_entries_client_id_idx": { + "name": "time_entries_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "time_entries_start_time_idx": { + "name": "time_entries_start_time_idx", + "columns": [ + "start_time" + ], + "isUnique": false + }, + "time_entries_invoice_id_idx": { + "name": "time_entries_invoice_id_idx", + "columns": [ + "invoice_id" + ], + "isUnique": false + } + }, + "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": "no action", + "onUpdate": "no action" + }, + "time_entries_organization_id_organizations_id_fk": { + "name": "time_entries_organization_id_organizations_id_fk", + "tableFrom": "time_entries", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entries_client_id_clients_id_fk": { + "name": "time_entries_client_id_clients_id_fk", + "tableFrom": "time_entries", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "time_entry_tags": { + "name": "time_entry_tags", + "columns": { + "time_entry_id": { + "name": "time_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "time_entry_tags_time_entry_id_idx": { + "name": "time_entry_tags_time_entry_id_idx", + "columns": [ + "time_entry_id" + ], + "isUnique": false + }, + "time_entry_tags_tag_id_idx": { + "name": "time_entry_tags_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "time_entry_tags_time_entry_id_time_entries_id_fk": { + "name": "time_entry_tags_time_entry_id_time_entries_id_fk", + "tableFrom": "time_entry_tags", + "tableTo": "time_entries", + "columnsFrom": [ + "time_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "time_entry_tags_tag_id_tags_id_fk": { + "name": "time_entry_tags_tag_id_tags_id_fk", + "tableFrom": "time_entry_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "time_entry_tags_time_entry_id_tag_id_pk": { + "columns": [ + "time_entry_id", + "tag_id" + ], + "name": "time_entry_tags_time_entry_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_site_admin": { + "name": "is_site_admin", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 579fa93..ae7bc1c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1768931251965, "tag": "0005_fair_skreet", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1768932542464, + "tag": "0006_good_malcolm_colcord", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/CategoryChart.vue b/src/components/CategoryChart.vue deleted file mode 100644 index 9f8f6e3..0000000 --- a/src/components/CategoryChart.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/src/components/ManualEntry.vue b/src/components/ManualEntry.vue index 92688b3..9b587e6 100644 --- a/src/components/ManualEntry.vue +++ b/src/components/ManualEntry.vue @@ -3,7 +3,6 @@ import { ref } from "vue"; const props = defineProps<{ clients: { id: string; name: string }[]; - categories: { id: string; name: string; color: string | null }[]; tags: { id: string; name: string; color: string | null }[]; }>(); @@ -13,7 +12,6 @@ const emit = defineEmits<{ const description = ref(""); const selectedClientId = ref(""); -const selectedCategoryId = ref(""); const selectedTags = ref([]); const startDate = ref(""); const startTime = ref(""); @@ -53,10 +51,6 @@ function validateForm(): string | null { return "Please select a client"; } - if (!selectedCategoryId.value) { - return "Please select a category"; - } - if (!startDate.value || !startTime.value) { return "Please enter start date and time"; } @@ -101,7 +95,6 @@ async function submitManualEntry() { body: JSON.stringify({ description: description.value, clientId: selectedClientId.value, - categoryId: selectedCategoryId.value, startTime: startDateTime, endTime: endDateTime, tags: selectedTags.value, @@ -119,7 +112,6 @@ async function submitManualEntry() { description.value = ""; selectedClientId.value = ""; - selectedCategoryId.value = ""; selectedTags.value = []; startDate.value = today; endDate.value = today; @@ -144,7 +136,6 @@ async function submitManualEntry() { function clearForm() { description.value = ""; selectedClientId.value = ""; - selectedCategoryId.value = ""; selectedTags.value = []; startDate.value = today; endDate.value = today; @@ -208,49 +199,22 @@ function clearForm() { {{ error }} - -
-
- - -
- -
- - -
+ +
+ +
diff --git a/src/components/Timer.vue b/src/components/Timer.vue index 996b395..29a18d2 100644 --- a/src/components/Timer.vue +++ b/src/components/Timer.vue @@ -7,10 +7,8 @@ const props = defineProps<{ startTime: number; description: string | null; clientId: string; - categoryId: string; } | null; clients: { id: string; name: string }[]; - categories: { id: string; name: string; color: string | null }[]; tags: { id: string; name: string; color: string | null }[]; }>(); @@ -19,7 +17,6 @@ const startTime = ref(null); const elapsedTime = ref(0); const description = ref(""); const selectedClientId = ref(""); -const selectedCategoryId = ref(""); const selectedTags = ref([]); let interval: ReturnType | null = null; @@ -63,7 +60,6 @@ onMounted(() => { startTime.value = props.initialRunningEntry.startTime; description.value = props.initialRunningEntry.description || ""; selectedClientId.value = props.initialRunningEntry.clientId; - selectedCategoryId.value = props.initialRunningEntry.categoryId; elapsedTime.value = Date.now() - startTime.value; interval = setInterval(() => { elapsedTime.value = Date.now() - startTime.value!; @@ -81,18 +77,12 @@ async function startTimer() { return; } - if (!selectedCategoryId.value) { - alert("Please select a category"); - return; - } - const res = await fetch("/api/time-entries/start", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: description.value, clientId: selectedClientId.value, - categoryId: selectedCategoryId.value, tags: selectedTags.value, }), }); @@ -119,7 +109,6 @@ async function stopTimer() { startTime.value = null; description.value = ""; selectedClientId.value = ""; - selectedCategoryId.value = ""; selectedTags.value = []; window.location.reload(); } @@ -131,49 +120,22 @@ async function stopTimer() { class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200" >
- -
-
- - -
- -
- - -
+ +
+ +
diff --git a/src/db/schema.ts b/src/db/schema.ts index 8d2c61a..adf0224 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -97,30 +97,6 @@ export const clients = sqliteTable( }), ); -export const categories = sqliteTable( - "categories", - { - id: text("id") - .primaryKey() - .$defaultFn(() => nanoid()), - organizationId: text("organization_id").notNull(), - name: text("name").notNull(), - color: text("color"), - createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( - () => new Date(), - ), - }, - (table: any) => ({ - orgFk: foreignKey({ - columns: [table.organizationId], - foreignColumns: [organizations.id], - }), - organizationIdIdx: index("categories_organization_id_idx").on( - table.organizationId, - ), - }), -); - export const timeEntries = sqliteTable( "time_entries", { @@ -130,7 +106,6 @@ export const timeEntries = sqliteTable( userId: text("user_id").notNull(), organizationId: text("organization_id").notNull(), clientId: text("client_id").notNull(), - categoryId: text("category_id").notNull(), startTime: integer("start_time", { mode: "timestamp" }).notNull(), endTime: integer("end_time", { mode: "timestamp" }), description: text("description"), @@ -153,10 +128,6 @@ export const timeEntries = sqliteTable( columns: [table.clientId], foreignColumns: [clients.id], }), - categoryFk: foreignKey({ - columns: [table.categoryId], - foreignColumns: [categories.id], - }), userIdIdx: index("time_entries_user_id_idx").on(table.userId), organizationIdIdx: index("time_entries_organization_id_idx").on( table.organizationId, diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f19c8ab..8445ccd 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,49 +1,28 @@ import { db } from "../db"; -import { clients, categories, tags as tagsTable } from "../db/schema"; +import { clients, tags as tagsTable } from "../db/schema"; import { eq, and, inArray } from "drizzle-orm"; export async function validateTimeEntryResources({ organizationId, clientId, - categoryId, tagIds, }: { organizationId: string; clientId: string; - categoryId: string; tagIds?: string[]; }) { - const [client, category] = await Promise.all([ - db - .select() - .from(clients) - .where( - and( - eq(clients.id, clientId), - eq(clients.organizationId, organizationId), - ), - ) - .get(), - db - .select() - .from(categories) - .where( - and( - eq(categories.id, categoryId), - eq(categories.organizationId, organizationId), - ), - ) - .get(), - ]); + const client = await db + .select() + .from(clients) + .where( + and(eq(clients.id, clientId), eq(clients.organizationId, organizationId)), + ) + .get(); if (!client) { return { valid: false, error: "Invalid client" }; } - if (!category) { - return { valid: false, error: "Invalid category" }; - } - if (tagIds && tagIds.length > 0) { const validTags = await db .select() diff --git a/src/pages/api/categories/[id]/delete.ts b/src/pages/api/categories/[id]/delete.ts deleted file mode 100644 index 17038c6..0000000 --- a/src/pages/api/categories/[id]/delete.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { APIRoute } from "astro"; -import { db } from "../../../../db"; -import { categories, members, timeEntries } from "../../../../db/schema"; -import { eq, and } from "drizzle-orm"; - -export const POST: APIRoute = async ({ request, locals, redirect, params }) => { - const user = locals.user; - if (!user) { - return new Response("Unauthorized", { status: 401 }); - } - - const { id } = params; - let redirectTo: string | undefined; - - if (request.headers.get("Content-Type")?.includes("application/json")) { - const body = await request.json(); - redirectTo = body.redirectTo; - } else { - const formData = await request.formData(); - redirectTo = formData.get("redirectTo")?.toString(); - } - - const userOrg = await db - .select() - .from(members) - .where(eq(members.userId, user.id)) - .get(); - - if (!userOrg) { - return new Response("No organization found", { status: 400 }); - } - - const isAdmin = userOrg.role === "owner" || userOrg.role === "admin"; - if (!isAdmin) { - return new Response("Forbidden", { status: 403 }); - } - - const hasEntries = await db - .select() - .from(timeEntries) - .where(eq(timeEntries.categoryId, id!)) - .get(); - - if (hasEntries) { - return new Response("Cannot delete category with time entries", { - status: 400, - }); - } - - await db - .delete(categories) - .where( - and( - eq(categories.id, id!), - eq(categories.organizationId, userOrg.organizationId), - ), - ); - - if (locals.scopes) { - return new Response(JSON.stringify({ success: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - - return redirect(redirectTo || "/dashboard/team/settings"); -}; diff --git a/src/pages/api/categories/[id]/update.ts b/src/pages/api/categories/[id]/update.ts deleted file mode 100644 index 9e52bd8..0000000 --- a/src/pages/api/categories/[id]/update.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { APIRoute } from "astro"; -import { db } from "../../../../db"; -import { categories, members } from "../../../../db/schema"; -import { eq, and } from "drizzle-orm"; - -export const POST: APIRoute = async ({ request, locals, redirect, params }) => { - const user = locals.user; - if (!user) { - return new Response("Unauthorized", { status: 401 }); - } - - const { id } = params; - let name: string | undefined; - let color: string | undefined; - let redirectTo: string | undefined; - - if (request.headers.get("Content-Type")?.includes("application/json")) { - const body = await request.json(); - name = body.name; - color = body.color; - redirectTo = body.redirectTo; - } else { - const formData = await request.formData(); - name = formData.get("name")?.toString(); - color = formData.get("color")?.toString(); - redirectTo = formData.get("redirectTo")?.toString(); - } - - if (!name) { - return new Response("Name is required", { status: 400 }); - } - - const userOrg = await db - .select() - .from(members) - .where(eq(members.userId, user.id)) - .get(); - - if (!userOrg) { - return new Response("No organization found", { status: 400 }); - } - - const isAdmin = userOrg.role === "owner" || userOrg.role === "admin"; - if (!isAdmin) { - return new Response("Forbidden", { status: 403 }); - } - - await db - .update(categories) - .set({ - name, - color: color || null, - }) - .where( - and( - eq(categories.id, id!), - eq(categories.organizationId, userOrg.organizationId), - ), - ); - - if (locals.scopes) { - return new Response( - JSON.stringify({ success: true, id, name, color: color || null }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - } - - return redirect(redirectTo || "/dashboard/team/settings"); -}; diff --git a/src/pages/api/categories/create.ts b/src/pages/api/categories/create.ts deleted file mode 100644 index 747c91a..0000000 --- a/src/pages/api/categories/create.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { APIRoute } from "astro"; -import { db } from "../../../db"; -import { categories, members } from "../../../db/schema"; -import { eq } from "drizzle-orm"; -import { nanoid } from "nanoid"; - -export const POST: APIRoute = async ({ request, locals, redirect }) => { - const user = locals.user; - if (!user) { - return new Response("Unauthorized", { status: 401 }); - } - - let name: string | undefined; - let color: string | undefined; - let redirectTo: string | undefined; - - if (request.headers.get("Content-Type")?.includes("application/json")) { - const body = await request.json(); - name = body.name; - color = body.color; - redirectTo = body.redirectTo; - } else { - const formData = await request.formData(); - name = formData.get("name")?.toString(); - color = formData.get("color")?.toString(); - redirectTo = formData.get("redirectTo")?.toString(); - } - - if (!name) { - return new Response("Name is required", { status: 400 }); - } - - const userOrg = await db - .select() - .from(members) - .where(eq(members.userId, user.id)) - .get(); - - if (!userOrg) { - return new Response("No organization found", { status: 400 }); - } - - const id = nanoid(); - await db.insert(categories).values({ - id, - organizationId: userOrg.organizationId, - name, - color: color || null, - }); - - if (locals.scopes) { - return new Response(JSON.stringify({ id, name, color: color || null }), { - status: 201, - headers: { "Content-Type": "application/json" }, - }); - } - - return redirect(redirectTo || "/dashboard/team/settings"); -}; diff --git a/src/pages/api/invoices/[id]/generate.ts b/src/pages/api/invoices/[id]/generate.ts index 09b9953..e967c6e 100644 --- a/src/pages/api/invoices/[id]/generate.ts +++ b/src/pages/api/invoices/[id]/generate.ts @@ -1,102 +1,100 @@ import type { APIRoute } from "astro"; -import { db } from "../../../../db"; -import { - invoices, - invoiceItems, - clients, - organizations, - members, -} from "../../../../db/schema"; -import { eq, and } from "drizzle-orm"; import { renderToStream } from "@ceereals/vue-pdf"; +import { db } from "../../../../db"; +import { invoices, invoiceItems, clients, organizations, members } from "../../../../db/schema"; +import { eq, and } from "drizzle-orm"; import { createInvoiceDocument } from "../../../../pdf/generateInvoicePDF"; export const GET: APIRoute = async ({ params, locals }) => { + const user = locals.user; + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + const { id } = params; + if (!id) { + return new Response("Invoice ID is required", { status: 400 }); + } + + // Fetch invoice with related data + const invoiceResult = await db.select({ + invoice: invoices, + client: clients, + organization: organizations, + }) + .from(invoices) + .leftJoin(clients, eq(invoices.clientId, clients.id)) + .innerJoin(organizations, eq(invoices.organizationId, organizations.id)) + .where(eq(invoices.id, id)) + .get(); + + if (!invoiceResult) { + return new Response("Invoice not found", { status: 404 }); + } + + const { invoice, client, organization } = invoiceResult; + + // Verify membership + const membership = await db.select() + .from(members) + .where(and( + eq(members.userId, user.id), + eq(members.organizationId, invoice.organizationId) + )) + .get(); + + if (!membership) { + return new Response("Not authorized", { status: 403 }); + } + + // Fetch items + const items = await db.select() + .from(invoiceItems) + .where(eq(invoiceItems.invoiceId, invoice.id)) + .all(); + try { - const { id } = params; - const user = locals.user; + const document = createInvoiceDocument({ + invoice: { + ...invoice, + notes: invoice.notes || null, + // Ensure null safety for optional fields that might be undefined in some runtimes depending on driver + discountValue: invoice.discountValue ?? null, + discountType: invoice.discountType ?? null, + discountAmount: invoice.discountAmount ?? null, + taxRate: invoice.taxRate ?? null, + }, + items, + client: { + name: client?.name || "Deleted Client", + email: client?.email || null, + street: client?.street || null, + city: client?.city || null, + state: client?.state || null, + zip: client?.zip || null, + country: client?.country || null, + }, + organization: { + name: organization.name, + street: organization.street || null, + city: organization.city || null, + state: organization.state || null, + zip: organization.zip || null, + country: organization.country || null, + logoUrl: organization.logoUrl || null, + } + }); - if (!user || !id) { - return new Response("Unauthorized", { status: 401 }); - } + const stream = await renderToStream(document); - const invoiceResult = await db - .select({ - invoice: invoices, - client: clients, - organization: organizations, - }) - .from(invoices) - .leftJoin(clients, eq(invoices.clientId, clients.id)) - .innerJoin(organizations, eq(invoices.organizationId, organizations.id)) - .where(eq(invoices.id, id)) - .get(); - - if (!invoiceResult) { - return new Response("Invoice not found", { status: 404 }); - } - - const { invoice, client, organization } = invoiceResult; - - const membership = await db - .select() - .from(members) - .where( - and( - eq(members.userId, user.id), - eq(members.organizationId, invoice.organizationId), - ), - ) - .get(); - - if (!membership) { - return new Response("Forbidden", { status: 403 }); - } - - const items = await db - .select() - .from(invoiceItems) - .where(eq(invoiceItems.invoiceId, invoice.id)) - .all(); - - if (!client) { - return new Response("Client not found", { status: 404 }); - } - - const originalConsoleLog = console.log; - const originalConsoleWarn = console.warn; - console.log = () => {}; - console.warn = () => {}; - - try { - const pdfDocument = createInvoiceDocument({ - invoice, - items, - client, - organization, - }); - - const stream = await renderToStream(pdfDocument); - - console.log = originalConsoleLog; - console.warn = originalConsoleWarn; - - const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`; - - return new Response(stream as any, { - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="${filename}"`, - }, - }); - } catch (pdfError) { - // Restore console.log on error - console.log = originalConsoleLog; - console.warn = originalConsoleWarn; - throw pdfError; - } + return new Response(stream, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${invoice.number}.pdf"`, + }, + }); } catch (error) { console.error("Error generating PDF:", error); - return new Response("Error generating PDF", { status: 500 }); + return new Response("Failed to generate PDF", { status: 500 }); } }; diff --git a/src/pages/api/reports/export.ts b/src/pages/api/reports/export.ts index 260e155..e9dd50a 100644 --- a/src/pages/api/reports/export.ts +++ b/src/pages/api/reports/export.ts @@ -1,72 +1,96 @@ -import type { APIRoute } from 'astro'; -import { db } from '../../../db'; -import { timeEntries, members, users, clients, categories } from '../../../db/schema'; -import { eq, and, gte, lte, desc } from 'drizzle-orm'; +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { + timeEntries, + members, + users, + clients, + tags, + timeEntryTags, +} from "../../../db/schema"; +import { eq, and, gte, lte, desc, inArray } from "drizzle-orm"; export const GET: APIRoute = async ({ request, locals, cookies }) => { const user = locals.user; if (!user) { - return new Response('Unauthorized', { status: 401 }); + return new Response("Unauthorized", { status: 401 }); } // Get current team from cookie - const currentTeamId = cookies.get('currentTeamId')?.value; + const currentTeamId = cookies.get("currentTeamId")?.value; - const userMemberships = await db.select() + const userMemberships = await db + .select() .from(members) .where(eq(members.userId, user.id)) .all(); if (userMemberships.length === 0) { - return new Response('No organization found', { status: 404 }); + return new Response("No organization found", { status: 404 }); } // Use current team or fallback to first membership const userMembership = currentTeamId - ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] + ? userMemberships.find((m) => m.organizationId === currentTeamId) || + userMemberships[0] : userMemberships[0]; const url = new URL(request.url); - const selectedMemberId = url.searchParams.get('member') || ''; - const selectedCategoryId = url.searchParams.get('category') || ''; - const selectedClientId = url.searchParams.get('client') || ''; - const timeRange = url.searchParams.get('range') || 'week'; - const customFrom = url.searchParams.get('from'); - const customTo = url.searchParams.get('to'); + const selectedMemberId = url.searchParams.get("member") || ""; + const selectedClientId = url.searchParams.get("client") || ""; + const timeRange = url.searchParams.get("range") || "week"; + const customFrom = url.searchParams.get("from"); + const customTo = url.searchParams.get("to"); const now = new Date(); let startDate = new Date(); let endDate = new Date(); switch (timeRange) { - case 'today': + case "today": startDate.setHours(0, 0, 0, 0); endDate.setHours(23, 59, 59, 999); break; - case 'week': + case "week": startDate.setDate(now.getDate() - 7); break; - case 'month': + case "month": startDate.setMonth(now.getMonth() - 1); break; - case 'mtd': + case "mtd": startDate = new Date(now.getFullYear(), now.getMonth(), 1); break; - case 'ytd': + case "ytd": startDate = new Date(now.getFullYear(), 0, 1); break; - case 'last-month': + case "last-month": startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); break; - case 'custom': + case "custom": if (customFrom) { - const parts = customFrom.split('-'); - startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0); + const parts = customFrom.split("-"); + startDate = new Date( + parseInt(parts[0]), + parseInt(parts[1]) - 1, + parseInt(parts[2]), + 0, + 0, + 0, + 0, + ); } if (customTo) { - const parts = customTo.split('-'); - endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999); + const parts = customTo.split("-"); + endDate = new Date( + parseInt(parts[0]), + parseInt(parts[1]) - 1, + parseInt(parts[2]), + 23, + 59, + 59, + 999, + ); } break; } @@ -81,31 +105,58 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => { conditions.push(eq(timeEntries.userId, selectedMemberId)); } - if (selectedCategoryId) { - conditions.push(eq(timeEntries.categoryId, selectedCategoryId)); - } - if (selectedClientId) { conditions.push(eq(timeEntries.clientId, selectedClientId)); } - const entries = await db.select({ - entry: timeEntries, - user: users, - client: clients, - category: categories, - }) + const entries = await db + .select({ + entry: timeEntries, + user: users, + client: clients, + }) .from(timeEntries) .innerJoin(users, eq(timeEntries.userId, users.id)) .innerJoin(clients, eq(timeEntries.clientId, clients.id)) - .innerJoin(categories, eq(timeEntries.categoryId, categories.id)) .where(and(...conditions)) .orderBy(desc(timeEntries.startTime)) .all(); + // Fetch tags for these entries + const entryIds = entries.map((e) => e.entry.id); + const tagsMap = new Map(); + + if (entryIds.length > 0) { + const entryTags = await db + .select({ + entryId: timeEntryTags.timeEntryId, + tagName: tags.name, + }) + .from(timeEntryTags) + .innerJoin(tags, eq(timeEntryTags.tagId, tags.id)) + .where(inArray(timeEntryTags.timeEntryId, entryIds)) + .all(); + + for (const tag of entryTags) { + if (!tagsMap.has(tag.entryId)) { + tagsMap.set(tag.entryId, []); + } + tagsMap.get(tag.entryId)!.push(tag.tagName); + } + } + // Generate CSV - const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description']; - const rows = entries.map(e => { + const headers = [ + "Date", + "Start Time", + "End Time", + "Duration (h)", + "Member", + "Client", + "Tags", + "Description", + ]; + const rows = entries.map((e) => { const start = e.entry.startTime; const end = e.entry.endTime; @@ -114,24 +165,26 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => { duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours } + const tagsStr = tagsMap.get(e.entry.id)?.join("; ") || ""; + return [ start.toLocaleDateString(), start.toLocaleTimeString(), - end ? end.toLocaleTimeString() : '', - end ? duration.toFixed(2) : 'Running', - `"${(e.user.name || '').replace(/"/g, '""')}"`, - `"${(e.client.name || '').replace(/"/g, '""')}"`, - `"${(e.category.name || '').replace(/"/g, '""')}"`, - `"${(e.entry.description || '').replace(/"/g, '""')}"` - ].join(','); + end ? end.toLocaleTimeString() : "", + end ? duration.toFixed(2) : "Running", + `"${(e.user.name || "").replace(/"/g, '""')}"`, + `"${(e.client.name || "").replace(/"/g, '""')}"`, + `"${tagsStr.replace(/"/g, '""')}"`, + `"${(e.entry.description || "").replace(/"/g, '""')}"`, + ].join(","); }); - const csvContent = [headers.join(','), ...rows].join('\n'); + const csvContent = [headers.join(","), ...rows].join("\n"); return new Response(csvContent, { headers: { - 'Content-Type': 'text/csv', - 'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.csv"`, + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="time-entries-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}.csv"`, }, }); }; diff --git a/src/pages/api/time-entries/manual.ts b/src/pages/api/time-entries/manual.ts index 6883b32..c74cdd7 100644 --- a/src/pages/api/time-entries/manual.ts +++ b/src/pages/api/time-entries/manual.ts @@ -17,7 +17,7 @@ export const POST: APIRoute = async ({ request, locals }) => { } const body = await request.json(); - const { description, clientId, categoryId, startTime, endTime, tags } = body; + const { description, clientId, startTime, endTime, tags } = body; // Validation if (!clientId) { @@ -27,13 +27,6 @@ export const POST: APIRoute = async ({ request, locals }) => { }); } - if (!categoryId) { - return new Response(JSON.stringify({ error: "Category is required" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - if (!startTime) { return new Response(JSON.stringify({ error: "Start time is required" }), { status: 400, @@ -81,7 +74,6 @@ export const POST: APIRoute = async ({ request, locals }) => { const resourceValidation = await validateTimeEntryResources({ organizationId: member.organizationId, clientId, - categoryId, tagIds: Array.isArray(tags) ? tags : undefined, }); @@ -101,7 +93,6 @@ export const POST: APIRoute = async ({ request, locals }) => { userId: locals.user.id, organizationId: member.organizationId, clientId, - categoryId, startTime: startDate, endTime: endDate, description: description || null, diff --git a/src/pages/api/time-entries/start.ts b/src/pages/api/time-entries/start.ts index f59aa44..6a4320d 100644 --- a/src/pages/api/time-entries/start.ts +++ b/src/pages/api/time-entries/start.ts @@ -11,17 +11,12 @@ export const POST: APIRoute = async ({ request, locals }) => { const body = await request.json(); const description = body.description || ""; const clientId = body.clientId; - const categoryId = body.categoryId; const tags = body.tags || []; if (!clientId) { return new Response("Client is required", { status: 400 }); } - if (!categoryId) { - return new Response("Category is required", { status: 400 }); - } - const runningEntry = await db .select() .from(timeEntries) @@ -47,7 +42,6 @@ export const POST: APIRoute = async ({ request, locals }) => { const validation = await validateTimeEntryResources({ organizationId: member.organizationId, clientId, - categoryId, tagIds: tags, }); @@ -63,7 +57,6 @@ export const POST: APIRoute = async ({ request, locals }) => { userId: locals.user.id, organizationId: member.organizationId, clientId, - categoryId, startTime, description, isManual: false, diff --git a/src/pages/dashboard/categories.astro b/src/pages/dashboard/categories.astro deleted file mode 100644 index 5e35708..0000000 --- a/src/pages/dashboard/categories.astro +++ /dev/null @@ -1,62 +0,0 @@ ---- -import DashboardLayout from '../../layouts/DashboardLayout.astro'; -import { db } from '../../db'; -import { categories, members } from '../../db/schema'; -import { eq } from 'drizzle-orm'; - -const user = Astro.locals.user; -if (!user) return Astro.redirect('/login'); - -// Get current team from cookie -const currentTeamId = Astro.cookies.get('currentTeamId')?.value; - -const userMemberships = await db.select() - .from(members) - .where(eq(members.userId, user.id)) - .all(); - -if (userMemberships.length === 0) return Astro.redirect('/dashboard'); - -// Use current team or fallback to first membership -const userMembership = currentTeamId - ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] - : userMemberships[0]; - -const allCategories = await db.select() - .from(categories) - .where(eq(categories.organizationId, userMembership.organizationId)) - .all(); ---- - - -
-

Categories

- Add Category -
- -
- {allCategories.map(category => ( -
-
-

- {category.color && ( - - )} - {category.name} -

-

Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}

-
- Edit -
-
-
- ))} -
- - {allCategories.length === 0 && ( -
-

No categories yet

- Add Your First Category -
- )} -
diff --git a/src/pages/dashboard/categories/[id]/edit.astro b/src/pages/dashboard/categories/[id]/edit.astro deleted file mode 100644 index 37ea17f..0000000 --- a/src/pages/dashboard/categories/[id]/edit.astro +++ /dev/null @@ -1,99 +0,0 @@ ---- -import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; -import { Icon } from 'astro-icon/components'; -import { db } from '../../../../db'; -import { categories, members } from '../../../../db/schema'; -import { eq, and } from 'drizzle-orm'; - -const user = Astro.locals.user; -if (!user) return Astro.redirect('/login'); - -const { id } = Astro.params; - -// Get current team from cookie -const currentTeamId = Astro.cookies.get('currentTeamId')?.value; - -const userMemberships = await db.select() - .from(members) - .where(eq(members.userId, user.id)) - .all(); - -if (userMemberships.length === 0) return Astro.redirect('/dashboard'); - -// Use current team or fallback to first membership -const userMembership = currentTeamId - ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] - : userMemberships[0]; - -const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; -if (!isAdmin) return Astro.redirect('/dashboard/categories'); - -const category = await db.select() - .from(categories) - .where(and( - eq(categories.id, id!), - eq(categories.organizationId, userMembership.organizationId) - )) - .get(); - -if (!category) return Astro.redirect('/dashboard/categories'); ---- - - -
-
- - - -

Edit Category

-
- -
-
-
- - -
- - -
- -
- - -
-
- -
-
- - -
- -
- Cancel - -
-
-
-
-
-
diff --git a/src/pages/dashboard/categories/new.astro b/src/pages/dashboard/categories/new.astro deleted file mode 100644 index 9082b99..0000000 --- a/src/pages/dashboard/categories/new.astro +++ /dev/null @@ -1,54 +0,0 @@ ---- -import DashboardLayout from '../../../layouts/DashboardLayout.astro'; -import { Icon } from 'astro-icon/components'; - -const user = Astro.locals.user; -if (!user) return Astro.redirect('/login'); ---- - - -
-
- - - -

Add New Category

-
- -
- -
-
- - -
- -
- - -
- -
- Cancel - -
-
-
-
-
diff --git a/src/pages/dashboard/team/settings.astro b/src/pages/dashboard/team/settings.astro index 0eeca03..1e7d533 100644 --- a/src/pages/dashboard/team/settings.astro +++ b/src/pages/dashboard/team/settings.astro @@ -2,7 +2,7 @@ import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import { Icon } from 'astro-icon/components'; import { db } from '../../../db'; -import { categories, members, organizations, tags } from '../../../db/schema'; +import { members, organizations, tags } from '../../../db/schema'; import { eq } from 'drizzle-orm'; const user = Astro.locals.user; @@ -35,10 +35,7 @@ const organization = await db.select() if (!organization) return Astro.redirect('/dashboard'); -const allCategories = await db.select() - .from(categories) - .where(eq(categories.organizationId, orgId)) - .all(); + const allTags = await db.select() .from(tags) @@ -415,60 +412,6 @@ const successType = url.searchParams.get('success'); - -
-
-
-

- - Work Categories -

- - - Add Category - -
-

- Categories help organize time tracking by type of work. All team members use the same categories. -

- - {allCategories.length === 0 ? ( -
- -
-
No categories yet
-
Create your first category to start organizing time entries.
-
-
- ) : ( -
- {allCategories.map(category => ( -
-
-
- {category.color && ( - - )} -
-

{category.name}

-

- Created {category.createdAt?.toLocaleDateString() ?? 'N/A'} -

-
- - - -
-
-
- ))} -
- )} -
-
diff --git a/src/pages/dashboard/team/settings/categories/[id]/edit.astro b/src/pages/dashboard/team/settings/categories/[id]/edit.astro deleted file mode 100644 index 9fb4112..0000000 --- a/src/pages/dashboard/team/settings/categories/[id]/edit.astro +++ /dev/null @@ -1,93 +0,0 @@ ---- -import DashboardLayout from '../../../../../../layouts/DashboardLayout.astro'; -import { Icon } from 'astro-icon/components'; -import { db } from '../../../../../../db'; -import { categories, members } from '../../../../../../db/schema'; -import { eq, and } from 'drizzle-orm'; - -const user = Astro.locals.user; -if (!user) return Astro.redirect('/login'); - -const { id } = Astro.params; - -// Get current team from cookie -const currentTeamId = Astro.cookies.get('currentTeamId')?.value; - -const userMemberships = await db.select() - .from(members) - .where(eq(members.userId, user.id)) - .all(); - -if (userMemberships.length === 0) return Astro.redirect('/dashboard'); - -// Use current team or fallback to first membership -const userMembership = currentTeamId - ? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0] - : userMemberships[0]; - -const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; -if (!isAdmin) return Astro.redirect('/dashboard/team/settings'); - -const category = await db.select() - .from(categories) - .where(and( - eq(categories.id, id!), - eq(categories.organizationId, userMembership.organizationId) - )) - .get(); - -if (!category) return Astro.redirect('/dashboard/team/settings'); ---- - - -
-
- - - -

Edit Category

-
- -
-
-
- - -
- -
- - -
- -
- - - -
- Cancel - -
-
-
- -
-
diff --git a/src/pages/dashboard/team/settings/categories/new.astro b/src/pages/dashboard/team/settings/categories/new.astro deleted file mode 100644 index 4777d44..0000000 --- a/src/pages/dashboard/team/settings/categories/new.astro +++ /dev/null @@ -1,53 +0,0 @@ ---- -import DashboardLayout from '../../../../../layouts/DashboardLayout.astro'; -import { Icon } from 'astro-icon/components'; - -const user = Astro.locals.user; -if (!user) return Astro.redirect('/login'); ---- - - -
-
- - - -

Add New Category

-
- -
-
-
- - -
- -
- - -
- -
- Cancel - -
-
-
-
-
diff --git a/src/pages/dashboard/tracker.astro b/src/pages/dashboard/tracker.astro index 5163dfb..0e29e0e 100644 --- a/src/pages/dashboard/tracker.astro +++ b/src/pages/dashboard/tracker.astro @@ -4,7 +4,7 @@ import { Icon } from 'astro-icon/components'; import Timer from '../../components/Timer.vue'; import ManualEntry from '../../components/ManualEntry.vue'; import { db } from '../../db'; -import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema'; +import { timeEntries, clients, members, tags, timeEntryTags, users } from '../../db/schema'; import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm'; import { formatTimeRange } from '../../lib/formatTime'; @@ -33,11 +33,6 @@ const allClients = await db.select() .where(eq(clients.organizationId, organizationId)) .all(); -const allCategories = await db.select() - .from(categories) - .where(eq(categories.organizationId, organizationId)) - .all(); - const allTags = await db.select() .from(tags) .where(eq(tags.organizationId, organizationId)) @@ -50,7 +45,7 @@ const pageSize = 20; const offset = (page - 1) * pageSize; const filterClient = url.searchParams.get('client') || ''; -const filterCategory = url.searchParams.get('category') || ''; + const filterStatus = url.searchParams.get('status') || ''; const filterType = url.searchParams.get('type') || ''; const sortBy = url.searchParams.get('sort') || 'start-desc'; @@ -62,10 +57,6 @@ if (filterClient) { conditions.push(eq(timeEntries.clientId, filterClient)); } -if (filterCategory) { - conditions.push(eq(timeEntries.categoryId, filterCategory)); -} - if (filterStatus === 'completed') { conditions.push(sql`${timeEntries.endTime} IS NOT NULL`); } else if (filterStatus === 'running') { @@ -107,12 +98,10 @@ switch (sortBy) { const entries = await db.select({ entry: timeEntries, client: clients, - category: categories, user: users, }) .from(timeEntries) .leftJoin(clients, eq(timeEntries.clientId, clients.id)) - .leftJoin(categories, eq(timeEntries.categoryId, categories.id)) .leftJoin(users, eq(timeEntries.userId, users.id)) .where(and(...conditions)) .orderBy(orderBy) @@ -169,12 +158,6 @@ const paginationPages = getPaginationPages(page, totalPages); You need to create a client before tracking time. Add Client
- ) : allCategories.length === 0 ? ( -
- - You need to create a category before tracking time. - Team Settings -
) : ( ({ id: c.id, name: c.name }))} - categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))} tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))} /> )} @@ -199,17 +180,10 @@ const paginationPages = getPaginationPages(page, totalPages); You need to create a client before adding time entries. Add Client
- ) : allCategories.length === 0 ? ( -
- - You need to create a category before adding time entries. - Team Settings -
) : ( ({ id: c.id, name: c.name }))} - categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))} tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))} /> )} @@ -252,19 +226,7 @@ const paginationPages = getPaginationPages(page, totalPages);
-
- - -
+