This commit is contained in:
@@ -17,6 +17,8 @@ WORKDIR /app
|
|||||||
RUN npm i -g pnpm
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
RUN pnpm install --prod
|
RUN pnpm install --prod
|
||||||
@@ -28,4 +30,4 @@ ENV PORT=4321
|
|||||||
ENV DATABASE_URL=/app/data/chronus.db
|
ENV DATABASE_URL=/app/data/chronus.db
|
||||||
EXPOSE 4321
|
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",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"migrate": "node scripts/migrate.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/node": "^9.5.2",
|
"@astrojs/node": "^9.5.2",
|
||||||
"@astrojs/vue": "^5.1.4",
|
"@astrojs/vue": "^5.1.4",
|
||||||
|
"@ceereals/vue-pdf": "^0.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^5.16.11",
|
"astro": "^5.16.11",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@catppuccin/daisyui": "^2.1.1",
|
"@catppuccin/daisyui": "^2.1.1",
|
||||||
"@iconify-json/heroicons": "^1.2.3",
|
"@iconify-json/heroicons": "^1.2.3",
|
||||||
|
"@react-pdf/types": "^2.9.2",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"drizzle-kit": "0.31.8"
|
"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,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
integer,
|
integer,
|
||||||
|
real,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
foreignKey,
|
foreignKey,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
@@ -25,6 +26,11 @@ export const organizations = sqliteTable("organizations", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
name: text("name").notNull(),
|
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(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => 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" />
|
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||||
Time Tracker
|
Time Tracker
|
||||||
</a></li>
|
</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={[
|
<li><a href="/dashboard/reports" class:list={[
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
"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") }
|
{ "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 formData = await request.formData();
|
||||||
const organizationId = formData.get("organizationId") as string;
|
const organizationId = formData.get("organizationId") as string;
|
||||||
const name = formData.get("name") 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) {
|
if (!organizationId || !name || name.trim().length === 0) {
|
||||||
return new Response("Organization ID and name are required", {
|
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
|
await db
|
||||||
.update(organizations)
|
.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))
|
.where(eq(organizations.id, organizationId))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
return redirect("/dashboard/team/settings?success=org-name");
|
return redirect("/dashboard/team/settings?success=org-name");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating organization name:", error);
|
console.error("Error updating organization:", error);
|
||||||
return new Response("Failed to update organization name", { status: 500 });
|
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 ClientChart from '../../components/ClientChart.vue';
|
||||||
import MemberChart from '../../components/MemberChart.vue';
|
import MemberChart from '../../components/MemberChart.vue';
|
||||||
import { db } from '../../db';
|
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 { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const userMemberships = await db.select()
|
|||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
// Use current team or fallback to first membership
|
||||||
const userMembership = currentTeamId
|
const userMembership = currentTeamId
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||||
: userMemberships[0];
|
: userMemberships[0];
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ const statsByMember = teamMembers.map(member => {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
member,
|
member,
|
||||||
totalTime,
|
totalTime,
|
||||||
@@ -136,7 +136,7 @@ const statsByCategory = allCategories.map(category => {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
category,
|
category,
|
||||||
totalTime,
|
totalTime,
|
||||||
@@ -152,7 +152,7 @@ const statsByClient = allClients.map(client => {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
totalTime,
|
totalTime,
|
||||||
@@ -167,6 +167,81 @@ const totalTime = entries.reduce((sum, e) => {
|
|||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 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) {
|
function getTimeRangeLabel(range: string) {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': return 'Today';
|
case 'today': return 'Today';
|
||||||
@@ -247,7 +322,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- 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="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-primary">
|
<div class="stat-figure text-primary">
|
||||||
@@ -270,6 +345,17 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
</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="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-accent">
|
<div class="stat-figure text-accent">
|
||||||
@@ -282,6 +368,121 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Charts Section - Only show if there's data */}
|
||||||
{totalTime > 0 && (
|
{totalTime > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -295,8 +496,8 @@ function getTimeRangeLabel(range: string) {
|
|||||||
Category Distribution
|
Category Distribution
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<CategoryChart
|
<CategoryChart
|
||||||
client:load
|
client:load
|
||||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.category.name,
|
name: s.category.name,
|
||||||
totalTime: s.totalTime,
|
totalTime: s.totalTime,
|
||||||
@@ -317,8 +518,8 @@ function getTimeRangeLabel(range: string) {
|
|||||||
Time by Client
|
Time by Client
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<ClientChart
|
<ClientChart
|
||||||
client:load
|
client:load
|
||||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.client.name,
|
name: s.client.name,
|
||||||
totalTime: s.totalTime
|
totalTime: s.totalTime
|
||||||
@@ -339,8 +540,8 @@ function getTimeRangeLabel(range: string) {
|
|||||||
Time by Team Member
|
Time by Team Member
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<MemberChart
|
<MemberChart
|
||||||
client:load
|
client:load
|
||||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.member.name,
|
name: s.member.name,
|
||||||
totalTime: s.totalTime
|
totalTime: s.totalTime
|
||||||
@@ -427,9 +628,9 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<td>{stat.entryCount}</td>
|
<td>{stat.entryCount}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<progress
|
<progress
|
||||||
class="progress progress-primary w-20"
|
class="progress progress-primary w-20"
|
||||||
value={stat.totalTime}
|
value={stat.totalTime}
|
||||||
max={totalTime}
|
max={totalTime}
|
||||||
></progress>
|
></progress>
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
@@ -472,9 +673,9 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<td>{stat.entryCount}</td>
|
<td>{stat.entryCount}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<progress
|
<progress
|
||||||
class="progress progress-secondary w-20"
|
class="progress progress-secondary w-20"
|
||||||
value={stat.totalTime}
|
value={stat.totalTime}
|
||||||
max={totalTime}
|
max={totalTime}
|
||||||
></progress>
|
></progress>
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
@@ -532,7 +733,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</td>
|
</td>
|
||||||
<td>{e.entry.description || '-'}</td>
|
<td>{e.entry.description || '-'}</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{e.entry.endTime
|
{e.entry.endTime
|
||||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||||
: 'Running...'
|
: 'Running...'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const successType = url.searchParams.get('success');
|
|||||||
{successType === 'org-name' && (
|
{successType === 'org-name' && (
|
||||||
<div class="alert alert-success mb-4">
|
<div class="alert alert-success mb-4">
|
||||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||||
<span>Team name updated successfully!</span>
|
<span>Team information updated successfully!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -87,6 +87,83 @@ const successType = url.searchParams.get('success');
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</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">
|
<div class="flex justify-end">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
<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