This commit is contained in:
@@ -17,6 +17,8 @@ WORKDIR /app
|
||||
RUN npm i -g pnpm
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN pnpm install --prod
|
||||
@@ -28,4 +30,4 @@ ENV PORT=4321
|
||||
ENV DATABASE_URL=/app/data/chronus.db
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
CMD ["sh", "-c", "pnpm run migrate && node ./dist/server/entry.mjs"]
|
||||
|
||||
140
drizzle/0000_mixed_morlocks.sql
Normal file
140
drizzle/0000_mixed_morlocks.sql
Normal file
@@ -0,0 +1,140 @@
|
||||
CREATE TABLE `api_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`scopes` text DEFAULT '*' NOT NULL,
|
||||
`last_used_at` integer,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `api_tokens_token_unique` ON `api_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `categories` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `invoice_items` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`invoice_id` text NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`quantity` real DEFAULT 1 NOT NULL,
|
||||
`unit_price` integer DEFAULT 0 NOT NULL,
|
||||
`amount` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `invoices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`number` text NOT NULL,
|
||||
`type` text DEFAULT 'invoice' NOT NULL,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`issue_date` integer NOT NULL,
|
||||
`due_date` integer NOT NULL,
|
||||
`notes` text,
|
||||
`currency` text DEFAULT 'USD' NOT NULL,
|
||||
`subtotal` integer DEFAULT 0 NOT NULL,
|
||||
`tax_rate` real DEFAULT 0,
|
||||
`tax_amount` integer DEFAULT 0 NOT NULL,
|
||||
`total` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `members` (
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`role` text DEFAULT 'member' NOT NULL,
|
||||
`joined_at` integer,
|
||||
PRIMARY KEY(`user_id`, `organization_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `organizations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`street` text,
|
||||
`city` text,
|
||||
`state` text,
|
||||
`zip` text,
|
||||
`country` text,
|
||||
`created_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `site_settings` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`key` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `site_settings_key_unique` ON `site_settings` (`key`);--> statement-breakpoint
|
||||
CREATE TABLE `tags` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `time_entries` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`category_id` text NOT NULL,
|
||||
`start_time` integer NOT NULL,
|
||||
`end_time` integer,
|
||||
`description` text,
|
||||
`is_manual` integer DEFAULT false,
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `time_entry_tags` (
|
||||
`time_entry_id` text NOT NULL,
|
||||
`tag_id` text NOT NULL,
|
||||
PRIMARY KEY(`time_entry_id`, `tag_id`),
|
||||
FOREIGN KEY (`time_entry_id`) REFERENCES `time_entries`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`password_hash` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`is_site_admin` integer DEFAULT false,
|
||||
`created_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
|
||||
980
drizzle/meta/0000_snapshot.json
Normal file
980
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,980 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "cfa98c92-215e-4dbc-b8d4-23a655684d1b",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1768609277648,
|
||||
"tag": "0000_mixed_morlocks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,12 +8,14 @@
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"migrate": "node scripts/migrate.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@astrojs/vue": "^5.1.4",
|
||||
"@ceereals/vue-pdf": "^0.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.16.11",
|
||||
"astro-icon": "^1.1.5",
|
||||
@@ -31,6 +33,7 @@
|
||||
"devDependencies": {
|
||||
"@catppuccin/daisyui": "^2.1.1",
|
||||
"@iconify-json/heroicons": "^1.2.3",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"drizzle-kit": "0.31.8"
|
||||
}
|
||||
|
||||
759
pnpm-lock.yaml
generated
759
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
45
scripts/migrate.js
Normal file
45
scripts/migrate.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const runMigrations = () => {
|
||||
console.log("Starting database migrations...");
|
||||
|
||||
const dbUrl =
|
||||
process.env.DATABASE_URL || path.resolve(process.cwd(), "chronus.db");
|
||||
const dbDir = path.dirname(dbUrl);
|
||||
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
console.log(`Creating directory for database: ${dbDir}`);
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Using database at: ${dbUrl}`);
|
||||
|
||||
const sqlite = new Database(dbUrl);
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
const migrationsFolder = path.resolve(process.cwd(), "drizzle");
|
||||
|
||||
if (!fs.existsSync(migrationsFolder)) {
|
||||
console.error(`Migrations folder not found at: ${migrationsFolder}`);
|
||||
console.error(
|
||||
"Did you run `drizzle-kit generate` and copy the folder to the container?",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
migrate(db, { migrationsFolder });
|
||||
console.log("Migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
real,
|
||||
primaryKey,
|
||||
foreignKey,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
@@ -25,6 +26,11 @@ export const organizations = sqliteTable("organizations", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
street: text("street"),
|
||||
city: text("city"),
|
||||
state: text("state"),
|
||||
zip: text("zip"),
|
||||
country: text("country"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -221,3 +227,58 @@ export const apiTokens = sqliteTable(
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const invoices = sqliteTable(
|
||||
"invoices",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organization_id").notNull(),
|
||||
clientId: text("client_id").notNull(),
|
||||
number: text("number").notNull(),
|
||||
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote'
|
||||
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined'
|
||||
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
|
||||
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
|
||||
notes: text("notes"),
|
||||
currency: text("currency").default("USD").notNull(),
|
||||
subtotal: integer("subtotal").notNull().default(0), // in cents
|
||||
taxRate: real("tax_rate").default(0), // percentage
|
||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
||||
total: integer("total").notNull().default(0), // in cents
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
},
|
||||
(table: any) => ({
|
||||
orgFk: foreignKey({
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
clientFk: foreignKey({
|
||||
columns: [table.clientId],
|
||||
foreignColumns: [clients.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const invoiceItems = sqliteTable(
|
||||
"invoice_items",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
invoiceId: text("invoice_id").notNull(),
|
||||
description: text("description").notNull(),
|
||||
quantity: real("quantity").notNull().default(1),
|
||||
unitPrice: integer("unit_price").notNull().default(0), // in cents
|
||||
amount: integer("amount").notNull().default(0), // in cents
|
||||
},
|
||||
(table: any) => ({
|
||||
invoiceFk: foreignKey({
|
||||
columns: [table.invoiceId],
|
||||
foreignColumns: [invoices.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -125,6 +125,13 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||
Time Tracker
|
||||
</a></li>
|
||||
<li><a href="/dashboard/invoices" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/invoices") }
|
||||
]}>
|
||||
<Icon name="heroicons:document-currency-dollar" class="w-5 h-5" />
|
||||
Invoices & Quotes
|
||||
</a></li>
|
||||
<li><a href="/dashboard/reports" class:list={[
|
||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/reports") }
|
||||
|
||||
104
src/pages/api/invoices/[id]/generate.ts
Normal file
104
src/pages/api/invoices/[id]/generate.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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 { createInvoiceDocument } from "../../../../pdf/generateInvoicePDF";
|
||||
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
try {
|
||||
const { id } = params;
|
||||
const user = locals.user;
|
||||
|
||||
if (!user || !id) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// 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 access
|
||||
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 });
|
||||
}
|
||||
|
||||
// Fetch items
|
||||
const items = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||
.all();
|
||||
|
||||
if (!client) {
|
||||
return new Response("Client not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Generate PDF using Vue PDF
|
||||
// Suppress verbose logging from PDF renderer
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = () => {};
|
||||
|
||||
try {
|
||||
const pdfDocument = createInvoiceDocument({
|
||||
invoice,
|
||||
items,
|
||||
client,
|
||||
organization,
|
||||
});
|
||||
|
||||
const stream = await renderToStream(pdfDocument);
|
||||
|
||||
// Restore console.log
|
||||
console.log = originalConsoleLog;
|
||||
|
||||
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;
|
||||
throw pdfError;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating PDF:", error);
|
||||
return new Response("Error generating PDF", { status: 500 });
|
||||
}
|
||||
};
|
||||
88
src/pages/api/invoices/[id]/items/add.ts
Normal file
88
src/pages/api/invoices/[id]/items/add.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../../db";
|
||||
import { invoiceItems, 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 and check status
|
||||
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 });
|
||||
}
|
||||
|
||||
// Only allow editing if draft
|
||||
if (invoice.status !== "draft") {
|
||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const description = formData.get("description") as string;
|
||||
const quantityStr = formData.get("quantity") as string;
|
||||
const unitPriceStr = formData.get("unitPrice") as string;
|
||||
|
||||
if (!description || !quantityStr || !unitPriceStr) {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
const quantity = parseFloat(quantityStr);
|
||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||
|
||||
// Convert to cents
|
||||
const unitPrice = Math.round(unitPriceMajor * 100);
|
||||
const amount = Math.round(quantity * unitPrice);
|
||||
|
||||
try {
|
||||
await db.insert(invoiceItems).values({
|
||||
invoiceId,
|
||||
description,
|
||||
quantity,
|
||||
unitPrice,
|
||||
amount,
|
||||
});
|
||||
|
||||
// Update invoice totals
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error adding invoice item:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
84
src/pages/api/invoices/[id]/items/delete.ts
Normal file
84
src/pages/api/invoices/[id]/items/delete.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../../db";
|
||||
import { invoiceItems, 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 and check status
|
||||
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 });
|
||||
}
|
||||
|
||||
// Only allow editing if draft
|
||||
if (invoice.status !== "draft") {
|
||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get("itemId") as string;
|
||||
|
||||
if (!itemId) {
|
||||
return new Response("Item ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Verify item belongs to invoice
|
||||
const item = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(and(eq(invoiceItems.id, itemId), eq(invoiceItems.invoiceId, invoiceId)))
|
||||
.get();
|
||||
|
||||
if (!item) {
|
||||
return new Response("Item not found", { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
||||
|
||||
// Update invoice totals
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error deleting invoice item:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
76
src/pages/api/invoices/[id]/status.ts
Normal file
76
src/pages/api/invoices/[id]/status.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import { invoices, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const status = formData.get("status") as string;
|
||||
|
||||
const validStatuses = [
|
||||
"draft",
|
||||
"sent",
|
||||
"paid",
|
||||
"void",
|
||||
"accepted",
|
||||
"declined",
|
||||
];
|
||||
|
||||
if (!status || !validStatuses.includes(status)) {
|
||||
return new Response("Invalid status", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check ownership
|
||||
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 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({ status: status as any })
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice status:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
87
src/pages/api/invoices/[id]/update.ts
Normal file
87
src/pages/api/invoices/[id]/update.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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 number = formData.get("number") as string;
|
||||
const currency = formData.get("currency") as string;
|
||||
const issueDateStr = formData.get("issueDate") as string;
|
||||
const dueDateStr = formData.get("dueDate") as string;
|
||||
const taxRateStr = formData.get("taxRate") as string;
|
||||
const notes = formData.get("notes") as string;
|
||||
|
||||
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const issueDate = new Date(issueDateStr);
|
||||
const dueDate = new Date(dueDateStr);
|
||||
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
number,
|
||||
currency,
|
||||
issueDate,
|
||||
dueDate,
|
||||
taxRate,
|
||||
notes: notes || null,
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
// Recalculate totals in case tax rate changed
|
||||
await recalculateInvoiceTotals(invoiceId);
|
||||
|
||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||
} catch (error) {
|
||||
console.error("Error updating invoice:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
74
src/pages/api/invoices/create.ts
Normal file
74
src/pages/api/invoices/create.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { APIRoute } from "astro";
|
||||
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 }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const type = formData.get("type") as string;
|
||||
const clientId = formData.get("clientId") as string;
|
||||
const number = formData.get("number") as string;
|
||||
const issueDateStr = formData.get("issueDate") as string;
|
||||
const dueDateStr = formData.get("dueDate") as string;
|
||||
const currency = formData.get("currency") as string;
|
||||
|
||||
if (!type || !clientId || !number || !issueDateStr || !dueDateStr) {
|
||||
return new Response("Missing required fields", { status: 400 });
|
||||
}
|
||||
|
||||
// Get current team context
|
||||
const currentTeamId = cookies.get("currentTeamId")?.value;
|
||||
|
||||
// Verify membership
|
||||
const userMemberships = await db
|
||||
.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) {
|
||||
return redirect("/dashboard");
|
||||
}
|
||||
|
||||
const membership = currentTeamId
|
||||
? userMemberships.find((m) => m.organizationId === currentTeamId)
|
||||
: userMemberships[0];
|
||||
|
||||
if (!membership) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const organizationId = membership.organizationId;
|
||||
|
||||
try {
|
||||
const issueDate = new Date(issueDateStr);
|
||||
const dueDate = new Date(dueDateStr);
|
||||
|
||||
const [newInvoice] = await db
|
||||
.insert(invoices)
|
||||
.values({
|
||||
organizationId,
|
||||
clientId,
|
||||
number,
|
||||
type: type as "invoice" | "quote",
|
||||
status: "draft",
|
||||
issueDate,
|
||||
dueDate,
|
||||
currency: currency || "USD",
|
||||
subtotal: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return redirect(`/dashboard/invoices/${newInvoice.id}`);
|
||||
} catch (error) {
|
||||
console.error("Error creating invoice:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
58
src/pages/api/invoices/delete.ts
Normal file
58
src/pages/api/invoices/delete.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { invoices, invoiceItems, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect, locals }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const invoiceId = formData.get("id") as string;
|
||||
|
||||
if (!invoiceId) {
|
||||
return new Response("Invoice ID required", { status: 400 });
|
||||
}
|
||||
|
||||
// Fetch invoice to verify existence and check ownership
|
||||
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 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete invoice items first (manual cascade)
|
||||
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
||||
|
||||
// Delete the invoice
|
||||
await db.delete(invoices).where(eq(invoices.id, invoiceId));
|
||||
|
||||
return redirect("/dashboard/invoices");
|
||||
} catch (error) {
|
||||
console.error("Error deleting invoice:", error);
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,11 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const formData = await request.formData();
|
||||
const organizationId = formData.get("organizationId") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const street = formData.get("street") as string | null;
|
||||
const city = formData.get("city") as string | null;
|
||||
const state = formData.get("state") as string | null;
|
||||
const zip = formData.get("zip") as string | null;
|
||||
const country = formData.get("country") as string | null;
|
||||
|
||||
if (!organizationId || !name || name.trim().length === 0) {
|
||||
return new Response("Organization ID and name are required", {
|
||||
@@ -44,16 +49,23 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Update organization name
|
||||
// Update organization information
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({ name: name.trim() })
|
||||
.set({
|
||||
name: name.trim(),
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
})
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.run();
|
||||
|
||||
return redirect("/dashboard/team/settings?success=org-name");
|
||||
} catch (error) {
|
||||
console.error("Error updating organization name:", error);
|
||||
return new Response("Failed to update organization name", { status: 500 });
|
||||
console.error("Error updating organization:", error);
|
||||
return new Response("Failed to update organization", { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
312
src/pages/dashboard/invoices/[id].astro
Normal file
312
src/pages/dashboard/invoices/[id].astro
Normal file
@@ -0,0 +1,312 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
|
||||
if (!user || !id) {
|
||||
return Astro.redirect('/dashboard/invoices');
|
||||
}
|
||||
|
||||
// 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 Astro.redirect('/404');
|
||||
}
|
||||
|
||||
const { invoice, client, organization } = invoiceResult;
|
||||
|
||||
// Verify access
|
||||
const membership = await db.select()
|
||||
.from(members)
|
||||
.where(and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
))
|
||||
.get();
|
||||
|
||||
if (!membership) {
|
||||
return Astro.redirect('/dashboard');
|
||||
}
|
||||
|
||||
// Fetch items
|
||||
const items = await db.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||
.all();
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: invoice.currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const isDraft = invoice.status === 'draft';
|
||||
---
|
||||
|
||||
<DashboardLayout title={`${invoice.number} - Chronus`}>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs btn-square">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<div class={`badge ${
|
||||
invoice.status === 'paid' || invoice.status === 'accepted' ? 'badge-success' :
|
||||
invoice.status === 'sent' ? 'badge-info' :
|
||||
invoice.status === 'void' || invoice.status === 'declined' ? 'badge-error' :
|
||||
'badge-ghost'
|
||||
} uppercase font-bold tracking-wider`}>
|
||||
{invoice.status}
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold">{invoice.number}</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{isDraft && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="sent" />
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:paper-airplane" class="w-5 h-5" />
|
||||
Mark Sent
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.status === 'sent' && invoice.type === 'invoice') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="paid" />
|
||||
<button type="submit" class="btn btn-success text-white">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Mark Paid
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.status === 'sent' && invoice.type === 'quote') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="accepted" />
|
||||
<button type="submit" class="btn btn-success text-white">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Mark Accepted
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div role="button" tabindex="0" class="btn btn-square btn-ghost border border-base-300">
|
||||
<Icon name="heroicons:ellipsis-horizontal" class="w-6 h-6" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
Edit Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/api/invoices/${invoice.id}/generate`} download>
|
||||
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" onclick="window.print()">
|
||||
<Icon name="heroicons:printer" class="w-4 h-4" />
|
||||
Print
|
||||
</button>
|
||||
</li>
|
||||
{invoice.status !== 'void' && invoice.status !== 'draft' && (
|
||||
<li>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="void" />
|
||||
<button type="submit" class="text-error">
|
||||
<Icon name="heroicons:x-circle" class="w-4 h-4" />
|
||||
Void
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
<form method="POST" action="/api/invoices/delete" onsubmit="return confirm('Are you sure?');">
|
||||
<input type="hidden" name="id" value={invoice.id} />
|
||||
<button type="submit" class="text-error">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Paper -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 print:shadow-none print:border-none">
|
||||
<div class="card-body p-8 sm:p-12">
|
||||
<!-- Header Section -->
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-8 mb-12">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-primary mb-1">{organization.name}</h2>
|
||||
{(organization.street || organization.city || organization.state || organization.zip || organization.country) && (
|
||||
<div class="text-sm opacity-70 space-y-0.5">
|
||||
{organization.street && <div>{organization.street}</div>}
|
||||
{(organization.city || organization.state || organization.zip) && (
|
||||
<div>
|
||||
{[organization.city, organization.state, organization.zip].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{organization.country && <div>{organization.country}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-4xl font-light text-base-content/30 uppercase tracking-widest mb-4">
|
||||
{invoice.type}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<div class="text-base-content/60">Number:</div>
|
||||
<div class="font-mono font-bold">{invoice.number}</div>
|
||||
<div class="text-base-content/60">Date:</div>
|
||||
<div>{invoice.issueDate.toLocaleDateString()}</div>
|
||||
<div class="text-base-content/60">Due Date:</div>
|
||||
<div>{invoice.dueDate.toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bill To -->
|
||||
<div class="mb-12">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Bill To</div>
|
||||
{client ? (
|
||||
<div>
|
||||
<div class="font-bold text-lg">{client.name}</div>
|
||||
<div class="text-base-content/70">{client.email}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="italic text-base-content/40">Client deleted</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="mb-8">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-base-200 text-left text-xs font-bold uppercase tracking-wider text-base-content/40">
|
||||
<th class="py-3">Description</th>
|
||||
<th class="py-3 text-right w-24">Qty</th>
|
||||
<th class="py-3 text-right w-32">Price</th>
|
||||
<th class="py-3 text-right w-32">Amount</th>
|
||||
{isDraft && <th class="py-3 w-10"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-base-200">
|
||||
{items.map(item => (
|
||||
<tr>
|
||||
<td class="py-4">{item.description}</td>
|
||||
<td class="py-4 text-right">{item.quantity}</td>
|
||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
|
||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
|
||||
{isDraft && (
|
||||
<td class="py-4 text-right">
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error opacity-50 hover:opacity-100">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/40 italic">
|
||||
No items added yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add Item Form (Only if Draft) -->
|
||||
{isDraft && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-300/50">
|
||||
<h4 class="text-sm font-bold mb-3">Add Item</h4>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4 items-end">
|
||||
<div class="sm:col-span-6">
|
||||
<label class="label label-text text-xs pt-0">Description</label>
|
||||
<input type="text" name="description" class="input input-sm input-bordered w-full" required placeholder="Service or product..." />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label label-text text-xs pt-0">Qty</label>
|
||||
<input type="number" name="quantity" step="0.01" class="input input-sm input-bordered w-full" required value="1" />
|
||||
</div>
|
||||
<div class="sm:col-span-3">
|
||||
<label class="label label-text text-xs pt-0">Unit Price ({invoice.currency})</label>
|
||||
<input type="number" name="unitPrice" step="0.01" class="input input-sm input-bordered w-full" required placeholder="0.00" />
|
||||
</div>
|
||||
<div class="sm:col-span-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-full">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="flex justify-end">
|
||||
<div class="w-64 space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{(invoice.taxRate ?? 0) > 0 && (
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Tax ({invoice.taxRate}%)</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span class="text-primary">{formatCurrency(invoice.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
{invoice.notes && (
|
||||
<div class="mt-12 pt-8 border-t border-base-200">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Notes</div>
|
||||
<div class="text-sm whitespace-pre-wrap opacity-80">{invoice.notes}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Notes (Draft Only) - Simplistic approach */}
|
||||
{isDraft && !invoice.notes && (
|
||||
<div class="mt-8 text-center">
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-ghost">Add Notes</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
153
src/pages/dashboard/invoices/[id]/edit.astro
Normal file
153
src/pages/dashboard/invoices/[id]/edit.astro
Normal file
@@ -0,0 +1,153 @@
|
||||
---
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { invoices, members } from '../../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
const { id } = Astro.params;
|
||||
const user = Astro.locals.user;
|
||||
|
||||
if (!user || !id) {
|
||||
return Astro.redirect('/dashboard/invoices');
|
||||
}
|
||||
|
||||
// Fetch invoice
|
||||
const invoice = await db.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, id))
|
||||
.get();
|
||||
|
||||
if (!invoice) {
|
||||
return Astro.redirect('/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 Astro.redirect('/dashboard');
|
||||
}
|
||||
|
||||
// Format dates for input[type="date"]
|
||||
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
|
||||
const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
---
|
||||
|
||||
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
Back to Invoice
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold mt-2">Edit Details</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body gap-6">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Number -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Number</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="number"
|
||||
class="input input-bordered font-mono"
|
||||
value={invoice.number}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Currency -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Currency</span>
|
||||
</label>
|
||||
<select name="currency" class="select select-bordered w-full">
|
||||
<option value="USD" selected={invoice.currency === 'USD'}>USD ($)</option>
|
||||
<option value="EUR" selected={invoice.currency === 'EUR'}>EUR (€)</option>
|
||||
<option value="GBP" selected={invoice.currency === 'GBP'}>GBP (£)</option>
|
||||
<option value="CAD" selected={invoice.currency === 'CAD'}>CAD ($)</option>
|
||||
<option value="AUD" selected={invoice.currency === 'AUD'}>AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Issue Date -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Issue Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="issueDate"
|
||||
class="input input-bordered"
|
||||
value={issueDateStr}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Due Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="dueDate"
|
||||
class="input input-bordered"
|
||||
value={dueDateStr}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tax Rate -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Tax Rate (%)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="taxRate"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
class="input input-bordered"
|
||||
value={invoice.taxRate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Notes / Terms</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
class="textarea textarea-bordered h-32 font-mono text-sm"
|
||||
placeholder="Payment terms, bank details, or thank you notes..."
|
||||
>{invoice.notes}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
215
src/pages/dashboard/invoices/index.astro
Normal file
215
src/pages/dashboard/invoices/index.astro
Normal file
@@ -0,0 +1,215 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { invoices, clients, members } from '../../../db/schema';
|
||||
import { eq, desc, and } 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 currentTeamIdResolved = userMembership.organizationId;
|
||||
|
||||
// Fetch invoices and quotes
|
||||
const allInvoices = await db.select({
|
||||
invoice: invoices,
|
||||
client: clients,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(eq(invoices.organizationId, currentTeamIdResolved))
|
||||
.orderBy(desc(invoices.issueDate))
|
||||
.all();
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'badge-success';
|
||||
case 'accepted': return 'badge-success';
|
||||
case 'sent': return 'badge-info';
|
||||
case 'draft': return 'badge-ghost';
|
||||
case 'void': return 'badge-error';
|
||||
case 'declined': return 'badge-error';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
<DashboardLayout title="Invoices & Quotes - Chronus">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Invoices & Quotes</h1>
|
||||
<p class="text-base-content/60 mt-1">Manage your billing and estimates</p>
|
||||
</div>
|
||||
<a href="/dashboard/invoices/new" class="btn btn-primary">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
Create New
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:document-text" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Invoices</div>
|
||||
<div class="stat-value text-primary">{allInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
|
||||
<div class="stat-desc">All time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Open Quotes</div>
|
||||
<div class="stat-value text-secondary">{allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
|
||||
<div class="stat-desc">Waiting for approval</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow bg-base-100 border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Revenue</div>
|
||||
<div class="stat-value text-success">
|
||||
{formatCurrency(allInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
||||
</div>
|
||||
<div class="stat-desc">Paid invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr class="bg-base-200/50">
|
||||
<th>Number</th>
|
||||
<th>Client</th>
|
||||
<th>Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allInvoices.length === 0 ? (
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-8 text-base-content/60">
|
||||
No invoices or quotes found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
allInvoices.map(({ invoice, client }) => (
|
||||
<tr class="hover:bg-base-200/50 transition-colors">
|
||||
<td class="font-mono font-medium">
|
||||
<a href={`/dashboard/invoices/${invoice.id}`} class="link link-hover text-primary">
|
||||
{invoice.number}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{client ? (
|
||||
<div class="font-medium">{client.name}</div>
|
||||
) : (
|
||||
<span class="text-base-content/40 italic">Deleted Client</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{invoice.issueDate.toLocaleDateString()}</td>
|
||||
<td>{invoice.dueDate.toLocaleDateString()}</td>
|
||||
<td class="font-mono font-medium">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</td>
|
||||
<td>
|
||||
<div class={`badge ${getStatusColor(invoice.status)} badge-sm uppercase font-bold tracking-wider`}>
|
||||
{invoice.status}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="capitalize text-sm">{invoice.type}</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="dropdown dropdown-end">
|
||||
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-[100]">
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Icon name="heroicons:eye" class="w-4 h-4" />
|
||||
View Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={`/api/invoices/${invoice.id}/generate`} download>
|
||||
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
|
||||
Download PDF
|
||||
</a>
|
||||
</li>
|
||||
{invoice.status === 'draft' && (
|
||||
<li>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`} class="w-full">
|
||||
<input type="hidden" name="status" value="sent" />
|
||||
<button type="submit" class="w-full justify-start">
|
||||
<Icon name="heroicons:paper-airplane" class="w-4 h-4" />
|
||||
Mark as Sent
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
)}
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full">
|
||||
<input type="hidden" name="id" value={invoice.id} />
|
||||
<button type="submit" class="w-full justify-start text-error hover:bg-error/10">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
233
src/pages/dashboard/invoices/new.astro
Normal file
233
src/pages/dashboard/invoices/new.astro
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { clients, members, invoices } from '../../../db/schema';
|
||||
import { eq, desc, and } 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 currentTeamIdResolved = userMembership.organizationId;
|
||||
|
||||
// Fetch clients for dropdown
|
||||
const teamClients = await db.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, currentTeamIdResolved))
|
||||
.all();
|
||||
|
||||
// Generate next invoice number (INV-)
|
||||
const lastInvoice = await db.select()
|
||||
.from(invoices)
|
||||
.where(and(
|
||||
eq(invoices.organizationId, currentTeamIdResolved),
|
||||
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;
|
||||
const prefix = lastInvoice.number.replace(match[0], '');
|
||||
nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate next quote number (EST-)
|
||||
const lastQuote = await db.select()
|
||||
.from(invoices)
|
||||
.where(and(
|
||||
eq(invoices.organizationId, currentTeamIdResolved),
|
||||
eq(invoices.type, 'quote')
|
||||
))
|
||||
.orderBy(desc(invoices.createdAt))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
let nextQuoteNumber = 'EST-001';
|
||||
if (lastQuote) {
|
||||
const match = lastQuote.number.match(/(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]) + 1;
|
||||
const prefix = lastQuote.number.replace(match[0], '');
|
||||
nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0');
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setDate(nextMonth.getDate() + 30);
|
||||
const defaultDueDate = nextMonth.toISOString().split('T')[0];
|
||||
---
|
||||
|
||||
<DashboardLayout title="New Document - Chronus">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
Back to Invoices
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold mt-2">Create New Document</h1>
|
||||
</div>
|
||||
|
||||
{teamClients.length === 0 ? (
|
||||
<div role="alert" class="alert alert-warning shadow-lg">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
|
||||
<div>
|
||||
<h3 class="font-bold">No Clients Found</h3>
|
||||
<div class="text-xs">You need to add a client before you can create an invoice or quote.</div>
|
||||
</div>
|
||||
<a href="/dashboard/clients" class="btn btn-sm">Manage Clients</a>
|
||||
</div>
|
||||
) : (
|
||||
<form method="POST" action="/api/invoices/create" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body gap-6">
|
||||
|
||||
<!-- Document Type -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Document Type</span>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="label cursor-pointer justify-start gap-2 border border-base-300 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all">
|
||||
<input type="radio" name="type" value="invoice" class="radio radio-primary" checked />
|
||||
<span class="label-text font-medium">Invoice</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2 border border-base-300 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all">
|
||||
<input type="radio" name="type" value="quote" class="radio radio-primary" />
|
||||
<span class="label-text font-medium">Quote / Estimate</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Client -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Client</span>
|
||||
</label>
|
||||
<select name="clientId" class="select select-bordered w-full" required>
|
||||
<option value="" disabled selected>Select a client...</option>
|
||||
{teamClients.map(client => (
|
||||
<option value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Number -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Number</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="number"
|
||||
id="documentNumber"
|
||||
class="input input-bordered font-mono"
|
||||
value={nextInvoiceNumber}
|
||||
data-invoice-number={nextInvoiceNumber}
|
||||
data-quote-number={nextQuoteNumber}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Issue Date -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Issue Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="issueDate"
|
||||
class="input input-bordered"
|
||||
value={today}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Due Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="dueDate"
|
||||
class="input input-bordered"
|
||||
value={defaultDueDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Currency -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Currency</span>
|
||||
</label>
|
||||
<select name="currency" class="select select-bordered w-full">
|
||||
<option value="USD">USD ($)</option>
|
||||
<option value="EUR">EUR (€)</option>
|
||||
<option value="GBP">GBP (£)</option>
|
||||
<option value="CAD">CAD ($)</option>
|
||||
<option value="AUD">AUD ($)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/dashboard/invoices" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create Draft
|
||||
<Icon name="heroicons:arrow-right" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
|
||||
<script>
|
||||
// Update number based on document type
|
||||
const typeRadios = document.querySelectorAll('input[name="type"]');
|
||||
const numberInput = document.getElementById('documentNumber') as HTMLInputElement | null;
|
||||
|
||||
if (numberInput) {
|
||||
const invoiceNumber = numberInput.dataset.invoiceNumber || 'INV-001';
|
||||
const quoteNumber = numberInput.dataset.quoteNumber || 'EST-001';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@ import CategoryChart from '../../components/CategoryChart.vue';
|
||||
import ClientChart from '../../components/ClientChart.vue';
|
||||
import MemberChart from '../../components/MemberChart.vue';
|
||||
import { db } from '../../db';
|
||||
import { timeEntries, members, users, clients, categories } from '../../db/schema';
|
||||
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
|
||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||
|
||||
@@ -23,7 +23,7 @@ const userMemberships = await db.select()
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
|
||||
@@ -120,7 +120,7 @@ const statsByMember = teamMembers.map(member => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
member,
|
||||
totalTime,
|
||||
@@ -136,7 +136,7 @@ const statsByCategory = allCategories.map(category => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
category,
|
||||
totalTime,
|
||||
@@ -152,7 +152,7 @@ const statsByClient = allClients.map(client => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
client,
|
||||
totalTime,
|
||||
@@ -167,6 +167,81 @@ const totalTime = entries.reduce((sum, e) => {
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Fetch invoices and quotes for the same time period
|
||||
const invoiceConditions = [
|
||||
eq(invoices.organizationId, userMembership.organizationId),
|
||||
gte(invoices.issueDate, startDate),
|
||||
lte(invoices.issueDate, endDate),
|
||||
];
|
||||
|
||||
if (selectedClientId) {
|
||||
invoiceConditions.push(eq(invoices.clientId, selectedClientId));
|
||||
}
|
||||
|
||||
const allInvoices = await db.select({
|
||||
invoice: invoices,
|
||||
client: clients,
|
||||
})
|
||||
.from(invoices)
|
||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||
.where(and(...invoiceConditions))
|
||||
.orderBy(desc(invoices.issueDate))
|
||||
.all();
|
||||
|
||||
// Invoice statistics
|
||||
const invoiceStats = {
|
||||
total: allInvoices.filter(i => i.invoice.type === 'invoice').length,
|
||||
paid: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid').length,
|
||||
sent: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent').length,
|
||||
draft: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'draft').length,
|
||||
void: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'void').length,
|
||||
};
|
||||
|
||||
// Quote statistics
|
||||
const quoteStats = {
|
||||
total: allInvoices.filter(i => i.invoice.type === 'quote').length,
|
||||
accepted: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'accepted').length,
|
||||
sent: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length,
|
||||
declined: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'declined').length,
|
||||
draft: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'draft').length,
|
||||
};
|
||||
|
||||
// Revenue statistics
|
||||
const revenueStats = {
|
||||
total: allInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||
.reduce((sum, i) => sum + i.invoice.total, 0),
|
||||
pending: allInvoices
|
||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent')
|
||||
.reduce((sum, i) => sum + i.invoice.total, 0),
|
||||
quotedValue: allInvoices
|
||||
.filter(i => i.invoice.type === 'quote' && (i.invoice.status === 'sent' || i.invoice.status === 'accepted'))
|
||||
.reduce((sum, i) => sum + i.invoice.total, 0),
|
||||
};
|
||||
|
||||
// Revenue by client
|
||||
const revenueByClient = allClients.map(client => {
|
||||
const clientInvoices = allInvoices.filter(i =>
|
||||
i.client?.id === client.id &&
|
||||
i.invoice.type === 'invoice' &&
|
||||
i.invoice.status === 'paid'
|
||||
);
|
||||
const revenue = clientInvoices.reduce((sum, i) => sum + i.invoice.total, 0);
|
||||
|
||||
return {
|
||||
client,
|
||||
revenue,
|
||||
invoiceCount: clientInvoices.length,
|
||||
};
|
||||
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
|
||||
|
||||
function formatCurrency(amount: number, currency: string = 'USD') {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
function getTimeRangeLabel(range: string) {
|
||||
switch (range) {
|
||||
case 'today': return 'Today';
|
||||
@@ -247,7 +322,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
@@ -270,6 +345,17 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Revenue</div>
|
||||
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
|
||||
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-accent">
|
||||
@@ -282,6 +368,121 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice & Quote Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||
Invoices Overview
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Total Invoices</div>
|
||||
<div class="stat-value text-2xl">{invoiceStats.total}</div>
|
||||
</div>
|
||||
<div class="stat bg-success/10 rounded-lg">
|
||||
<div class="stat-title text-xs">Paid</div>
|
||||
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
|
||||
</div>
|
||||
<div class="stat bg-info/10 rounded-lg">
|
||||
<div class="stat-title text-xs">Sent</div>
|
||||
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Draft</div>
|
||||
<div class="stat-value text-2xl">{invoiceStats.draft}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Revenue (Paid)</span>
|
||||
<span class="font-bold text-success">{formatCurrency(revenueStats.total)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Pending (Sent)</span>
|
||||
<span class="font-bold text-warning">{formatCurrency(revenueStats.pending)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:clipboard-document-list" class="w-6 h-6" />
|
||||
Quotes Overview
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-title text-xs">Total Quotes</div>
|
||||
<div class="stat-value text-2xl">{quoteStats.total}</div>
|
||||
</div>
|
||||
<div class="stat bg-success/10 rounded-lg">
|
||||
<div class="stat-title text-xs">Accepted</div>
|
||||
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
|
||||
</div>
|
||||
<div class="stat bg-info/10 rounded-lg">
|
||||
<div class="stat-title text-xs">Pending</div>
|
||||
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
|
||||
</div>
|
||||
<div class="stat bg-error/10 rounded-lg">
|
||||
<div class="stat-title text-xs">Declined</div>
|
||||
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-2"></div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Quoted Value</span>
|
||||
<span class="font-bold">{formatCurrency(revenueStats.quotedValue)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Conversion Rate</span>
|
||||
<span class="font-bold">
|
||||
{quoteStats.total > 0 ? Math.round((quoteStats.accepted / quoteStats.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
|
||||
{!selectedClientId && revenueByClient.length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:banknotes" class="w-6 h-6" />
|
||||
Revenue by Client
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Revenue</th>
|
||||
<th>Invoices</th>
|
||||
<th>Avg Invoice</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revenueByClient.slice(0, 10).map(stat => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-bold">{stat.client.name}</div>
|
||||
</td>
|
||||
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td>
|
||||
<td>{stat.invoiceCount}</td>
|
||||
<td class="font-mono">
|
||||
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Charts Section - Only show if there's data */}
|
||||
{totalTime > 0 && (
|
||||
<>
|
||||
@@ -295,8 +496,8 @@ function getTimeRangeLabel(range: string) {
|
||||
Category Distribution
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<CategoryChart
|
||||
client:load
|
||||
<CategoryChart
|
||||
client:load
|
||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.category.name,
|
||||
totalTime: s.totalTime,
|
||||
@@ -317,8 +518,8 @@ function getTimeRangeLabel(range: string) {
|
||||
Time by Client
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<ClientChart
|
||||
client:load
|
||||
<ClientChart
|
||||
client:load
|
||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.client.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -339,8 +540,8 @@ function getTimeRangeLabel(range: string) {
|
||||
Time by Team Member
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<MemberChart
|
||||
client:load
|
||||
<MemberChart
|
||||
client:load
|
||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.member.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -427,9 +628,9 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-primary w-20"
|
||||
value={stat.totalTime}
|
||||
<progress
|
||||
class="progress progress-primary w-20"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
@@ -472,9 +673,9 @@ function getTimeRangeLabel(range: string) {
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-secondary w-20"
|
||||
value={stat.totalTime}
|
||||
<progress
|
||||
class="progress progress-secondary w-20"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
@@ -532,7 +733,7 @@ function getTimeRangeLabel(range: string) {
|
||||
</td>
|
||||
<td>{e.entry.description || '-'}</td>
|
||||
<td class="font-mono">
|
||||
{e.entry.endTime
|
||||
{e.entry.endTime
|
||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||
: 'Running...'
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ const successType = url.searchParams.get('success');
|
||||
{successType === 'org-name' && (
|
||||
<div class="alert alert-success mb-4">
|
||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||
<span>Team name updated successfully!</span>
|
||||
<span>Team information updated successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -87,6 +87,83 @@ const successType = url.searchParams.get('success');
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="divider">Address Information</div>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Street Address</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="street"
|
||||
value={organization.street || ''}
|
||||
placeholder="123 Main Street"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">City</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={organization.city || ''}
|
||||
placeholder="City"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">State/Province</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="state"
|
||||
value={organization.state || ''}
|
||||
placeholder="State/Province"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Postal Code</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="zip"
|
||||
value={organization.zip || ''}
|
||||
placeholder="12345"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Country</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="country"
|
||||
value={organization.country || ''}
|
||||
placeholder="Country"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Address information appears on invoices and quotes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
|
||||
505
src/pdf/generateInvoicePDF.ts
Normal file
505
src/pdf/generateInvoicePDF.ts
Normal file
@@ -0,0 +1,505 @@
|
||||
import { h } from "vue";
|
||||
import { Document, Page, Text, View } from "@ceereals/vue-pdf";
|
||||
import type { Style } from "@react-pdf/types";
|
||||
|
||||
interface InvoiceItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface Client {
|
||||
name: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
name: string;
|
||||
street: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
number: string;
|
||||
type: string;
|
||||
status: string;
|
||||
issueDate: Date;
|
||||
dueDate: Date;
|
||||
currency: string;
|
||||
subtotal: number;
|
||||
taxRate: number | null;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface InvoiceDocumentProps {
|
||||
invoice: Invoice;
|
||||
items: InvoiceItem[];
|
||||
client: Client;
|
||||
organization: Organization;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: {
|
||||
padding: 60,
|
||||
fontFamily: "Helvetica",
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: "#1F2937",
|
||||
backgroundColor: "#FFFFFF",
|
||||
} as Style,
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 45,
|
||||
paddingBottom: 24,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: "#E5E7EB",
|
||||
} as Style,
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
maxWidth: 280,
|
||||
} as Style,
|
||||
headerRight: {
|
||||
flex: 1,
|
||||
alignItems: "flex-end",
|
||||
} as Style,
|
||||
organizationName: {
|
||||
fontSize: 22,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: "#1F2937",
|
||||
letterSpacing: -0.5,
|
||||
} as Style,
|
||||
organizationAddress: {
|
||||
fontSize: 9,
|
||||
color: "#6B7280",
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 12,
|
||||
} as Style,
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
alignSelf: "flex-start",
|
||||
} as Style,
|
||||
statusDraft: {
|
||||
backgroundColor: "#F3F4F6",
|
||||
color: "#6B7280",
|
||||
} as Style,
|
||||
statusSent: {
|
||||
backgroundColor: "#DBEAFE",
|
||||
color: "#1E40AF",
|
||||
} as Style,
|
||||
statusPaid: {
|
||||
backgroundColor: "#D1FAE5",
|
||||
color: "#065F46",
|
||||
} as Style,
|
||||
statusAccepted: {
|
||||
backgroundColor: "#D1FAE5",
|
||||
color: "#065F46",
|
||||
} as Style,
|
||||
statusVoid: {
|
||||
backgroundColor: "#FEE2E2",
|
||||
color: "#991B1B",
|
||||
} as Style,
|
||||
statusDeclined: {
|
||||
backgroundColor: "#FEE2E2",
|
||||
color: "#991B1B",
|
||||
} as Style,
|
||||
invoiceTypeContainer: {
|
||||
alignItems: "flex-end",
|
||||
marginBottom: 16,
|
||||
} as Style,
|
||||
invoiceType: {
|
||||
fontSize: 36,
|
||||
fontWeight: "normal",
|
||||
color: "#9CA3AF",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 6,
|
||||
lineHeight: 1,
|
||||
} as Style,
|
||||
metaContainer: {
|
||||
alignItems: "flex-end",
|
||||
marginTop: 4,
|
||||
} as Style,
|
||||
metaRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
marginBottom: 5,
|
||||
minWidth: 220,
|
||||
} as Style,
|
||||
metaLabel: {
|
||||
color: "#6B7280",
|
||||
fontSize: 9,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
marginRight: 12,
|
||||
width: 70,
|
||||
textAlign: "right",
|
||||
} as Style,
|
||||
metaValue: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
fontSize: 10,
|
||||
color: "#1F2937",
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
} as Style,
|
||||
billToSection: {
|
||||
marginBottom: 40,
|
||||
} as Style,
|
||||
sectionLabel: {
|
||||
fontSize: 9,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1.5,
|
||||
color: "#9CA3AF",
|
||||
marginBottom: 12,
|
||||
} as Style,
|
||||
clientName: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 4,
|
||||
color: "#1F2937",
|
||||
} as Style,
|
||||
clientEmail: {
|
||||
fontSize: 10,
|
||||
color: "#6B7280",
|
||||
} as Style,
|
||||
table: {
|
||||
marginBottom: 40,
|
||||
} as Style,
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#F9FAFB",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
} as Style,
|
||||
tableHeaderCell: {
|
||||
fontSize: 9,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: "#6B7280",
|
||||
} as Style,
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#F3F4F6",
|
||||
} as Style,
|
||||
tableCell: {
|
||||
fontSize: 10,
|
||||
color: "#1F2937",
|
||||
} as Style,
|
||||
colDescription: {
|
||||
flex: 3,
|
||||
paddingRight: 16,
|
||||
} as Style,
|
||||
colQty: {
|
||||
width: 60,
|
||||
textAlign: "center",
|
||||
} as Style,
|
||||
colPrice: {
|
||||
width: 90,
|
||||
textAlign: "right",
|
||||
paddingRight: 16,
|
||||
} as Style,
|
||||
colAmount: {
|
||||
width: 100,
|
||||
textAlign: "right",
|
||||
fontFamily: "Helvetica-Bold",
|
||||
} as Style,
|
||||
totalsSection: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
marginTop: 20,
|
||||
marginBottom: 50,
|
||||
} as Style,
|
||||
totalsBox: {
|
||||
width: 280,
|
||||
backgroundColor: "#F9FAFB",
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
} as Style,
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 10,
|
||||
fontSize: 10,
|
||||
} as Style,
|
||||
totalLabel: {
|
||||
color: "#6B7280",
|
||||
fontSize: 10,
|
||||
} as Style,
|
||||
totalValue: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
color: "#1F2937",
|
||||
fontSize: 10,
|
||||
} as Style,
|
||||
divider: {
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: "#E5E7EB",
|
||||
marginVertical: 12,
|
||||
} as Style,
|
||||
grandTotalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingTop: 8,
|
||||
} as Style,
|
||||
grandTotalLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#1F2937",
|
||||
} as Style,
|
||||
grandTotalValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
color: "#2563EB",
|
||||
} as Style,
|
||||
notesSection: {
|
||||
marginTop: 30,
|
||||
paddingTop: 30,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: "#E5E7EB",
|
||||
} as Style,
|
||||
notesText: {
|
||||
fontSize: 9,
|
||||
color: "#6B7280",
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: "pre-wrap",
|
||||
} as Style,
|
||||
};
|
||||
|
||||
export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
const { invoice, items, client, organization } = props;
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: invoice.currency,
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string): Style => {
|
||||
const baseStyle = styles.statusBadge;
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return { ...baseStyle, ...styles.statusDraft };
|
||||
case "sent":
|
||||
return { ...baseStyle, ...styles.statusSent };
|
||||
case "paid":
|
||||
case "accepted":
|
||||
return { ...baseStyle, ...styles.statusPaid };
|
||||
case "void":
|
||||
case "declined":
|
||||
return { ...baseStyle, ...styles.statusVoid };
|
||||
default:
|
||||
return { ...baseStyle, ...styles.statusDraft };
|
||||
}
|
||||
};
|
||||
|
||||
return h(Document, [
|
||||
h(
|
||||
Page,
|
||||
{ size: "A4", style: styles.page },
|
||||
[
|
||||
// Header
|
||||
h(View, { style: styles.header }, [
|
||||
h(View, { style: styles.headerLeft }, [
|
||||
h(Text, { style: styles.organizationName }, organization.name),
|
||||
organization.street || organization.city
|
||||
? h(
|
||||
View,
|
||||
{ style: styles.organizationAddress },
|
||||
[
|
||||
organization.street ? h(Text, organization.street) : null,
|
||||
organization.city || organization.state || organization.zip
|
||||
? h(
|
||||
Text,
|
||||
[
|
||||
organization.city,
|
||||
organization.state,
|
||||
organization.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
)
|
||||
: null,
|
||||
organization.country ? h(Text, organization.country) : null,
|
||||
].filter(Boolean),
|
||||
)
|
||||
: null,
|
||||
h(View, { style: getStatusStyle(invoice.status) }, [
|
||||
h(Text, invoice.status),
|
||||
]),
|
||||
]),
|
||||
h(View, { style: styles.headerRight }, [
|
||||
h(View, { style: styles.invoiceTypeContainer }, [
|
||||
h(Text, { style: styles.invoiceType }, invoice.type),
|
||||
]),
|
||||
h(View, { style: styles.metaContainer }, [
|
||||
h(View, { style: styles.metaRow }, [
|
||||
h(Text, { style: styles.metaLabel }, "Number"),
|
||||
h(Text, { style: styles.metaValue }, invoice.number),
|
||||
]),
|
||||
h(View, { style: styles.metaRow }, [
|
||||
h(Text, { style: styles.metaLabel }, "Date"),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.metaValue },
|
||||
formatDate(invoice.issueDate),
|
||||
),
|
||||
]),
|
||||
h(View, { style: styles.metaRow }, [
|
||||
h(Text, { style: styles.metaLabel }, "Due Date"),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.metaValue },
|
||||
formatDate(invoice.dueDate),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Bill To
|
||||
h(
|
||||
View,
|
||||
{ style: styles.billToSection },
|
||||
[
|
||||
h(Text, { style: styles.sectionLabel }, "Bill To"),
|
||||
h(Text, { style: styles.clientName }, client.name),
|
||||
client.email
|
||||
? h(Text, { style: styles.clientEmail }, client.email)
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
),
|
||||
|
||||
// Items Table
|
||||
h(View, { style: styles.table }, [
|
||||
h(View, { style: styles.tableHeader }, [
|
||||
h(
|
||||
Text,
|
||||
{
|
||||
style: { ...styles.tableHeaderCell, ...styles.colDescription },
|
||||
},
|
||||
"Description",
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableHeaderCell, ...styles.colQty } },
|
||||
"Qty",
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableHeaderCell, ...styles.colPrice } },
|
||||
"Unit Price",
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableHeaderCell, ...styles.colAmount } },
|
||||
"Amount",
|
||||
),
|
||||
]),
|
||||
...items.map((item) =>
|
||||
h(View, { key: item.id, style: styles.tableRow }, [
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableCell, ...styles.colDescription } },
|
||||
item.description,
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableCell, ...styles.colQty } },
|
||||
item.quantity.toString(),
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableCell, ...styles.colPrice } },
|
||||
formatCurrency(item.unitPrice),
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: { ...styles.tableCell, ...styles.colAmount } },
|
||||
formatCurrency(item.amount),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
||||
// Totals
|
||||
h(View, { style: styles.totalsSection }, [
|
||||
h(
|
||||
View,
|
||||
{ style: styles.totalsBox },
|
||||
[
|
||||
h(View, { style: styles.totalRow }, [
|
||||
h(Text, { style: styles.totalLabel }, "Subtotal"),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalValue },
|
||||
formatCurrency(invoice.subtotal),
|
||||
),
|
||||
]),
|
||||
(invoice.taxRate ?? 0) > 0
|
||||
? h(View, { style: styles.totalRow }, [
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalLabel },
|
||||
`Tax (${invoice.taxRate}%)`,
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalValue },
|
||||
formatCurrency(invoice.taxAmount),
|
||||
),
|
||||
])
|
||||
: null,
|
||||
h(View, { style: styles.divider }),
|
||||
h(View, { style: styles.grandTotalRow }, [
|
||||
h(Text, { style: styles.grandTotalLabel }, "Total"),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.grandTotalValue },
|
||||
formatCurrency(invoice.total),
|
||||
),
|
||||
]),
|
||||
].filter(Boolean),
|
||||
),
|
||||
]),
|
||||
|
||||
// Notes
|
||||
invoice.notes
|
||||
? h(View, { style: styles.notesSection }, [
|
||||
h(Text, { style: styles.sectionLabel }, "Notes"),
|
||||
h(Text, { style: styles.notesText }, invoice.notes),
|
||||
])
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
),
|
||||
]);
|
||||
}
|
||||
44
src/utils/invoice.ts
Normal file
44
src/utils/invoice.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { db } from "../db";
|
||||
import { invoices, invoiceItems } from "../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Recalculates the subtotal, tax amount, and total for a specific invoice
|
||||
* based on its items and tax rate.
|
||||
*/
|
||||
export async function recalculateInvoiceTotals(invoiceId: string) {
|
||||
// Fetch invoice to get tax rate
|
||||
const invoice = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.get();
|
||||
|
||||
if (!invoice) return;
|
||||
|
||||
// Fetch all items
|
||||
const items = await db
|
||||
.select()
|
||||
.from(invoiceItems)
|
||||
.where(eq(invoiceItems.invoiceId, invoiceId))
|
||||
.all();
|
||||
|
||||
// Calculate totals
|
||||
// Note: amounts are in cents
|
||||
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
||||
|
||||
const taxRate = invoice.taxRate || 0;
|
||||
const taxAmount = Math.round(subtotal * (taxRate / 100));
|
||||
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
// Update invoice
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
subtotal,
|
||||
taxAmount,
|
||||
total,
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
Reference in New Issue
Block a user