Compare commits

2 Commits

Author SHA1 Message Date
5aa9388678 Trying this...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-16 17:24:50 -07:00
15b903f1af Adding manual entries + UI cleanup 2026-01-16 16:28:06 -07:00
35 changed files with 5170 additions and 167 deletions

View File

@@ -17,6 +17,8 @@ WORKDIR /app
RUN npm i -g pnpm
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/scripts ./scripts
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod
@@ -28,4 +30,4 @@ ENV PORT=4321
ENV DATABASE_URL=/app/data/chronus.db
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]
CMD ["sh", "-c", "pnpm run migrate && node ./dist/server/entry.mjs"]

View 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`);

View 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": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1768609277648,
"tag": "0000_mixed_morlocks",
"breakpoints": true
}
]
}

View File

@@ -8,12 +8,14 @@
"preview": "astro preview",
"astro": "astro",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"migrate": "node scripts/migrate.js"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.2",
"@astrojs/vue": "^5.1.4",
"@ceereals/vue-pdf": "^0.2.1",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.11",
"astro-icon": "^1.1.5",
@@ -31,6 +33,7 @@
"devDependencies": {
"@catppuccin/daisyui": "^2.1.1",
"@iconify-json/heroicons": "^1.2.3",
"@react-pdf/types": "^2.9.2",
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "0.31.8"
}

759
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

45
scripts/migrate.js Normal file
View 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();

View File

@@ -0,0 +1,15 @@
---
interface Props {
name: string;
class?: string;
}
const { name, class: className } = Astro.props;
const initial = name ? name.charAt(0).toUpperCase() : '?';
---
<div class:list={["avatar placeholder", className]}>
<div class="bg-primary text-primary-content w-10 rounded-full flex items-center justify-center">
<span class="text-lg font-semibold">{initial}</span>
</div>
</div>

View File

@@ -0,0 +1,369 @@
<script setup lang="ts">
import { ref } from "vue";
const props = defineProps<{
clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[];
}>();
const emit = defineEmits<{
(e: "entryCreated"): void;
}>();
const description = ref("");
const selectedClientId = ref("");
const selectedCategoryId = ref("");
const selectedTags = ref<string[]>([]);
const startDate = ref("");
const startTime = ref("");
const endDate = ref("");
const endTime = ref("");
const isSubmitting = ref(false);
const error = ref("");
const success = ref(false);
// Set default dates to today
const today = new Date().toISOString().split("T")[0];
startDate.value = today;
endDate.value = today;
function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId);
if (index > -1) {
selectedTags.value.splice(index, 1);
} else {
selectedTags.value.push(tagId);
}
}
function formatDuration(start: Date, end: Date): string {
const ms = end.getTime() - start.getTime();
const totalMinutes = Math.round(ms / 1000 / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
return `${minutes}m`;
}
function validateForm(): string | null {
if (!selectedClientId.value) {
return "Please select a client";
}
if (!selectedCategoryId.value) {
return "Please select a category";
}
if (!startDate.value || !startTime.value) {
return "Please enter start date and time";
}
if (!endDate.value || !endTime.value) {
return "Please enter end date and time";
}
const start = new Date(`${startDate.value}T${startTime.value}`);
const end = new Date(`${endDate.value}T${endTime.value}`);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return "Invalid date or time format";
}
if (end <= start) {
return "End time must be after start time";
}
return null;
}
async function submitManualEntry() {
error.value = "";
success.value = false;
const validationError = validateForm();
if (validationError) {
error.value = validationError;
return;
}
isSubmitting.value = true;
try {
const startDateTime = `${startDate.value}T${startTime.value}`;
const endDateTime = `${endDate.value}T${endTime.value}`;
const res = await fetch("/api/time-entries/manual", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
startTime: startDateTime,
endTime: endDateTime,
tags: selectedTags.value,
}),
});
const data = await res.json();
if (res.ok) {
success.value = true;
// Calculate duration for success message
const start = new Date(startDateTime);
const end = new Date(endDateTime);
const duration = formatDuration(start, end);
// Reset form
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
startDate.value = today;
endDate.value = today;
startTime.value = "";
endTime.value = "";
// Emit event and reload after a short delay
setTimeout(() => {
emit("entryCreated");
window.location.reload();
}, 1500);
} else {
error.value = data.error || "Failed to create time entry";
}
} catch (err) {
error.value = "An error occurred. Please try again.";
console.error("Error creating manual entry:", err);
} finally {
isSubmitting.value = false;
}
}
function clearForm() {
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
startDate.value = today;
endDate.value = today;
startTime.value = "";
endTime.value = "";
error.value = "";
success.value = false;
}
</script>
<template>
<div
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200"
>
<div class="card-body gap-6">
<div class="flex justify-between items-center">
<h3 class="text-xl font-semibold">Add Manual Entry</h3>
<button
type="button"
@click="clearForm"
class="btn btn-ghost btn-sm"
:disabled="isSubmitting"
>
Clear
</button>
</div>
<!-- Success Message -->
<div v-if="success" class="alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Manual time entry created successfully!</span>
</div>
<!-- Error Message -->
<div v-if="error" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{{ error }}</span>
</div>
<!-- Client and Category Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Client</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
v-model="selectedClientId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
>
<option value="">Select a client...</option>
<option
v-for="client in clients"
:key="client.id"
:value="client.id"
>
{{ client.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Category</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
v-model="selectedCategoryId"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
>
<option value="">Select a category...</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
</div>
</div>
<!-- Start Date and Time -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Start Date</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
v-model="startDate"
type="date"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Start Time</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
v-model="startTime"
type="time"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
</div>
<!-- End Date and Time -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">End Date</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
v-model="endDate"
type="date"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">End Time</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
v-model="endTime"
type="time"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
</div>
<!-- Description Row -->
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Description</span>
</label>
<input
v-model="description"
type="text"
placeholder="What did you work on?"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
<!-- Tags Section -->
<div v-if="tags.length > 0" class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Tags</span>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in tags"
:key="tag.id"
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTags.includes(tag.id)
? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50',
]"
:disabled="isSubmitting"
type="button"
>
{{ tag.name }}
</button>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-4 pt-4">
<button
@click="submitManualEntry"
class="btn btn-primary flex-1 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
:disabled="isSubmitting"
>
<span v-if="isSubmitting" class="loading loading-spinner"></span>
{{ isSubmitting ? "Creating..." : "Add Manual Entry" }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -127,7 +127,9 @@ async function stopTimer() {
</script>
<template>
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<div
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200"
>
<div class="card-body gap-6">
<!-- Client and Description Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -137,7 +139,7 @@ async function stopTimer() {
</label>
<select
v-model="selectedClientId"
class="select select-bordered w-full"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isRunning"
>
<option value="">Select a client...</option>
@@ -157,7 +159,7 @@ async function stopTimer() {
</label>
<select
v-model="selectedCategoryId"
class="select select-bordered w-full"
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isRunning"
>
<option value="">Select a category...</option>
@@ -181,7 +183,7 @@ async function stopTimer() {
v-model="description"
type="text"
placeholder="What are you working on?"
class="input input-bordered w-full"
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
:disabled="isRunning"
/>
</div>
@@ -197,8 +199,10 @@ async function stopTimer() {
:key="tag.id"
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all',
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline',
'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTags.includes(tag.id)
? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50',
]"
:disabled="isRunning"
type="button"
@@ -211,18 +215,22 @@ async function stopTimer() {
<!-- Timer and Action Row -->
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
<div
class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left grow"
class="font-mono text-5xl font-bold tabular-nums tracking-tight text-center sm:text-left grow text-primary"
>
{{ formatTime(elapsedTime) }}
</div>
<button
v-if="!isRunning"
@click="startTimer"
class="btn btn-primary btn-lg min-w-40"
class="btn btn-primary btn-lg min-w-40 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
>
Start Timer
</button>
<button v-else @click="stopTimer" class="btn btn-error btn-lg min-w-40">
<button
v-else
@click="stopTimer"
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
>
Stop Timer
</button>
</div>

View File

@@ -2,6 +2,7 @@ import {
sqliteTable,
text,
integer,
real,
primaryKey,
foreignKey,
} from "drizzle-orm/sqlite-core";
@@ -25,6 +26,11 @@ export const organizations = sqliteTable("organizations", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
street: text("street"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -108,6 +114,7 @@ export const timeEntries = sqliteTable(
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
endTime: integer("end_time", { mode: "timestamp" }),
description: text("description"),
isManual: integer("is_manual", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -220,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],
}),
}),
);

View File

@@ -5,6 +5,8 @@ import { db } from '../db';
import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm';
import Footer from '../components/Footer.astro';
import Avatar from '../components/Avatar.astro';
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@@ -41,13 +43,14 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
</head>
<body class="bg-linear-to-br from-base-100 via-base-200 to-base-100 h-screen flex flex-col overflow-hidden">
<body class="bg-base-100 h-screen flex flex-col overflow-hidden">
<div class="drawer lg:drawer-open flex-1 overflow-auto">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-auto">
<!-- Navbar -->
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-300">
<div class="navbar bg-base-200/50 backdrop-blur-sm sticky top-0 z-50 lg:hidden border-b border-base-300/50">
<div class="flex-none lg:hidden">
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
<Icon name="heroicons:bars-3" class="w-6 h-6" />
@@ -66,10 +69,10 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</div>
<div class="drawer-side z-50">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 min-h-full w-80 p-4">
<ul class="menu bg-base-200/95 backdrop-blur-sm min-h-full w-80 p-4 border-r border-base-300/30">
<!-- Sidebar content here -->
<li class="mb-6">
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary">
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
Chronus
</a>
@@ -80,7 +83,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<li class="mb-4">
<div class="form-control">
<select
class="select select-bordered w-full font-semibold"
class="select select-bordered w-full font-semibold bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary focus:outline-none focus:outline-offset-0 transition-all duration-200 hover:border-primary/40 focus:ring-3 focus:ring-primary/15 [&>option]:bg-base-300 [&>option]:text-base-content [&>option]:p-2"
id="team-switcher"
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
>
@@ -108,57 +111,78 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<div class="divider my-2"></div>
<li><a href="/dashboard">
<li><a href="/dashboard" 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 === "/dashboard" }
]}>
<Icon name="heroicons:home" class="w-5 h-5" />
Dashboard
</a></li>
<li><a href="/dashboard/tracker">
<li><a href="/dashboard/tracker" 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/tracker") }
]}>
<Icon name="heroicons:clock" class="w-5 h-5" />
Time Tracker
</a></li>
<li><a href="/dashboard/reports">
<li><a href="/dashboard/invoices" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/invoices") }
]}>
<Icon name="heroicons:document-currency-dollar" class="w-5 h-5" />
Invoices & Quotes
</a></li>
<li><a href="/dashboard/reports" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/reports") }
]}>
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
Reports
</a></li>
<li><a href="/dashboard/clients">
<li><a href="/dashboard/clients" 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/clients") }
]}>
<Icon name="heroicons:building-office" class="w-5 h-5" />
Clients
</a></li>
<li><a href="/dashboard/team">
<li><a href="/dashboard/team" 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/team") }
]}>
<Icon name="heroicons:user-group" class="w-5 h-5" />
Team
</a></li>
{user.isSiteAdmin && (
<>
<div class="divider"></div>
<li><a href="/admin" class="font-semibold">
<div class="divider my-2"></div>
<li><a href="/admin" class:list={[
"font-semibold 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("/admin") }
]}>
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
Site Admin
</a></li>
</>
)}
<div class="divider"></div>
<div class="divider my-2"></div>
<li>
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3">
<div class="avatar placeholder">
<div class="bg-linear-to-br from-primary via-secondary to-accent text-primary-content rounded-full w-10 ring ring-primary ring-offset-base-100 ring-offset-2">
<span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span>
</div>
</div>
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-300/30 hover:bg-base-300/60 rounded-lg p-3 transition-colors">
<Avatar name={user.name} />
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm truncate">{user.name}</div>
<div class="text-xs text-base-content/60 truncate">{user.email}</div>
<div class="text-xs text-base-content/50 truncate">{user.email}</div>
</div>
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-50" />
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-40" />
</a>
</li>
<li>
<form action="/api/auth/logout" method="POST">
<button type="submit" class="w-full text-error hover:bg-error/10">
<button type="submit" class="w-full text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
Logout
</button>

View File

@@ -1,6 +1,7 @@
---
import '../styles/global.css';
import Footer from '../components/Footer.astro';
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@@ -18,6 +19,7 @@ const { title } = Astro.props;
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
</head>
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
<div class="flex-1 overflow-auto">

View File

@@ -1,5 +1,6 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { db } from '../../db';
import { siteSettings, users } from '../../db/schema';
import { eq } from 'drizzle-orm';
@@ -79,11 +80,7 @@ const allUsers = await db.select().from(users).all();
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span>{u.name.charAt(0)}</span>
</div>
</div>
<Avatar name={u.name} />
<div class="font-bold">{u.name}</div>
</div>
</td>

View 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 });
}
};

View 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 });
}
};

View 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 });
}
};

View 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 });
}
};

View 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 });
}
};

View 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 });
}
};

View 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 });
}
};

View File

@@ -12,6 +12,11 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
const formData = await request.formData();
const organizationId = formData.get("organizationId") as string;
const name = formData.get("name") as string;
const street = formData.get("street") as string | null;
const city = formData.get("city") as string | null;
const state = formData.get("state") as string | null;
const zip = formData.get("zip") as string | null;
const country = formData.get("country") as string | null;
if (!organizationId || !name || name.trim().length === 0) {
return new Response("Organization ID and name are required", {
@@ -44,16 +49,23 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
);
}
// Update organization name
// Update organization information
await db
.update(organizations)
.set({ name: name.trim() })
.set({
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
})
.where(eq(organizations.id, organizationId))
.run();
return redirect("/dashboard/team/settings?success=org-name");
} catch (error) {
console.error("Error updating organization name:", error);
return new Response("Failed to update organization name", { status: 500 });
console.error("Error updating organization:", error);
return new Response("Failed to update organization", { status: 500 });
}
};

View File

@@ -0,0 +1,195 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
}
);
}
const body = await request.json();
const { description, clientId, categoryId, startTime, endTime, tags } = body;
// Validation
if (!clientId) {
return new Response(
JSON.stringify({ error: 'Client is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
if (!categoryId) {
return new Response(
JSON.stringify({ error: 'Category is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
if (!startTime) {
return new Response(
JSON.stringify({ error: 'Start time is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
if (!endTime) {
return new Response(
JSON.stringify({ error: 'End time is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
const startDate = new Date(startTime);
const endDate = new Date(endTime);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return new Response(
JSON.stringify({ error: 'Invalid date format' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
if (endDate <= startDate) {
return new Response(
JSON.stringify({ error: 'End time must be after start time' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Get user's organization
const member = await db
.select()
.from(members)
.where(eq(members.userId, locals.user.id))
.limit(1)
.get();
if (!member) {
return new Response(
JSON.stringify({ error: 'No organization found' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Verify category belongs to organization
const category = await db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
)
)
.get();
if (!category) {
return new Response(
JSON.stringify({ error: 'Invalid category' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Verify client belongs to organization
const client = await db
.select()
.from(clients)
.where(
and(
eq(clients.id, clientId),
eq(clients.organizationId, member.organizationId)
)
)
.get();
if (!client) {
return new Response(
JSON.stringify({ error: 'Invalid client' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
const id = nanoid();
try {
// Insert the manual time entry
await db.insert(timeEntries).values({
id,
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
startTime: startDate,
endTime: endDate,
description: description || null,
isManual: true,
});
// Insert tags if provided
if (tags && Array.isArray(tags) && tags.length > 0) {
await db.insert(timeEntryTags).values(
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
}))
);
}
return new Response(
JSON.stringify({
success: true,
id,
startTime: startDate.toISOString(),
endTime: endDate.toISOString(),
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' }
}
);
} catch (error) {
console.error('Error creating manual time entry:', error);
return new Response(
JSON.stringify({ error: 'Failed to create time entry' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
};

View File

@@ -1,51 +1,66 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
import { eq, and, isNull } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
timeEntries,
members,
timeEntryTags,
categories,
} from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!locals.user) return new Response("Unauthorized", { status: 401 });
const body = await request.json();
const description = body.description || '';
const description = body.description || "";
const clientId = body.clientId;
const categoryId = body.categoryId;
const tags = body.tags || [];
if (!clientId) {
return new Response('Client is required', { status: 400 });
return new Response("Client is required", { status: 400 });
}
if (!categoryId) {
return new Response('Category is required', { status: 400 });
return new Response("Category is required", { status: 400 });
}
const runningEntry = await db.select().from(timeEntries).where(
and(
eq(timeEntries.userId, locals.user.id),
isNull(timeEntries.endTime)
const runningEntry = await db
.select()
.from(timeEntries)
.where(
and(eq(timeEntries.userId, locals.user.id), isNull(timeEntries.endTime)),
)
).get();
.get();
if (runningEntry) {
return new Response('Timer already running', { status: 400 });
return new Response("Timer already running", { status: 400 });
}
const member = await db.select().from(members).where(eq(members.userId, locals.user.id)).limit(1).get();
const member = await db
.select()
.from(members)
.where(eq(members.userId, locals.user.id))
.limit(1)
.get();
if (!member) {
return new Response('No organization found', { status: 400 });
return new Response("No organization found", { status: 400 });
}
const category = await db.select().from(categories).where(
const category = await db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
eq(categories.organizationId, member.organizationId),
),
)
).get();
.get();
if (!category) {
return new Response('Invalid category', { status: 400 });
return new Response("Invalid category", { status: 400 });
}
const startTime = new Date();
@@ -59,6 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
categoryId,
startTime,
description,
isManual: false,
});
if (tags.length > 0) {
@@ -66,7 +82,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
}))
})),
);
}

View 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>

View 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>

View 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>

View 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>

View File

@@ -5,7 +5,7 @@ import CategoryChart from '../../components/CategoryChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, categories } from '../../db/schema';
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
@@ -167,6 +167,81 @@ const totalTime = entries.reduce((sum, e) => {
return sum;
}, 0);
// Fetch invoices and quotes for the same time period
const invoiceConditions = [
eq(invoices.organizationId, userMembership.organizationId),
gte(invoices.issueDate, startDate),
lte(invoices.issueDate, endDate),
];
if (selectedClientId) {
invoiceConditions.push(eq(invoices.clientId, selectedClientId));
}
const allInvoices = await db.select({
invoice: invoices,
client: clients,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(and(...invoiceConditions))
.orderBy(desc(invoices.issueDate))
.all();
// Invoice statistics
const invoiceStats = {
total: allInvoices.filter(i => i.invoice.type === 'invoice').length,
paid: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid').length,
sent: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent').length,
draft: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'draft').length,
void: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'void').length,
};
// Quote statistics
const quoteStats = {
total: allInvoices.filter(i => i.invoice.type === 'quote').length,
accepted: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'accepted').length,
sent: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length,
declined: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'declined').length,
draft: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'draft').length,
};
// Revenue statistics
const revenueStats = {
total: allInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((sum, i) => sum + i.invoice.total, 0),
pending: allInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent')
.reduce((sum, i) => sum + i.invoice.total, 0),
quotedValue: allInvoices
.filter(i => i.invoice.type === 'quote' && (i.invoice.status === 'sent' || i.invoice.status === 'accepted'))
.reduce((sum, i) => sum + i.invoice.total, 0),
};
// Revenue by client
const revenueByClient = allClients.map(client => {
const clientInvoices = allInvoices.filter(i =>
i.client?.id === client.id &&
i.invoice.type === 'invoice' &&
i.invoice.status === 'paid'
);
const revenue = clientInvoices.reduce((sum, i) => sum + i.invoice.total, 0);
return {
client,
revenue,
invoiceCount: clientInvoices.length,
};
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
function formatCurrency(amount: number, currency: string = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
}
function getTimeRangeLabel(range: string) {
switch (range) {
case 'today': return 'Today';
@@ -247,7 +322,7 @@ function getTimeRangeLabel(range: string) {
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-primary">
@@ -270,6 +345,17 @@ function getTimeRangeLabel(range: string) {
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
</div>
<div class="stat-title">Revenue</div>
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-accent">
@@ -282,6 +368,121 @@ function getTimeRangeLabel(range: string) {
</div>
</div>
<!-- Invoice & Quote Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:document-text" class="w-6 h-6" />
Invoices Overview
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Total Invoices</div>
<div class="stat-value text-2xl">{invoiceStats.total}</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<div class="stat-title text-xs">Paid</div>
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
</div>
<div class="stat bg-info/10 rounded-lg">
<div class="stat-title text-xs">Sent</div>
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
</div>
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Draft</div>
<div class="stat-value text-2xl">{invoiceStats.draft}</div>
</div>
</div>
<div class="divider my-2"></div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Revenue (Paid)</span>
<span class="font-bold text-success">{formatCurrency(revenueStats.total)}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Pending (Sent)</span>
<span class="font-bold text-warning">{formatCurrency(revenueStats.pending)}</span>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:clipboard-document-list" class="w-6 h-6" />
Quotes Overview
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Total Quotes</div>
<div class="stat-value text-2xl">{quoteStats.total}</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<div class="stat-title text-xs">Accepted</div>
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
</div>
<div class="stat bg-info/10 rounded-lg">
<div class="stat-title text-xs">Pending</div>
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
</div>
<div class="stat bg-error/10 rounded-lg">
<div class="stat-title text-xs">Declined</div>
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
</div>
</div>
<div class="divider my-2"></div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Quoted Value</span>
<span class="font-bold">{formatCurrency(revenueStats.quotedValue)}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Conversion Rate</span>
<span class="font-bold">
{quoteStats.total > 0 ? Math.round((quoteStats.accepted / quoteStats.total) * 100) : 0}%
</span>
</div>
</div>
</div>
</div>
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
{!selectedClientId && revenueByClient.length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:banknotes" class="w-6 h-6" />
Revenue by Client
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Client</th>
<th>Revenue</th>
<th>Invoices</th>
<th>Avg Invoice</th>
</tr>
</thead>
<tbody>
{revenueByClient.slice(0, 10).map(stat => (
<tr>
<td>
<div class="font-bold">{stat.client.name}</div>
</td>
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td>
<td>{stat.invoiceCount}</td>
<td class="font-mono">
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Charts Section - Only show if there's data */}
{totalTime > 0 && (
<>

View File

@@ -1,5 +1,6 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { members, users } from '../../db/schema';
@@ -70,11 +71,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
<tr>
<td>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span>{teamUser.name.charAt(0)}</span>
</div>
</div>
<Avatar name={teamUser.name} />
<div>
<div class="font-bold">{teamUser.name}</div>
{teamUser.id === user.id && (

View File

@@ -63,7 +63,7 @@ const successType = url.searchParams.get('success');
{successType === 'org-name' && (
<div class="alert alert-success mb-4">
<Icon name="heroicons:check-circle" class="w-6 h-6" />
<span>Team name updated successfully!</span>
<span>Team information updated successfully!</span>
</div>
)}
@@ -87,6 +87,83 @@ const successType = url.searchParams.get('success');
</div>
</label>
<div class="divider">Address Information</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Street Address</span>
</div>
<input
type="text"
name="street"
value={organization.street || ''}
placeholder="123 Main Street"
class="input input-bordered w-full"
/>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="form-control">
<div class="label">
<span class="label-text font-medium">City</span>
</div>
<input
type="text"
name="city"
value={organization.city || ''}
placeholder="City"
class="input input-bordered w-full"
/>
</label>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">State/Province</span>
</div>
<input
type="text"
name="state"
value={organization.state || ''}
placeholder="State/Province"
class="input input-bordered w-full"
/>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Postal Code</span>
</div>
<input
type="text"
name="zip"
value={organization.zip || ''}
placeholder="12345"
class="input input-bordered w-full"
/>
</label>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Country</span>
</div>
<input
type="text"
name="country"
value={organization.country || ''}
placeholder="Country"
class="input input-bordered w-full"
/>
</label>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
Address information appears on invoices and quotes
</span>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
<Icon name="heroicons:check" class="w-5 h-5" />

View File

@@ -2,6 +2,7 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue';
import { db } from '../../db';
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
@@ -51,6 +52,7 @@ const offset = (page - 1) * pageSize;
const filterClient = url.searchParams.get('client') || '';
const filterCategory = url.searchParams.get('category') || '';
const filterStatus = url.searchParams.get('status') || '';
const filterType = url.searchParams.get('type') || '';
const sortBy = url.searchParams.get('sort') || 'start-desc';
const searchTerm = url.searchParams.get('search') || '';
@@ -74,6 +76,12 @@ if (searchTerm) {
conditions.push(like(timeEntries.description, `%${searchTerm}%`));
}
if (filterType === 'manual') {
conditions.push(eq(timeEntries.isManual, true));
} else if (filterType === 'timed') {
conditions.push(eq(timeEntries.isManual, false));
}
const totalCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries)
.where(and(...conditions))
@@ -151,13 +159,17 @@ const paginationPages = getPaginationPages(page, totalPages);
<DashboardLayout title="Time Tracker - Chronus">
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry -->
<div role="tablist" class="tabs tabs-lifted mb-6">
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning mb-6">
<div class="alert alert-warning">
<span>You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning mb-6">
<div class="alert alert-warning">
<span>You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
</div>
@@ -175,11 +187,39 @@ const paginationPages = getPaginationPages(page, totalPages);
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
</div>
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a category before adding time entries.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
</div>
) : (
<ManualEntry
client:load
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
</div>
</div>
{allClients.length === 0 ? (
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
) : null}
<!-- Filters and Search -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<div class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200 mb-6">
<div class="card-body">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Search</span>
@@ -188,7 +228,7 @@ const paginationPages = getPaginationPages(page, totalPages);
type="text"
name="search"
placeholder="Search descriptions..."
class="input input-bordered"
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
value={searchTerm}
/>
</div>
@@ -197,7 +237,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Client</span>
</label>
<select name="client" class="select select-bordered" onchange="this.form.submit()">
<select name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}>
@@ -211,7 +251,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Category</span>
</label>
<select name="category" class="select select-bordered" onchange="this.form.submit()">
<select name="category" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<option value="">All Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={filterCategory === category.id}>
@@ -225,18 +265,29 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select name="status" class="select select-bordered" onchange="this.form.submit()">
<select name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<option value="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Entry Type</span>
</label>
<select name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<option value="" selected={filterType === ''}>All Types</option>
<option value="timed" selected={filterType === 'timed'}>Timed</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Sort By</span>
</label>
<select name="sort" class="select select-bordered" onchange="this.form.submit()">
<select name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
@@ -245,8 +296,8 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
<input type="hidden" name="page" value="1" />
<div class="form-control md:col-span-2 lg:col-span-5">
<button type="submit" class="btn btn-primary">
<div class="form-control md:col-span-2 lg:col-span-6">
<button type="submit" class="btn btn-primary shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all">
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
Search
</button>
@@ -255,24 +306,25 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card bg-base-200/30 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
Time Entries ({totalCount?.count || 0} total)
</h2>
{(filterClient || filterCategory || filterStatus || searchTerm) && (
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost">
{(filterClient || filterCategory || filterStatus || filterType || searchTerm) && (
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost hover:bg-base-300/50 transition-colors">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
Clear Filters
</a>
)}
</div>
<div class="overflow-x-auto">
<table class="table">
<table class="table table-zebra">
<thead>
<tr>
<tr class="bg-base-300/30">
<th>Type</th>
<th>Client</th>
<th>Category</th>
<th>Description</th>
@@ -285,17 +337,30 @@ const paginationPages = getPaginationPages(page, totalPages);
</thead>
<tbody>
{entries.map(({ entry, client, category, user: entryUser }) => (
<tr>
<td>{client?.name || 'Unknown'}</td>
<tr class="hover:bg-base-300/20 transition-colors">
<td>
{entry.isManual ? (
<span class="badge badge-info badge-sm gap-1 shadow-sm" title="Manual Entry">
<Icon name="heroicons:pencil" class="w-3 h-3" />
Manual
</span>
) : (
<span class="badge badge-success badge-sm gap-1 shadow-sm" title="Timed Entry">
<Icon name="heroicons:clock" class="w-3 h-3" />
Timed
</span>
)}
</td>
<td class="font-medium">{client?.name || 'Unknown'}</td>
<td>
{category ? (
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style={`background-color: ${category.color}`}></span>
<span class="w-3 h-3 rounded-full shadow-sm" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
</div>
) : '-'}
</td>
<td>{entry.description || '-'}</td>
<td class="text-base-content/80">{entry.description || '-'}</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap">
{entry.startTime.toLocaleDateString()}<br/>
@@ -312,15 +377,15 @@ const paginationPages = getPaginationPages(page, totalPages);
</span>
</>
) : (
<span class="badge badge-success">Running</span>
<span class="badge badge-success shadow-sm">Running</span>
)}
</td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td class="font-mono font-semibold text-primary">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button
type="submit"
class="btn btn-ghost btn-sm text-error"
class="btn btn-ghost btn-sm text-error hover:bg-error/10 transition-colors"
onclick="return confirm('Are you sure you want to delete this entry?')"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
@@ -337,8 +402,8 @@ const paginationPages = getPaginationPages(page, totalPages);
{totalPages > 1 && (
<div class="flex justify-center items-center gap-2 mt-6">
<a
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === 1 ? 'btn-disabled' : ''}`}
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === 1 ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
Previous
@@ -347,8 +412,8 @@ const paginationPages = getPaginationPages(page, totalPages);
<div class="flex gap-1">
{paginationPages.map(pageNum => (
<a
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === pageNum ? 'btn-active' : ''}`}
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`}
>
{pageNum}
</a>
@@ -356,8 +421,8 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
<a
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === totalPages ? 'btn-disabled' : ''}`}
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
>
Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" />

View 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),
),
]);
}

View File

@@ -1,7 +1,7 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin(
"mocha",
"macchiato",
{},
{
default: true,

44
src/utils/invoice.ts Normal file
View 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));
}