diff --git a/.env.example b/.env.example index 05b2eb5..121ec05 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ -HOST=0.0.0.0 -PORT=4321 -DATABASE_URL=chronus.db +# Docker Configuration +IMAGE=ghcr.io/atridad/chronus:latest +APP_PORT=4321 +ROOT_DIR=./data diff --git a/.gitignore b/.gitignore index 777a88e..040cbcd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # build output dist/ +data/ # generated types .astro/ diff --git a/docker-compose.yml b/docker-compose.yml index 2d8160c..fb67b44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - NODE_ENV=production - HOST=0.0.0.0 - PORT=4321 - - DATABASE_URL=/app/data/chronus.db + - ROOT_DIR=/app/data volumes: - ${ROOT_DIR}:/app/data restart: unless-stopped diff --git a/drizzle.config.ts b/drizzle.config.ts index 8e514a6..c4290db 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,24 @@ import { defineConfig } from "drizzle-kit"; +import fs from "fs"; +import path from "path"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const rootDir = process.env.ROOT_DIR || process.cwd(); + +if (process.env.ROOT_DIR && !fs.existsSync(rootDir)) { + fs.mkdirSync(rootDir, { recursive: true }); +} + +const dbUrl = `file:${path.join(rootDir, "chronus.db")}`; export default defineConfig({ schema: "./src/db/schema.ts", out: "./drizzle", dialect: "turso", dbCredentials: { - url: process.env.DATABASE_URL || "file:chronus.db", + url: dbUrl, authToken: process.env.DATABASE_AUTH_TOKEN, }, }); diff --git a/drizzle/0000_powerful_texas_twister.sql b/drizzle/0000_motionless_king_cobra.sql similarity index 99% rename from drizzle/0000_powerful_texas_twister.sql rename to drizzle/0000_motionless_king_cobra.sql index 9c6a1d9..d0add5a 100644 --- a/drizzle/0000_powerful_texas_twister.sql +++ b/drizzle/0000_motionless_king_cobra.sql @@ -71,6 +71,7 @@ CREATE TABLE `members` ( CREATE TABLE `organizations` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, + `logo_url` text, `street` text, `city` text, `state` text, diff --git a/drizzle/0001_lazy_roughhouse.sql b/drizzle/0001_lazy_roughhouse.sql new file mode 100644 index 0000000..4f8b860 --- /dev/null +++ b/drizzle/0001_lazy_roughhouse.sql @@ -0,0 +1,6 @@ +ALTER TABLE `clients` ADD `phone` text;--> statement-breakpoint +ALTER TABLE `clients` ADD `street` text;--> statement-breakpoint +ALTER TABLE `clients` ADD `city` text;--> statement-breakpoint +ALTER TABLE `clients` ADD `state` text;--> statement-breakpoint +ALTER TABLE `clients` ADD `zip` text;--> statement-breakpoint +ALTER TABLE `clients` ADD `country` text; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 6f364e0..f625b51 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "baea49b9-0dd5-4e46-9345-40acabf238c3", + "id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "api_tokens": { @@ -513,6 +513,13 @@ "notNull": true, "autoincrement": false }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "street": { "name": "street", "type": "text", diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..13d1b24 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1029 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5483c77e-e742-4fbd-8494-d6f9c6c9e28a", + "prevId": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be", + "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 + } + }, + "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": {} + }, + "categories": { + "name": "categories", + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "categories_organization_id_organizations_id_fk": { + "name": "categories_organization_id_organizations_id_fk", + "tableFrom": "categories", + "tableTo": "organizations", + "columnsFrom": [ + "organization_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": {}, + "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": {}, + "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 + }, + "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": {}, + "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": {}, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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": {}, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "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 + }, + "category_id": { + "name": "category_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 + }, + "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": {}, + "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" + }, + "time_entries_category_id_categories_id_fk": { + "name": "time_entries_category_id_categories_id_fk", + "tableFrom": "time_entries", + "tableTo": "categories", + "columnsFrom": [ + "category_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": {}, + "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 5d171ce..ce841fd 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,15 @@ { "idx": 0, "version": "6", - "when": 1768672531260, - "tag": "0000_powerful_texas_twister", + "when": 1768688193284, + "tag": "0000_motionless_king_cobra", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1768690333269, + "tag": "0001_lazy_roughhouse", "breakpoints": true } ] diff --git a/package.json b/package.json index be62109..6c0b7a3 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@catppuccin/daisyui": "^2.1.1", "@iconify-json/heroicons": "^1.2.3", "@react-pdf/types": "^2.9.2", + "dotenv": "^17.2.3", "drizzle-kit": "0.31.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04272c5..505b13d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@react-pdf/types': specifier: ^2.9.2 version: 2.9.2 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 drizzle-kit: specifier: 0.31.8 version: 0.31.8 @@ -1722,6 +1725,10 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + drizzle-kit@0.31.8: resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} hasBin: true @@ -5379,6 +5386,8 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dotenv@17.2.3: {} + drizzle-kit@0.31.8: dependencies: '@drizzle-team/brocli': 0.10.2 diff --git a/scripts/migrate.js b/scripts/migrate.js index 113c62e..832e8e8 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -2,23 +2,20 @@ import { drizzle } from "drizzle-orm/libsql"; import { migrate } from "drizzle-orm/libsql/migrator"; import { createClient } from "@libsql/client"; import path from "path"; +import fs from "fs"; async function runMigrate() { console.log("Running migrations..."); - let url = process.env.DATABASE_URL; - if (!url) { - url = `file:${path.resolve(process.cwd(), "chronus.db")}`; - console.log(`No DATABASE_URL found, using default: ${url}`); - } else if ( - !url.startsWith("file:") && - !url.startsWith("libsql:") && - !url.startsWith("http:") && - !url.startsWith("https:") - ) { - url = `file:${url}`; + const rootDir = process.env.ROOT_DIR || process.cwd(); + + if (process.env.ROOT_DIR && !fs.existsSync(rootDir)) { + fs.mkdirSync(rootDir, { recursive: true }); } + const url = `file:${path.join(rootDir, "chronus.db")}`; + console.log(`Using database: ${url}`); + const authToken = process.env.DATABASE_AUTH_TOKEN; const client = createClient({ diff --git a/src/db/index.ts b/src/db/index.ts index a4977bc..aaf5657 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -2,6 +2,7 @@ import { drizzle } from "drizzle-orm/libsql"; import { createClient } from "@libsql/client"; import * as schema from "./schema"; import path from "path"; +import fs from "fs"; // Define the database type based on the schema type Database = ReturnType>; @@ -10,17 +11,16 @@ let _db: Database | null = null; function initDb(): Database { if (!_db) { - let url = process.env.DATABASE_URL; - if (!url) { - url = `file:${path.resolve(process.cwd(), "chronus.db")}`; - } else if ( - !url.startsWith("file:") && - !url.startsWith("libsql:") && - !url.startsWith("http:") && - !url.startsWith("https:") - ) { - url = `file:${url}`; + const envRootDir = process.env.ROOT_DIR + ? process.env.ROOT_DIR + : import.meta.env.ROOT_DIR; + const rootDir = envRootDir || process.cwd(); + + if (envRootDir && !fs.existsSync(rootDir)) { + fs.mkdirSync(rootDir, { recursive: true }); } + + const url = `file:${path.join(rootDir, "chronus.db")}`; const authToken = process.env.DATABASE_AUTH_TOKEN; const client = createClient({ diff --git a/src/db/schema.ts b/src/db/schema.ts index de34ddb..967133c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -26,6 +26,7 @@ export const organizations = sqliteTable("organizations", { .primaryKey() .$defaultFn(() => nanoid()), name: text("name").notNull(), + logoUrl: text("logo_url"), street: text("street"), city: text("city"), state: text("state"), @@ -68,6 +69,12 @@ export const clients = sqliteTable( organizationId: text("organization_id").notNull(), name: text("name").notNull(), email: text("email"), + phone: text("phone"), + street: text("street"), + city: text("city"), + state: text("state"), + zip: text("zip"), + country: text("country"), createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( () => new Date(), ), diff --git a/src/layouts/DashboardLayout.astro b/src/layouts/DashboardLayout.astro index 45d65ec..d578fae 100644 --- a/src/layouts/DashboardLayout.astro +++ b/src/layouts/DashboardLayout.astro @@ -181,8 +181,8 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
  • -
    - diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts index 29f01cc..6eeb0cc 100644 --- a/src/pages/api/auth/login.ts +++ b/src/pages/api/auth/login.ts @@ -1,33 +1,37 @@ -import type { APIRoute } from 'astro'; -import { db } from '../../../db'; -import { users } from '../../../db/schema'; -import { verifyPassword, createSession } from '../../../lib/auth'; -import { eq } from 'drizzle-orm'; +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { users } from "../../../db/schema"; +import { verifyPassword, createSession } from "../../../lib/auth"; +import { eq } from "drizzle-orm"; export const POST: APIRoute = async ({ request, cookies, redirect }) => { const formData = await request.formData(); - const email = formData.get('email')?.toString(); - const password = formData.get('password')?.toString(); + const email = formData.get("email")?.toString(); + const password = formData.get("password")?.toString(); if (!email || !password) { - return new Response('Missing fields', { status: 400 }); + return redirect("/login?error=missing_fields"); } - const user = await db.select().from(users).where(eq(users.email, email)).get(); + const user = await db + .select() + .from(users) + .where(eq(users.email, email)) + .get(); if (!user || !(await verifyPassword(password, user.passwordHash))) { - return new Response('Invalid email or password', { status: 400 }); + return redirect("/login?error=invalid_credentials"); } const { sessionId, expiresAt } = await createSession(user.id); - cookies.set('session_id', sessionId, { - path: '/', + cookies.set("session_id", sessionId, { + path: "/", httpOnly: true, secure: import.meta.env.PROD, - sameSite: 'lax', + sameSite: "lax", expires: expiresAt, }); - return redirect('/dashboard'); + return redirect("/dashboard"); }; diff --git a/src/pages/api/auth/signup.ts b/src/pages/api/auth/signup.ts index 12ce58a..cde5cef 100644 --- a/src/pages/api/auth/signup.ts +++ b/src/pages/api/auth/signup.ts @@ -1,39 +1,49 @@ -import type { APIRoute } from 'astro'; -import { db } from '../../../db'; -import { users, organizations, members, siteSettings } from '../../../db/schema'; -import { hashPassword, createSession } from '../../../lib/auth'; -import { eq, count, sql } from 'drizzle-orm'; -import { nanoid } from 'nanoid'; +import type { APIRoute } from "astro"; +import { db } from "../../../db"; +import { + users, + organizations, + members, + siteSettings, +} from "../../../db/schema"; +import { hashPassword, createSession } from "../../../lib/auth"; +import { eq, count, sql } from "drizzle-orm"; +import { nanoid } from "nanoid"; export const POST: APIRoute = async ({ request, cookies, redirect }) => { const userCountResult = await db.select({ count: count() }).from(users).get(); const isFirstUser = userCountResult ? userCountResult.count === 0 : true; if (!isFirstUser) { - const registrationSetting = await db.select() + const registrationSetting = await db + .select() .from(siteSettings) - .where(eq(siteSettings.key, 'registration_enabled')) + .where(eq(siteSettings.key, "registration_enabled")) .get(); - const registrationEnabled = registrationSetting?.value === 'true'; - + const registrationEnabled = registrationSetting?.value === "true"; + if (!registrationEnabled) { - return new Response('Registration is currently disabled', { status: 403 }); + return redirect("/signup?error=registration_disabled"); } } const formData = await request.formData(); - const name = formData.get('name')?.toString(); - const email = formData.get('email')?.toString(); - const password = formData.get('password')?.toString(); + const name = formData.get("name")?.toString(); + const email = formData.get("email")?.toString(); + const password = formData.get("password")?.toString(); if (!name || !email || !password) { - return new Response('Missing fields', { status: 400 }); + return redirect("/signup?error=missing_fields"); } - const existingUser = await db.select().from(users).where(eq(users.email, email)).get(); + const existingUser = await db + .select() + .from(users) + .where(eq(users.email, email)) + .get(); if (existingUser) { - return new Response('User already exists', { status: 400 }); + return redirect("/signup?error=user_exists"); } const passwordHash = await hashPassword(password); @@ -56,18 +66,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => { await db.insert(members).values({ userId, organizationId: orgId, - role: 'owner', + role: "owner", }); const { sessionId, expiresAt } = await createSession(userId); - cookies.set('session_id', sessionId, { - path: '/', + cookies.set("session_id", sessionId, { + path: "/", httpOnly: true, secure: import.meta.env.PROD, - sameSite: 'lax', + sameSite: "lax", expires: expiresAt, }); - return redirect('/dashboard'); + return redirect("/dashboard"); }; diff --git a/src/pages/api/clients/[id]/update.ts b/src/pages/api/clients/[id]/update.ts index 02fd017..2b385a5 100644 --- a/src/pages/api/clients/[id]/update.ts +++ b/src/pages/api/clients/[id]/update.ts @@ -16,15 +16,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => { let name: string | undefined; let email: string | undefined; + let phone: string | undefined; + let street: string | undefined; + let city: string | undefined; + let state: string | undefined; + let zip: string | undefined; + let country: string | undefined; if (request.headers.get("Content-Type")?.includes("application/json")) { const body = await request.json(); name = body.name; email = body.email; + phone = body.phone; + street = body.street; + city = body.city; + state = body.state; + zip = body.zip; + country = body.country; } else { const formData = await request.formData(); name = formData.get("name")?.toString(); email = formData.get("email")?.toString(); + phone = formData.get("phone")?.toString(); + street = formData.get("street")?.toString(); + city = formData.get("city")?.toString(); + state = formData.get("state")?.toString(); + zip = formData.get("zip")?.toString(); + country = formData.get("country")?.toString(); } if (!name || name.trim().length === 0) { @@ -74,6 +92,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => { .set({ name: name.trim(), email: email?.trim() || null, + phone: phone?.trim() || null, + street: street?.trim() || null, + city: city?.trim() || null, + state: state?.trim() || null, + zip: zip?.trim() || null, + country: country?.trim() || null, }) .where(eq(clients.id, id)) .run(); @@ -85,6 +109,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => { id, name: name.trim(), email: email?.trim() || null, + phone: phone?.trim() || null, + street: street?.trim() || null, + city: city?.trim() || null, + state: state?.trim() || null, + zip: zip?.trim() || null, + country: country?.trim() || null, }), { status: 200, diff --git a/src/pages/api/clients/create.ts b/src/pages/api/clients/create.ts index 0811816..331b4b3 100644 --- a/src/pages/api/clients/create.ts +++ b/src/pages/api/clients/create.ts @@ -12,15 +12,33 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => { let name: string | undefined; let email: string | undefined; + let phone: string | undefined; + let street: string | undefined; + let city: string | undefined; + let state: string | undefined; + let zip: string | undefined; + let country: string | undefined; if (request.headers.get("Content-Type")?.includes("application/json")) { const body = await request.json(); name = body.name; email = body.email; + phone = body.phone; + street = body.street; + city = body.city; + state = body.state; + zip = body.zip; + country = body.country; } else { const formData = await request.formData(); name = formData.get("name")?.toString(); email = formData.get("email")?.toString(); + phone = formData.get("phone")?.toString(); + street = formData.get("street")?.toString(); + city = formData.get("city")?.toString(); + state = formData.get("state")?.toString(); + zip = formData.get("zip")?.toString(); + country = formData.get("country")?.toString(); } if (!name) { @@ -44,13 +62,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => { organizationId: userOrg.organizationId, name, email: email || null, + phone: phone || null, + street: street || null, + city: city || null, + state: state || null, + zip: zip || null, + country: country || null, }); if (locals.scopes) { - return new Response(JSON.stringify({ id, name, email: email || null }), { - status: 201, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ + id, + name, + email: email || null, + phone: phone || null, + street: street || null, + city: city || null, + state: state || null, + zip: zip || null, + country: country || null, + }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + }, + ); } return redirect("/dashboard/clients"); diff --git a/src/pages/api/invoices/[id]/convert.ts b/src/pages/api/invoices/[id]/convert.ts new file mode 100644 index 0000000..93e4ede --- /dev/null +++ b/src/pages/api/invoices/[id]/convert.ts @@ -0,0 +1,97 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../../db"; +import { invoices, members } from "../../../../db/schema"; +import { eq, and, desc } from "drizzle-orm"; + +export const POST: APIRoute = async ({ redirect, locals, params }) => { + const user = locals.user; + if (!user) { + return redirect("/login"); + } + + const { id: invoiceId } = params; + if (!invoiceId) { + return new Response("Invoice ID required", { status: 400 }); + } + + // Fetch invoice to verify existence + const invoice = await db + .select() + .from(invoices) + .where(eq(invoices.id, invoiceId)) + .get(); + + if (!invoice) { + return new Response("Invoice not found", { status: 404 }); + } + + if (invoice.type !== "quote") { + return new Response("Only quotes can be converted to invoices", { + status: 400, + }); + } + + // 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("Unauthorized", { status: 401 }); + } + + try { + // Generate next invoice number + const lastInvoice = await db + .select() + .from(invoices) + .where( + and( + eq(invoices.organizationId, invoice.organizationId), + eq(invoices.type, "invoice"), + ), + ) + .orderBy(desc(invoices.createdAt)) + .limit(1) + .get(); + + let nextInvoiceNumber = "INV-001"; + if (lastInvoice) { + const match = lastInvoice.number.match(/(\d+)$/); + if (match) { + const num = parseInt(match[1]) + 1; + let prefix = lastInvoice.number.replace(match[0], ""); + if (prefix === "EST-") prefix = "INV-"; + nextInvoiceNumber = + prefix + num.toString().padStart(match[0].length, "0"); + } + } + + // Convert quote to invoice: + // 1. Change type to 'invoice' + // 2. Set status to 'draft' (so user can review before sending) + // 3. Update number to next invoice sequence + // 4. Update issue date to today + await db + .update(invoices) + .set({ + type: "invoice", + status: "draft", + number: nextInvoiceNumber, + issueDate: new Date(), + }) + .where(eq(invoices.id, invoiceId)); + + return redirect(`/dashboard/invoices/${invoiceId}`); + } catch (error) { + console.error("Error converting quote to invoice:", error); + return new Response("Internal Server Error", { status: 500 }); + } +}; diff --git a/src/pages/api/invoices/[id]/generate.ts b/src/pages/api/invoices/[id]/generate.ts index 5e1a23a..7c77a6b 100644 --- a/src/pages/api/invoices/[id]/generate.ts +++ b/src/pages/api/invoices/[id]/generate.ts @@ -69,7 +69,9 @@ export const GET: APIRoute = async ({ params, locals }) => { // Generate PDF using Vue PDF // Suppress verbose logging from PDF renderer const originalConsoleLog = console.log; + const originalConsoleWarn = console.warn; console.log = () => {}; + console.warn = () => {}; try { const pdfDocument = createInvoiceDocument({ @@ -83,6 +85,7 @@ export const GET: APIRoute = async ({ params, locals }) => { // Restore console.log console.log = originalConsoleLog; + console.warn = originalConsoleWarn; const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`; @@ -95,6 +98,7 @@ export const GET: APIRoute = async ({ params, locals }) => { } catch (pdfError) { // Restore console.log on error console.log = originalConsoleLog; + console.warn = originalConsoleWarn; throw pdfError; } } catch (error) { diff --git a/src/pages/api/invoices/[id]/update-tax.ts b/src/pages/api/invoices/[id]/update-tax.ts new file mode 100644 index 0000000..5a21e3c --- /dev/null +++ b/src/pages/api/invoices/[id]/update-tax.ts @@ -0,0 +1,79 @@ +import type { APIRoute } from "astro"; +import { db } from "../../../../db"; +import { invoices, members } from "../../../../db/schema"; +import { eq, and } from "drizzle-orm"; +import { recalculateInvoiceTotals } from "../../../../utils/invoice"; + +export const POST: APIRoute = async ({ + request, + redirect, + locals, + params, +}) => { + const user = locals.user; + if (!user) { + return redirect("/login"); + } + + const { id: invoiceId } = params; + if (!invoiceId) { + return new Response("Invoice ID required", { status: 400 }); + } + + // Fetch invoice to verify existence + const invoice = await db + .select() + .from(invoices) + .where(eq(invoices.id, invoiceId)) + .get(); + + if (!invoice) { + return new Response("Invoice not found", { status: 404 }); + } + + // 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("Unauthorized", { status: 401 }); + } + + const formData = await request.formData(); + const taxRateStr = formData.get("taxRate") as string; + + if (taxRateStr === null) { + return new Response("Tax rate is required", { status: 400 }); + } + + try { + const taxRate = parseFloat(taxRateStr); + + if (isNaN(taxRate) || taxRate < 0) { + return new Response("Invalid tax rate", { status: 400 }); + } + + await db + .update(invoices) + .set({ + taxRate, + }) + .where(eq(invoices.id, invoiceId)); + + // Recalculate totals since tax rate changed + await recalculateInvoiceTotals(invoiceId); + + return redirect(`/dashboard/invoices/${invoiceId}`); + } catch (error) { + console.error("Error updating invoice tax rate:", error); + return new Response("Internal Server Error", { status: 500 }); + } +}; diff --git a/src/pages/api/invoices/create.ts b/src/pages/api/invoices/create.ts index 6decfab..e6e1bd0 100644 --- a/src/pages/api/invoices/create.ts +++ b/src/pages/api/invoices/create.ts @@ -3,7 +3,12 @@ import { db } from "../../../db"; import { invoices, members } from "../../../db/schema"; import { eq, and } from "drizzle-orm"; -export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => { +export const POST: APIRoute = async ({ + request, + redirect, + locals, + cookies, +}) => { const user = locals.user; if (!user) { return redirect("/login"); @@ -36,7 +41,8 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => } const membership = currentTeamId - ? userMemberships.find((m) => m.organizationId === currentTeamId) + ? userMemberships.find((m) => m.organizationId === currentTeamId) || + userMemberships[0] : userMemberships[0]; if (!membership) { @@ -72,3 +78,7 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => return new Response("Internal Server Error", { status: 500 }); } }; + +export const GET: APIRoute = async ({ redirect }) => { + return redirect("/dashboard/invoices/new"); +}; diff --git a/src/pages/api/organizations/update-name.ts b/src/pages/api/organizations/update-name.ts index cb3637e..fd924a6 100644 --- a/src/pages/api/organizations/update-name.ts +++ b/src/pages/api/organizations/update-name.ts @@ -1,4 +1,6 @@ import type { APIRoute } from "astro"; +import { promises as fs } from "fs"; +import path from "path"; import { db } from "../../../db"; import { organizations, members } from "../../../db/schema"; import { eq, and } from "drizzle-orm"; @@ -17,6 +19,7 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => { const state = formData.get("state") as string | null; const zip = formData.get("zip") as string | null; const country = formData.get("country") as string | null; + const logo = formData.get("logo") as File | null; if (!organizationId || !name || name.trim().length === 0) { return new Response("Organization ID and name are required", { @@ -49,17 +52,63 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => { ); } + let logoUrl: string | undefined; + + if (logo && logo.size > 0) { + const allowedTypes = ["image/png", "image/jpeg"]; + if (!allowedTypes.includes(logo.type)) { + return new Response( + "Invalid file type. Only PNG and JPG are allowed.", + { + status: 400, + }, + ); + } + + const ext = logo.name.split(".").pop() || "png"; + const filename = `${organizationId}-${Date.now()}.${ext}`; + let uploadDir; + + const envRootDir = process.env.ROOT_DIR + ? process.env.ROOT_DIR + : import.meta.env.ROOT_DIR; + + if (envRootDir) { + uploadDir = path.join(envRootDir, "uploads"); + } else { + uploadDir = + process.env.UPLOAD_DIR || + path.join(process.cwd(), "public", "uploads"); + } + + try { + await fs.access(uploadDir); + } catch { + await fs.mkdir(uploadDir, { recursive: true }); + } + + const buffer = Buffer.from(await logo.arrayBuffer()); + await fs.writeFile(path.join(uploadDir, filename), buffer); + logoUrl = `/uploads/${filename}`; + } + // Update organization information + const updateData: any = { + name: name.trim(), + street: street?.trim() || null, + city: city?.trim() || null, + state: state?.trim() || null, + zip: zip?.trim() || null, + country: country?.trim() || null, + }; + + if (logoUrl) { + updateData.logoUrl = logoUrl; + } + await db .update(organizations) - .set({ - name: name.trim(), - street: street?.trim() || null, - city: city?.trim() || null, - state: state?.trim() || null, - zip: zip?.trim() || null, - country: country?.trim() || null, - }) + .set(updateData) .where(eq(organizations.id, organizationId)) .run(); diff --git a/src/pages/dashboard/clients/[id]/edit.astro b/src/pages/dashboard/clients/[id]/edit.astro index 1485dfc..a4bd522 100644 --- a/src/pages/dashboard/clients/[id]/edit.astro +++ b/src/pages/dashboard/clients/[id]/edit.astro @@ -58,7 +58,7 @@ if (!client) return Astro.redirect('/dashboard/clients'); name="name" value={client.name} placeholder="Acme Corp" - class="input input-bordered" + class="input input-bordered w-full" required /> @@ -72,11 +72,101 @@ if (!client) return Astro.redirect('/dashboard/clients'); id="email" name="email" value={client.email || ''} - placeholder="contact@acme.com" - class="input input-bordered" + placeholder="jason.borne@cia.com" + class="input input-bordered w-full" /> +
    + + +
    + +
    Address Details
    + +
    + + +
    + +
    +
    + + +
    + +
    + + +
    +
    + +
    +
    + + +
    + +
    + + +
    +
    +
    diff --git a/src/pages/dashboard/invoices/[id].astro b/src/pages/dashboard/invoices/[id].astro index 637c841..f922258 100644 --- a/src/pages/dashboard/invoices/[id].astro +++ b/src/pages/dashboard/invoices/[id].astro @@ -90,24 +90,32 @@ const isDraft = invoice.status === 'draft'; )} - {(invoice.status === 'sent' && invoice.type === 'invoice') && ( + {(invoice.status !== 'paid' && invoice.status !== 'void' && invoice.type === 'invoice') && (
    -
    )} - {(invoice.status === 'sent' && invoice.type === 'quote') && ( + {(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && invoice.type === 'quote') && (
    -
    )} + {(invoice.type === 'quote' && invoice.status === 'accepted') && ( +
    + +
    + )}
  • -
  • - -
  • {invoice.status !== 'void' && invoice.status !== 'draft' && (
  • @@ -196,7 +198,19 @@ const isDraft = invoice.status === 'draft'; {client ? (
    {client.name}
    -
    {client.email}
    + {client.email &&
    {client.email}
    } + {client.phone &&
    {client.phone}
    } + {(client.street || client.city || client.state || client.zip || client.country) && ( +
    + {client.street &&
    {client.street}
    } + {(client.city || client.state || client.zip) && ( +
    + {[client.city, client.state, client.zip].filter(Boolean).join(', ')} +
    + )} + {client.country &&
    {client.country}
    } +
    + )}
    ) : (
    Client deleted
    @@ -280,9 +294,16 @@ const isDraft = invoice.status === 'draft'; Subtotal {formatCurrency(invoice.subtotal)} - {(invoice.taxRate ?? 0) > 0 && ( -
    - Tax ({invoice.taxRate}%) + {((invoice.taxRate ?? 0) > 0 || isDraft) && ( +
    + + Tax ({invoice.taxRate ?? 0}%) + {isDraft && ( + + )} + {formatCurrency(invoice.taxAmount)}
    )} @@ -305,10 +326,42 @@ const isDraft = invoice.status === 'draft'; {/* Edit Notes (Draft Only) - Simplistic approach */} {isDraft && !invoice.notes && ( )}
    + + + + + +
    diff --git a/src/pages/dashboard/invoices/[id]/edit.astro b/src/pages/dashboard/invoices/[id]/edit.astro index 047fdd1..417fdc9 100644 --- a/src/pages/dashboard/invoices/[id]/edit.astro +++ b/src/pages/dashboard/invoices/[id]/edit.astro @@ -99,7 +99,9 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
    -
    +
    diff --git a/src/pages/dashboard/invoices/index.astro b/src/pages/dashboard/invoices/index.astro index 078fe2c..fb5fa0a 100644 --- a/src/pages/dashboard/invoices/index.astro +++ b/src/pages/dashboard/invoices/index.astro @@ -109,7 +109,7 @@ const getStatusColor = (status: string) => {
    -
    +
    diff --git a/src/pages/dashboard/invoices/new.astro b/src/pages/dashboard/invoices/new.astro index a4dd199..df9739b 100644 --- a/src/pages/dashboard/invoices/new.astro +++ b/src/pages/dashboard/invoices/new.astro @@ -47,7 +47,9 @@ if (lastInvoice) { const match = lastInvoice.number.match(/(\d+)$/); if (match) { const num = parseInt(match[1]) + 1; - const prefix = lastInvoice.number.replace(match[0], ''); + let prefix = lastInvoice.number.replace(match[0], ''); + // Ensure we don't carry over an EST- prefix to an invoice + if (prefix === 'EST-') prefix = 'INV-'; nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0'); } } @@ -68,7 +70,9 @@ if (lastQuote) { const match = lastQuote.number.match(/(\d+)$/); if (match) { const num = parseInt(match[1]) + 1; - const prefix = lastQuote.number.replace(match[0], ''); + let prefix = lastQuote.number.replace(match[0], ''); + // Ensure we don't carry over an INV- prefix to a quote + if (prefix === 'INV-') prefix = 'EST-'; nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0'); } } @@ -167,7 +171,7 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
    { - radio.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; - if (numberInput) { - if (target.value === 'quote') { - numberInput.value = quoteNumber; - } else if (target.value === 'invoice') { - numberInput.value = invoiceNumber; - } + typeRadios.forEach(radio => { + radio.addEventListener('change', (e) => { + const target = e.target as HTMLInputElement; + + if (numberInput) { + if (target.value === 'quote') { + numberInput.value = quoteNumber; + } else if (target.value === 'invoice') { + numberInput.value = invoiceNumber; } - }); + } + + if (dueDateLabel) { + dueDateLabel.textContent = target.value === 'quote' ? 'Valid Until' : 'Due Date'; + } }); - } + }); diff --git a/src/pages/dashboard/team/settings.astro b/src/pages/dashboard/team/settings.astro index a65cef0..14fcec8 100644 --- a/src/pages/dashboard/team/settings.astro +++ b/src/pages/dashboard/team/settings.astro @@ -67,9 +67,51 @@ const successType = url.searchParams.get('success');
    )} - + +
    +
    + Team Logo +
    +
    +
    +
    + {organization.logoUrl ? ( + {organization.name} + ) : ( + + )} +
    +
    +
    + +
    + Upload a company logo (PNG, JPG). +
    + Will be displayed on invoices and quotes. +
    +
    +
    +
    +