Compare commits
20 Commits
38fe0ea9ce
...
2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
3d4b8762e5
|
|||
|
5e70dd6bb8
|
|||
|
ce47de9e56
|
|||
|
db1d180afc
|
|||
|
82e1b8a626
|
|||
|
253c24c89b
|
|||
|
39c51b1115
|
|||
|
091766d6e4
|
|||
|
0cd77677f2
|
|||
|
3734b2693a
|
|||
|
996092d14e
|
|||
|
aae8693dd3
|
|||
|
bebc4b2743
|
|||
|
7026435cd3
|
|||
|
85750a5c79
|
|||
|
6aa4548a38
|
|||
|
42fbea6ae7
|
|||
|
c4ecc0b899
|
|||
|
5aa9388678
|
|||
|
15b903f1af
|
@@ -1,3 +1,4 @@
|
|||||||
HOST=0.0.0.0
|
DATA_DIR=./data
|
||||||
PORT=4321
|
ROOT_DIR=./data
|
||||||
DATABASE_URL=chronus.db
|
APP_PORT=4321
|
||||||
|
IMAGE=git.atri.dad/atash/chronus:latest
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
|
data/
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
|
|||||||
32
Dockerfile
32
Dockerfile
@@ -1,31 +1,35 @@
|
|||||||
FROM node:lts-alpine AS builder
|
FROM node:lts-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm i -g pnpm
|
RUN npm i -g pnpm
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
WORKDIR /app
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --prod --frozen-lockfile
|
||||||
|
|
||||||
RUN pnpm install
|
FROM base AS build-deps
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM build-deps AS builder
|
||||||
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM node:lts-alpine AS runtime
|
FROM base AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm i -g pnpm
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
RUN pnpm install --prod
|
COPY package.json ./
|
||||||
|
|
||||||
RUN mkdir -p /app/data
|
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
ENV DATABASE_URL=/app/data/chronus.db
|
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"]
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -1,2 +1,10 @@
|
|||||||
# Chronus
|
# Chronus
|
||||||
A modern time tracking application built with Astro, Vue, and DaisyUI.
|
A modern time tracking application built with Astro, Vue, and DaisyUI.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Framework: Astro
|
||||||
|
- Runtime: Node
|
||||||
|
- UI Library: Vue 3
|
||||||
|
- CSS and Styles: DaisyUI + Tailwind CSS
|
||||||
|
- Database: libSQL
|
||||||
|
- ORM: Drizzle ORM
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
import vue from '@astrojs/vue';
|
import vue from "@astrojs/vue";
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import icon from 'astro-icon';
|
import icon from "astro-icon";
|
||||||
|
|
||||||
import node from '@astrojs/node';
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: 'server',
|
output: "server",
|
||||||
integrations: [vue(), icon()],
|
integrations: [vue(), icon()],
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
ssr: {
|
|
||||||
external: ['better-sqlite3'],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: 'standalone',
|
mode: "standalone",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=4321
|
- PORT=4321
|
||||||
- DATABASE_URL=/app/data/chronus.db
|
- DATA_DIR=/app/data
|
||||||
volumes:
|
volumes:
|
||||||
- ${ROOT_DIR}:/app/data
|
- ${ROOT_DIR}:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { defineConfig } from 'drizzle-kit';
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR;
|
||||||
|
|
||||||
|
if (!dataDir) {
|
||||||
|
throw new Error("DATA_DIR environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUrl = `file:${path.join(dataDir, "chronus.db")}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: './src/db/schema.ts',
|
schema: "./src/db/schema.ts",
|
||||||
out: './drizzle',
|
out: "./drizzle",
|
||||||
dialect: 'sqlite',
|
dialect: "turso",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL || 'chronus.db',
|
url: dbUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
141
drizzle/0000_motionless_king_cobra.sql
Normal file
141
drizzle/0000_motionless_king_cobra.sql
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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,
|
||||||
|
`logo_url` text,
|
||||||
|
`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`);
|
||||||
6
drizzle/0001_lazy_roughhouse.sql
Normal file
6
drizzle/0001_lazy_roughhouse.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE `clients` ADD `phone` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `clients` ADD `street` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `clients` ADD `city` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `clients` ADD `state` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `clients` ADD `zip` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `clients` ADD `country` text;
|
||||||
987
drizzle/meta/0000_snapshot.json
Normal file
987
drizzle/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"logo_url": {
|
||||||
|
"name": "logo_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"street": {
|
||||||
|
"name": "street",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"name": "city",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"zip": {
|
||||||
|
"name": "zip",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"name": "country",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"site_settings": {
|
||||||
|
"name": "site_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"site_settings_key_unique": {
|
||||||
|
"name": "site_settings_key_unique",
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"name": "tags",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"organization_id": {
|
||||||
|
"name": "organization_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"name": "color",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tags_organization_id_organizations_id_fk": {
|
||||||
|
"name": "tags_organization_id_organizations_id_fk",
|
||||||
|
"tableFrom": "tags",
|
||||||
|
"tableTo": "organizations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"time_entries": {
|
||||||
|
"name": "time_entries",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"organization_id": {
|
||||||
|
"name": "organization_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"client_id": {
|
||||||
|
"name": "client_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"category_id": {
|
||||||
|
"name": "category_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"name": "start_time",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"name": "end_time",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_manual": {
|
||||||
|
"name": "is_manual",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"time_entries_user_id_users_id_fk": {
|
||||||
|
"name": "time_entries_user_id_users_id_fk",
|
||||||
|
"tableFrom": "time_entries",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"time_entries_organization_id_organizations_id_fk": {
|
||||||
|
"name": "time_entries_organization_id_organizations_id_fk",
|
||||||
|
"tableFrom": "time_entries",
|
||||||
|
"tableTo": "organizations",
|
||||||
|
"columnsFrom": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"time_entries_client_id_clients_id_fk": {
|
||||||
|
"name": "time_entries_client_id_clients_id_fk",
|
||||||
|
"tableFrom": "time_entries",
|
||||||
|
"tableTo": "clients",
|
||||||
|
"columnsFrom": [
|
||||||
|
"client_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"time_entries_category_id_categories_id_fk": {
|
||||||
|
"name": "time_entries_category_id_categories_id_fk",
|
||||||
|
"tableFrom": "time_entries",
|
||||||
|
"tableTo": "categories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"category_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"time_entry_tags": {
|
||||||
|
"name": "time_entry_tags",
|
||||||
|
"columns": {
|
||||||
|
"time_entry_id": {
|
||||||
|
"name": "time_entry_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"tag_id": {
|
||||||
|
"name": "tag_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"time_entry_tags_time_entry_id_time_entries_id_fk": {
|
||||||
|
"name": "time_entry_tags_time_entry_id_time_entries_id_fk",
|
||||||
|
"tableFrom": "time_entry_tags",
|
||||||
|
"tableTo": "time_entries",
|
||||||
|
"columnsFrom": [
|
||||||
|
"time_entry_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"time_entry_tags_tag_id_tags_id_fk": {
|
||||||
|
"name": "time_entry_tags_tag_id_tags_id_fk",
|
||||||
|
"tableFrom": "time_entry_tags",
|
||||||
|
"tableTo": "tags",
|
||||||
|
"columnsFrom": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"time_entry_tags_time_entry_id_tag_id_pk": {
|
||||||
|
"columns": [
|
||||||
|
"time_entry_id",
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"name": "time_entry_tags_time_entry_id_tag_id_pk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_site_admin": {
|
||||||
|
"name": "is_site_admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
1029
drizzle/meta/0001_snapshot.json
Normal file
1029
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768688193284,
|
||||||
|
"tag": "0000_motionless_king_cobra",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768690333269,
|
||||||
|
"tag": "0001_lazy_roughhouse",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
package.json
11
package.json
@@ -1,24 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"migrate": "node scripts/migrate.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/node": "^9.5.2",
|
"@astrojs/node": "^9.5.2",
|
||||||
"@astrojs/vue": "^5.1.4",
|
"@astrojs/vue": "^5.1.4",
|
||||||
|
"@ceereals/vue-pdf": "^0.2.1",
|
||||||
|
"@iconify/vue": "^5.0.0",
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^5.16.11",
|
"astro": "^5.16.11",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.6.0",
|
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.14",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
@@ -31,7 +34,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@catppuccin/daisyui": "^2.1.1",
|
"@catppuccin/daisyui": "^2.1.1",
|
||||||
"@iconify-json/heroicons": "^1.2.3",
|
"@iconify-json/heroicons": "^1.2.3",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@react-pdf/types": "^2.9.2",
|
||||||
"drizzle-kit": "0.31.8"
|
"drizzle-kit": "0.31.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1105
pnpm-lock.yaml
generated
1105
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
40
scripts/migrate.js
Normal file
40
scripts/migrate.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import { createClient } from "@libsql/client";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
async function runMigrate() {
|
||||||
|
console.log("Running migrations...");
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR;
|
||||||
|
|
||||||
|
if (!dataDir) {
|
||||||
|
throw new Error("DATA_DIR environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `file:${path.join(dataDir, "chronus.db")}`;
|
||||||
|
console.log(`Using database: ${url}`);
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||||
|
console.log("Migrations completed successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Migration failed:", error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrate();
|
||||||
15
src/components/Avatar.astro
Normal file
15
src/components/Avatar.astro
Normal 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>
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<footer class="footer footer-center p-4 bg-base-200 text-base-content border-t border-base-300">
|
|
||||||
<aside>
|
|
||||||
<p class="text-sm">
|
|
||||||
Made with <span class="text-red-500">❤️</span> by <a href="https://github.com/atridad" target="_blank" rel="noopener noreferrer" class="link link-hover font-semibold">Atridad Lahiji</a>
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</footer>
|
|
||||||
369
src/components/ManualEntry.vue
Normal file
369
src/components/ManualEntry.vue
Normal 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>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialRunningEntry: {
|
initialRunningEntry: {
|
||||||
@@ -127,7 +128,9 @@ async function stopTimer() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="card-body gap-6">
|
||||||
<!-- Client and Description Row -->
|
<!-- Client and Description Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@@ -137,7 +140,7 @@ async function stopTimer() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="selectedClientId"
|
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"
|
:disabled="isRunning"
|
||||||
>
|
>
|
||||||
<option value="">Select a client...</option>
|
<option value="">Select a client...</option>
|
||||||
@@ -157,7 +160,7 @@ async function stopTimer() {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
v-model="selectedCategoryId"
|
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"
|
:disabled="isRunning"
|
||||||
>
|
>
|
||||||
<option value="">Select a category...</option>
|
<option value="">Select a category...</option>
|
||||||
@@ -181,7 +184,7 @@ async function stopTimer() {
|
|||||||
v-model="description"
|
v-model="description"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="What are you working on?"
|
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"
|
:disabled="isRunning"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +200,10 @@ async function stopTimer() {
|
|||||||
:key="tag.id"
|
:key="tag.id"
|
||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'badge badge-lg cursor-pointer transition-all',
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline',
|
selectedTags.includes(tag.id)
|
||||||
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
|
: 'badge-outline hover:bg-base-300/50',
|
||||||
]"
|
]"
|
||||||
:disabled="isRunning"
|
:disabled="isRunning"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -211,19 +216,25 @@ async function stopTimer() {
|
|||||||
<!-- Timer and Action Row -->
|
<!-- Timer and Action Row -->
|
||||||
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
|
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
|
||||||
<div
|
<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) }}
|
{{ formatTime(elapsedTime) }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="!isRunning"
|
v-if="!isRunning"
|
||||||
@click="startTimer"
|
@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
|
<Icon icon="heroicons:play" class="w-5 h-5" />
|
||||||
|
Start Timer
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="stopTimer" class="btn btn-error btn-lg min-w-40">
|
<button
|
||||||
⏹️ Stop Timer
|
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"
|
||||||
|
>
|
||||||
|
<Icon icon="heroicons:stop" class="w-5 h-5" />
|
||||||
|
Stop Timer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,42 @@
|
|||||||
import Database from 'better-sqlite3';
|
import { drizzle } from "drizzle-orm/libsql";
|
||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { createClient } from "@libsql/client";
|
||||||
import * as schema from './schema';
|
import * as schema from "./schema";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
let _db: ReturnType<typeof drizzle> | null = null;
|
// Define the database type based on the schema
|
||||||
|
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
function initDb() {
|
let _db: Database | null = null;
|
||||||
|
|
||||||
|
function initDb(): Database {
|
||||||
if (!_db) {
|
if (!_db) {
|
||||||
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'chronus.db');
|
const dataDir = process.env.DATA_DIR
|
||||||
const sqlite = new Database(dbUrl, { readonly: false });
|
? process.env.DATA_DIR
|
||||||
_db = drizzle(sqlite, { schema });
|
: import.meta.env.DATA_DIR;
|
||||||
|
|
||||||
|
if (!dataDir) {
|
||||||
|
throw new Error("DATA_DIR environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `file:${path.join(dataDir, "chronus.db")}`;
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
_db = drizzle(client, { schema });
|
||||||
}
|
}
|
||||||
return _db;
|
return _db;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
export const db = new Proxy({} as Database, {
|
||||||
get(_target, prop) {
|
get(_target, prop) {
|
||||||
const database = initDb();
|
const database = initDb();
|
||||||
return database[prop as keyof typeof database];
|
return database[prop as keyof Database];
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
100
src/db/schema.ts
100
src/db/schema.ts
@@ -2,8 +2,10 @@ import {
|
|||||||
sqliteTable,
|
sqliteTable,
|
||||||
text,
|
text,
|
||||||
integer,
|
integer,
|
||||||
|
real,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
foreignKey,
|
foreignKey,
|
||||||
|
index,
|
||||||
} from "drizzle-orm/sqlite-core";
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -25,6 +27,12 @@ export const organizations = sqliteTable("organizations", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
logoUrl: text("logo_url"),
|
||||||
|
street: text("street"),
|
||||||
|
city: text("city"),
|
||||||
|
state: text("state"),
|
||||||
|
zip: text("zip"),
|
||||||
|
country: text("country"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -50,6 +58,10 @@ export const members = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("members_user_id_idx").on(table.userId),
|
||||||
|
organizationIdIdx: index("members_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,6 +74,12 @@ export const clients = sqliteTable(
|
|||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text("organization_id").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
street: text("street"),
|
||||||
|
city: text("city"),
|
||||||
|
state: text("state"),
|
||||||
|
zip: text("zip"),
|
||||||
|
country: text("country"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -71,6 +89,9 @@ export const clients = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("clients_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -92,6 +113,9 @@ export const categories = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("categories_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -108,6 +132,7 @@ export const timeEntries = sqliteTable(
|
|||||||
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
||||||
endTime: integer("end_time", { mode: "timestamp" }),
|
endTime: integer("end_time", { mode: "timestamp" }),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -129,6 +154,12 @@ export const timeEntries = sqliteTable(
|
|||||||
columns: [table.categoryId],
|
columns: [table.categoryId],
|
||||||
foreignColumns: [categories.id],
|
foreignColumns: [categories.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
||||||
|
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
|
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
|
||||||
|
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,6 +181,9 @@ export const tags = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
|
organizationIdIdx: index("tags_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,6 +203,10 @@ export const timeEntryTags = sqliteTable(
|
|||||||
columns: [table.tagId],
|
columns: [table.tagId],
|
||||||
foreignColumns: [tags.id],
|
foreignColumns: [tags.id],
|
||||||
}),
|
}),
|
||||||
|
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
|
||||||
|
table.timeEntryId,
|
||||||
|
),
|
||||||
|
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -184,6 +222,7 @@ export const sessions = sqliteTable(
|
|||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [users.id],
|
foreignColumns: [users.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("sessions_user_id_idx").on(table.userId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -218,5 +257,66 @@ export const apiTokens = sqliteTable(
|
|||||||
columns: [table.userId],
|
columns: [table.userId],
|
||||||
foreignColumns: [users.id],
|
foreignColumns: [users.id],
|
||||||
}),
|
}),
|
||||||
|
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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],
|
||||||
|
}),
|
||||||
|
organizationIdIdx: index("invoices_organization_id_idx").on(
|
||||||
|
table.organizationId,
|
||||||
|
),
|
||||||
|
clientIdIdx: index("invoices_client_id_idx").on(table.clientId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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],
|
||||||
|
}),
|
||||||
|
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Icon } from 'astro-icon/components';
|
|||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { members, organizations } from '../db/schema';
|
import { members, organizations } from '../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import Footer from '../components/Footer.astro';
|
import Avatar from '../components/Avatar.astro';
|
||||||
|
import { ClientRouter } from "astro:transitions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -41,20 +42,21 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
<ClientRouter />
|
||||||
</head>
|
</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">
|
<div class="drawer lg:drawer-open flex-1 overflow-auto">
|
||||||
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||||
<div class="drawer-content flex flex-col h-full overflow-auto">
|
<div class="drawer-content flex flex-col h-full overflow-auto">
|
||||||
<!-- Navbar -->
|
<!-- 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">
|
<div class="flex-none lg:hidden">
|
||||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
<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" />
|
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 px-2 flex items-center gap-2">
|
<div class="flex-1 px-2 flex items-center gap-2">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
|
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,11 +68,11 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
</div>
|
</div>
|
||||||
<div class="drawer-side z-50">
|
<div class="drawer-side z-50">
|
||||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
<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 -->
|
<!-- Sidebar content here -->
|
||||||
<li class="mb-6">
|
<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" />
|
<img src="/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||||
Chronus
|
Chronus
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -80,7 +82,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<li class="mb-4">
|
<li class="mb-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<select
|
<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"
|
id="team-switcher"
|
||||||
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
||||||
>
|
>
|
||||||
@@ -108,57 +110,78 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
|
|
||||||
<div class="divider my-2"></div>
|
<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" />
|
<Icon name="heroicons:home" class="w-5 h-5" />
|
||||||
Dashboard
|
Dashboard
|
||||||
</a></li>
|
</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" />
|
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||||
Time Tracker
|
Time Tracker
|
||||||
</a></li>
|
</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" />
|
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||||
Reports
|
Reports
|
||||||
</a></li>
|
</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" />
|
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
||||||
Clients
|
Clients
|
||||||
</a></li>
|
</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" />
|
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
||||||
Team
|
Team
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
{user.isSiteAdmin && (
|
{user.isSiteAdmin && (
|
||||||
<>
|
<>
|
||||||
<div class="divider"></div>
|
<div class="divider my-2"></div>
|
||||||
<li><a href="/admin" class="font-semibold">
|
<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" />
|
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||||
Site Admin
|
Site Admin
|
||||||
</a></li>
|
</a></li>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3">
|
<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">
|
||||||
<div class="avatar placeholder">
|
<Avatar name={user.name} />
|
||||||
<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>
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-semibold text-sm truncate">{user.name}</div>
|
<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>
|
</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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<form action="/api/auth/logout" method="POST">
|
<form action="/api/auth/logout" method="POST" class="contents">
|
||||||
<button type="submit" class="w-full text-error hover:bg-error/10">
|
<button type="submit" class="flex w-full items-center gap-2 py-2 px-4 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" />
|
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
@@ -168,6 +191,5 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import Footer from '../components/Footer.astro';
|
import { ClientRouter } from "astro:transitions";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,11 +18,11 @@ const { title } = Astro.props;
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
|
<ClientRouter />
|
||||||
</head>
|
</head>
|
||||||
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
|
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 flex flex-col">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,26 +4,15 @@
|
|||||||
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||||
*/
|
*/
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
const totalSeconds = Math.floor(ms / 1000);
|
|
||||||
const hours = Math.floor(totalSeconds / 3600);
|
|
||||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
||||||
const seconds = totalSeconds % 60;
|
|
||||||
|
|
||||||
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
||||||
|
|
||||||
// Calculate rounded version for easy reading
|
// Calculate rounded version for easy reading
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
const roundedHours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const roundedMinutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
let roundedStr = '';
|
if (hours > 0) {
|
||||||
if (roundedHours > 0) {
|
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||||
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`;
|
|
||||||
} else {
|
|
||||||
roundedStr = `${roundedMinutes}m`;
|
|
||||||
}
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
return `${timeStr} (${roundedStr})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +22,7 @@ export function formatDuration(ms: number): string {
|
|||||||
* @returns Formatted duration string or "Running..."
|
* @returns Formatted duration string or "Running..."
|
||||||
*/
|
*/
|
||||||
export function formatTimeRange(start: Date, end: Date | null): string {
|
export function formatTimeRange(start: Date, end: Date | null): string {
|
||||||
if (!end) return 'Running...';
|
if (!end) return "Running...";
|
||||||
const ms = end.getTime() - start.getTime();
|
const ms = end.getTime() - start.getTime();
|
||||||
return formatDuration(ms);
|
return formatDuration(ms);
|
||||||
}
|
}
|
||||||
|
|||||||
83
src/lib/validation.ts
Normal file
83
src/lib/validation.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import { clients, categories, tags as tagsTable } from "../db/schema";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function validateTimeEntryResources({
|
||||||
|
organizationId,
|
||||||
|
clientId,
|
||||||
|
categoryId,
|
||||||
|
tagIds,
|
||||||
|
}: {
|
||||||
|
organizationId: string;
|
||||||
|
clientId: string;
|
||||||
|
categoryId: string;
|
||||||
|
tagIds?: string[];
|
||||||
|
}) {
|
||||||
|
const [client, category] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.id, clientId),
|
||||||
|
eq(clients.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(categories.id, categoryId),
|
||||||
|
eq(categories.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return { valid: false, error: "Invalid client" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return { valid: false, error: "Invalid category" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagIds && tagIds.length > 0) {
|
||||||
|
const validTags = await db
|
||||||
|
.select()
|
||||||
|
.from(tagsTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(tagsTable.id, tagIds),
|
||||||
|
eq(tagsTable.organizationId, organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (validTags.length !== tagIds.length) {
|
||||||
|
return { valid: false, error: "Invalid tags" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimeRange(
|
||||||
|
start: string | number | Date,
|
||||||
|
end: string | number | Date,
|
||||||
|
) {
|
||||||
|
const startDate = new Date(start);
|
||||||
|
const endDate = new Date(end);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
return { valid: false, error: "Invalid date format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate <= startDate) {
|
||||||
|
return { valid: false, error: "End time must be after start time" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, startDate, endDate };
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
|
import Avatar from '../../components/Avatar.astro';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { siteSettings, users } from '../../db/schema';
|
import { siteSettings, users } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -36,18 +37,18 @@ const allUsers = await db.select().from(users).all();
|
|||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title mb-4">Site Settings</h2>
|
<h2 class="card-title mb-4">Site Settings</h2>
|
||||||
|
|
||||||
<form method="POST" action="/api/admin/settings">
|
<form method="POST" action="/api/admin/settings">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer">
|
<label class="label cursor-pointer">
|
||||||
<span class="label-text">
|
<span class="label-text flex-1 min-w-0 pr-4">
|
||||||
<div class="font-semibold">Allow New Registrations</div>
|
<div class="font-semibold">Allow New Registrations</div>
|
||||||
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
|
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="registration_enabled"
|
name="registration_enabled"
|
||||||
class="toggle toggle-primary"
|
class="toggle toggle-primary"
|
||||||
checked={registrationEnabled}
|
checked={registrationEnabled}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -79,11 +80,7 @@ const allUsers = await db.select().from(users).all();
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="avatar placeholder">
|
<Avatar name={u.name} />
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
|
||||||
<span>{u.name.charAt(0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="font-bold">{u.name}</div>
|
<div class="font-bold">{u.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { users } from '../../../db/schema';
|
import { users } from "../../../db/schema";
|
||||||
import { verifyPassword, createSession } from '../../../lib/auth';
|
import { verifyPassword, createSession } from "../../../lib/auth";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const email = formData.get('email')?.toString();
|
const email = formData.get("email")?.toString();
|
||||||
const password = formData.get('password')?.toString();
|
const password = formData.get("password")?.toString();
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return new Response('Missing fields', { status: 400 });
|
return redirect("/login?error=missing_fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.select().from(users).where(eq(users.email, email)).get();
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.get();
|
||||||
|
|
||||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||||
return new Response('Invalid email or password', { status: 400 });
|
return redirect("/login?error=invalid_credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, expiresAt } = await createSession(user.id);
|
const { sessionId, expiresAt } = await createSession(user.id);
|
||||||
|
|
||||||
cookies.set('session_id', sessionId, {
|
cookies.set("session_id", sessionId, {
|
||||||
path: '/',
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: import.meta.env.PROD,
|
secure: import.meta.env.PROD,
|
||||||
sameSite: 'lax',
|
sameSite: "lax",
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect('/dashboard');
|
return redirect("/dashboard");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,39 +1,53 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { users, organizations, members, siteSettings } from '../../../db/schema';
|
import {
|
||||||
import { hashPassword, createSession } from '../../../lib/auth';
|
users,
|
||||||
import { eq, count, sql } from 'drizzle-orm';
|
organizations,
|
||||||
import { nanoid } from 'nanoid';
|
members,
|
||||||
|
siteSettings,
|
||||||
|
} from "../../../db/schema";
|
||||||
|
import { hashPassword, createSession } from "../../../lib/auth";
|
||||||
|
import { eq, count, sql } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||||
const userCountResult = await db.select({ count: count() }).from(users).get();
|
const userCountResult = await db.select({ count: count() }).from(users).get();
|
||||||
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
|
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
|
||||||
|
|
||||||
if (!isFirstUser) {
|
if (!isFirstUser) {
|
||||||
const registrationSetting = await db.select()
|
const registrationSetting = await db
|
||||||
|
.select()
|
||||||
.from(siteSettings)
|
.from(siteSettings)
|
||||||
.where(eq(siteSettings.key, 'registration_enabled'))
|
.where(eq(siteSettings.key, "registration_enabled"))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
const registrationEnabled = registrationSetting?.value === 'true';
|
const registrationEnabled = registrationSetting?.value === "true";
|
||||||
|
|
||||||
if (!registrationEnabled) {
|
if (!registrationEnabled) {
|
||||||
return new Response('Registration is currently disabled', { status: 403 });
|
return redirect("/signup?error=registration_disabled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const name = formData.get('name')?.toString();
|
const name = formData.get("name")?.toString();
|
||||||
const email = formData.get('email')?.toString();
|
const email = formData.get("email")?.toString();
|
||||||
const password = formData.get('password')?.toString();
|
const password = formData.get("password")?.toString();
|
||||||
|
|
||||||
if (!name || !email || !password) {
|
if (!name || !email || !password) {
|
||||||
return new Response('Missing fields', { status: 400 });
|
return redirect("/signup?error=missing_fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await db.select().from(users).where(eq(users.email, email)).get();
|
if (password.length < 8) {
|
||||||
|
return redirect("/signup?error=password_too_short");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.email, email))
|
||||||
|
.get();
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return new Response('User already exists', { status: 400 });
|
return redirect("/signup?error=user_exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
@@ -56,18 +70,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
await db.insert(members).values({
|
await db.insert(members).values({
|
||||||
userId,
|
userId,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
role: 'owner',
|
role: "owner",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sessionId, expiresAt } = await createSession(userId);
|
const { sessionId, expiresAt } = await createSession(userId);
|
||||||
|
|
||||||
cookies.set('session_id', sessionId, {
|
cookies.set("session_id", sessionId, {
|
||||||
path: '/',
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: import.meta.env.PROD,
|
secure: import.meta.env.PROD,
|
||||||
sameSite: 'lax',
|
sameSite: "lax",
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect('/dashboard');
|
return redirect("/dashboard");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,15 +16,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
|
|
||||||
let name: string | undefined;
|
let name: string | undefined;
|
||||||
let email: string | undefined;
|
let email: string | undefined;
|
||||||
|
let phone: string | undefined;
|
||||||
|
let street: string | undefined;
|
||||||
|
let city: string | undefined;
|
||||||
|
let state: string | undefined;
|
||||||
|
let zip: string | undefined;
|
||||||
|
let country: string | undefined;
|
||||||
|
|
||||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
name = body.name;
|
name = body.name;
|
||||||
email = body.email;
|
email = body.email;
|
||||||
|
phone = body.phone;
|
||||||
|
street = body.street;
|
||||||
|
city = body.city;
|
||||||
|
state = body.state;
|
||||||
|
zip = body.zip;
|
||||||
|
country = body.country;
|
||||||
} else {
|
} else {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
name = formData.get("name")?.toString();
|
name = formData.get("name")?.toString();
|
||||||
email = formData.get("email")?.toString();
|
email = formData.get("email")?.toString();
|
||||||
|
phone = formData.get("phone")?.toString();
|
||||||
|
street = formData.get("street")?.toString();
|
||||||
|
city = formData.get("city")?.toString();
|
||||||
|
state = formData.get("state")?.toString();
|
||||||
|
zip = formData.get("zip")?.toString();
|
||||||
|
country = formData.get("country")?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
@@ -74,6 +92,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
.set({
|
.set({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
email: email?.trim() || null,
|
email: email?.trim() || null,
|
||||||
|
phone: phone?.trim() || null,
|
||||||
|
street: street?.trim() || null,
|
||||||
|
city: city?.trim() || null,
|
||||||
|
state: state?.trim() || null,
|
||||||
|
zip: zip?.trim() || null,
|
||||||
|
country: country?.trim() || null,
|
||||||
})
|
})
|
||||||
.where(eq(clients.id, id))
|
.where(eq(clients.id, id))
|
||||||
.run();
|
.run();
|
||||||
@@ -85,6 +109,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
id,
|
id,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
email: email?.trim() || null,
|
email: email?.trim() || null,
|
||||||
|
phone: phone?.trim() || null,
|
||||||
|
street: street?.trim() || null,
|
||||||
|
city: city?.trim() || null,
|
||||||
|
state: state?.trim() || null,
|
||||||
|
zip: zip?.trim() || null,
|
||||||
|
country: country?.trim() || null,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -12,15 +12,33 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
|
|
||||||
let name: string | undefined;
|
let name: string | undefined;
|
||||||
let email: string | undefined;
|
let email: string | undefined;
|
||||||
|
let phone: string | undefined;
|
||||||
|
let street: string | undefined;
|
||||||
|
let city: string | undefined;
|
||||||
|
let state: string | undefined;
|
||||||
|
let zip: string | undefined;
|
||||||
|
let country: string | undefined;
|
||||||
|
|
||||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
name = body.name;
|
name = body.name;
|
||||||
email = body.email;
|
email = body.email;
|
||||||
|
phone = body.phone;
|
||||||
|
street = body.street;
|
||||||
|
city = body.city;
|
||||||
|
state = body.state;
|
||||||
|
zip = body.zip;
|
||||||
|
country = body.country;
|
||||||
} else {
|
} else {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
name = formData.get("name")?.toString();
|
name = formData.get("name")?.toString();
|
||||||
email = formData.get("email")?.toString();
|
email = formData.get("email")?.toString();
|
||||||
|
phone = formData.get("phone")?.toString();
|
||||||
|
street = formData.get("street")?.toString();
|
||||||
|
city = formData.get("city")?.toString();
|
||||||
|
state = formData.get("state")?.toString();
|
||||||
|
zip = formData.get("zip")?.toString();
|
||||||
|
country = formData.get("country")?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -44,13 +62,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
organizationId: userOrg.organizationId,
|
organizationId: userOrg.organizationId,
|
||||||
name,
|
name,
|
||||||
email: email || null,
|
email: email || null,
|
||||||
|
phone: phone || null,
|
||||||
|
street: street || null,
|
||||||
|
city: city || null,
|
||||||
|
state: state || null,
|
||||||
|
zip: zip || null,
|
||||||
|
country: country || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (locals.scopes) {
|
if (locals.scopes) {
|
||||||
return new Response(JSON.stringify({ id, name, email: email || null }), {
|
return new Response(
|
||||||
status: 201,
|
JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
id,
|
||||||
});
|
name,
|
||||||
|
email: email || null,
|
||||||
|
phone: phone || null,
|
||||||
|
street: street || null,
|
||||||
|
city: city || null,
|
||||||
|
state: state || null,
|
||||||
|
zip: zip || null,
|
||||||
|
country: country || null,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect("/dashboard/clients");
|
return redirect("/dashboard/clients");
|
||||||
|
|||||||
97
src/pages/api/invoices/[id]/convert.ts
Normal file
97
src/pages/api/invoices/[id]/convert.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { invoices, members } from "../../../../db/schema";
|
||||||
|
import { eq, and, desc } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: invoiceId } = params;
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.type !== "quote") {
|
||||||
|
return new Response("Only quotes can be converted to invoices", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate next invoice number
|
||||||
|
const lastInvoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(invoices.organizationId, invoice.organizationId),
|
||||||
|
eq(invoices.type, "invoice"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(invoices.createdAt))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let nextInvoiceNumber = "INV-001";
|
||||||
|
if (lastInvoice) {
|
||||||
|
const match = lastInvoice.number.match(/(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const num = parseInt(match[1]) + 1;
|
||||||
|
let prefix = lastInvoice.number.replace(match[0], "");
|
||||||
|
if (prefix === "EST-") prefix = "INV-";
|
||||||
|
nextInvoiceNumber =
|
||||||
|
prefix + num.toString().padStart(match[0].length, "0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert quote to invoice:
|
||||||
|
// 1. Change type to 'invoice'
|
||||||
|
// 2. Set status to 'draft' (so user can review before sending)
|
||||||
|
// 3. Update number to next invoice sequence
|
||||||
|
// 4. Update issue date to today
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
type: "invoice",
|
||||||
|
status: "draft",
|
||||||
|
number: nextInvoiceNumber,
|
||||||
|
issueDate: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error converting quote to invoice:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
108
src/pages/api/invoices/[id]/generate.ts
Normal file
108
src/pages/api/invoices/[id]/generate.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
const originalConsoleWarn = console.warn;
|
||||||
|
console.log = () => {};
|
||||||
|
console.warn = () => {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfDocument = createInvoiceDocument({
|
||||||
|
invoice,
|
||||||
|
items,
|
||||||
|
client,
|
||||||
|
organization,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = await renderToStream(pdfDocument);
|
||||||
|
|
||||||
|
// Restore console.log
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
|
||||||
|
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
|
||||||
|
|
||||||
|
return new Response(stream as any, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (pdfError) {
|
||||||
|
// Restore console.log on error
|
||||||
|
console.log = originalConsoleLog;
|
||||||
|
console.warn = originalConsoleWarn;
|
||||||
|
throw pdfError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating PDF:", error);
|
||||||
|
return new Response("Error generating PDF", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
88
src/pages/api/invoices/[id]/items/add.ts
Normal file
88
src/pages/api/invoices/[id]/items/add.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../../db";
|
||||||
|
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
locals,
|
||||||
|
params,
|
||||||
|
}) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: invoiceId } = params;
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence and check status
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow editing if draft
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const description = formData.get("description") as string;
|
||||||
|
const quantityStr = formData.get("quantity") as string;
|
||||||
|
const unitPriceStr = formData.get("unitPrice") as string;
|
||||||
|
|
||||||
|
if (!description || !quantityStr || !unitPriceStr) {
|
||||||
|
return new Response("Missing required fields", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = parseFloat(quantityStr);
|
||||||
|
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||||
|
|
||||||
|
// Convert to cents
|
||||||
|
const unitPrice = Math.round(unitPriceMajor * 100);
|
||||||
|
const amount = Math.round(quantity * unitPrice);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(invoiceItems).values({
|
||||||
|
invoiceId,
|
||||||
|
description,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding invoice item:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
84
src/pages/api/invoices/[id]/items/delete.ts
Normal file
84
src/pages/api/invoices/[id]/items/delete.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../../db";
|
||||||
|
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
locals,
|
||||||
|
params,
|
||||||
|
}) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: invoiceId } = params;
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence and check status
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow editing if draft
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get("itemId") as string;
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return new Response("Item ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify item belongs to invoice
|
||||||
|
const item = await db
|
||||||
|
.select()
|
||||||
|
.from(invoiceItems)
|
||||||
|
.where(and(eq(invoiceItems.id, itemId), eq(invoiceItems.invoiceId, invoiceId)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return new Response("Item not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting invoice item:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
76
src/pages/api/invoices/[id]/status.ts
Normal file
76
src/pages/api/invoices/[id]/status.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { invoices, members } from "../../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
locals,
|
||||||
|
params,
|
||||||
|
}) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: invoiceId } = params;
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const status = formData.get("status") as string;
|
||||||
|
|
||||||
|
const validStatuses = [
|
||||||
|
"draft",
|
||||||
|
"sent",
|
||||||
|
"paid",
|
||||||
|
"void",
|
||||||
|
"accepted",
|
||||||
|
"declined",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!status || !validStatuses.includes(status)) {
|
||||||
|
return new Response("Invalid status", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence and check ownership
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({ status: status as any })
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating invoice status:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
79
src/pages/api/invoices/[id]/update-tax.ts
Normal file
79
src/pages/api/invoices/[id]/update-tax.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { invoices, members } from "../../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
locals,
|
||||||
|
params,
|
||||||
|
}) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: invoiceId } = params;
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const taxRateStr = formData.get("taxRate") as string;
|
||||||
|
|
||||||
|
if (taxRateStr === null) {
|
||||||
|
return new Response("Tax rate is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const taxRate = parseFloat(taxRateStr);
|
||||||
|
|
||||||
|
if (isNaN(taxRate) || taxRate < 0) {
|
||||||
|
return new Response("Invalid tax rate", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
taxRate,
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
|
||||||
|
// Recalculate totals since tax rate changed
|
||||||
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating invoice tax rate:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
87
src/pages/api/invoices/[id]/update.ts
Normal file
87
src/pages/api/invoices/[id]/update.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { invoices, members } from "../../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({
|
||||||
|
request,
|
||||||
|
redirect,
|
||||||
|
locals,
|
||||||
|
params,
|
||||||
|
}) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id: invoiceId } = params;
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const number = formData.get("number") as string;
|
||||||
|
const currency = formData.get("currency") as string;
|
||||||
|
const issueDateStr = formData.get("issueDate") as string;
|
||||||
|
const dueDateStr = formData.get("dueDate") as string;
|
||||||
|
const taxRateStr = formData.get("taxRate") as string;
|
||||||
|
const notes = formData.get("notes") as string;
|
||||||
|
|
||||||
|
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
||||||
|
return new Response("Missing required fields", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const issueDate = new Date(issueDateStr);
|
||||||
|
const dueDate = new Date(dueDateStr);
|
||||||
|
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
number,
|
||||||
|
currency,
|
||||||
|
issueDate,
|
||||||
|
dueDate,
|
||||||
|
taxRate,
|
||||||
|
notes: notes || null,
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
|
||||||
|
// Recalculate totals in case tax rate changed
|
||||||
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating invoice:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
84
src/pages/api/invoices/create.ts
Normal file
84
src/pages/api/invoices/create.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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]
|
||||||
|
: 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ redirect }) => {
|
||||||
|
return redirect("/dashboard/invoices/new");
|
||||||
|
};
|
||||||
58
src/pages/api/invoices/delete.ts
Normal file
58
src/pages/api/invoices/delete.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../db";
|
||||||
|
import { invoices, invoiceItems, members } from "../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, redirect, locals }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const invoiceId = formData.get("id") as string;
|
||||||
|
|
||||||
|
if (!invoiceId) {
|
||||||
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch invoice to verify existence and check ownership
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify membership
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete invoice items first (manual cascade)
|
||||||
|
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
||||||
|
|
||||||
|
// Delete the invoice
|
||||||
|
await db.delete(invoices).where(eq(invoices.id, invoiceId));
|
||||||
|
|
||||||
|
return redirect("/dashboard/invoices");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting invoice:", error);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { organizations, members } from "../../../db/schema";
|
import { organizations, members } from "../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
@@ -12,6 +14,12 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const organizationId = formData.get("organizationId") as string;
|
const organizationId = formData.get("organizationId") as string;
|
||||||
const name = formData.get("name") as string;
|
const name = formData.get("name") as string;
|
||||||
|
const street = formData.get("street") as string | null;
|
||||||
|
const city = formData.get("city") as string | null;
|
||||||
|
const state = formData.get("state") as string | null;
|
||||||
|
const zip = formData.get("zip") as string | null;
|
||||||
|
const country = formData.get("country") as string | null;
|
||||||
|
const logo = formData.get("logo") as File | null;
|
||||||
|
|
||||||
if (!organizationId || !name || name.trim().length === 0) {
|
if (!organizationId || !name || name.trim().length === 0) {
|
||||||
return new Response("Organization ID and name are required", {
|
return new Response("Organization ID and name are required", {
|
||||||
@@ -44,16 +52,65 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update organization name
|
let logoUrl: string | undefined;
|
||||||
|
|
||||||
|
if (logo && logo.size > 0) {
|
||||||
|
const allowedTypes = ["image/png", "image/jpeg"];
|
||||||
|
if (!allowedTypes.includes(logo.type)) {
|
||||||
|
return new Response(
|
||||||
|
"Invalid file type. Only PNG and JPG are allowed.",
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = logo.name.split(".").pop() || "png";
|
||||||
|
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
||||||
|
const dataDir = process.env.DATA_DIR
|
||||||
|
? process.env.DATA_DIR
|
||||||
|
: import.meta.env.DATA_DIR;
|
||||||
|
|
||||||
|
if (!dataDir) {
|
||||||
|
throw new Error("DATA_DIR environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = path.join(dataDir, "uploads");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(uploadDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await logo.arrayBuffer());
|
||||||
|
await fs.writeFile(path.join(uploadDir, filename), buffer);
|
||||||
|
logoUrl = `/uploads/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update organization information
|
||||||
|
const updateData: any = {
|
||||||
|
name: name.trim(),
|
||||||
|
street: street?.trim() || null,
|
||||||
|
city: city?.trim() || null,
|
||||||
|
state: state?.trim() || null,
|
||||||
|
zip: zip?.trim() || null,
|
||||||
|
country: country?.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (logoUrl) {
|
||||||
|
updateData.logoUrl = logoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(organizations)
|
.update(organizations)
|
||||||
.set({ name: name.trim() })
|
.set(updateData)
|
||||||
.where(eq(organizations.id, organizationId))
|
.where(eq(organizations.id, organizationId))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
return redirect("/dashboard/team/settings?success=org-name");
|
return redirect("/dashboard/team/settings?success=org-name");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating organization name:", error);
|
console.error("Error updating organization:", error);
|
||||||
return new Response("Failed to update organization name", { status: 500 });
|
return new Response("Failed to update organization", { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
143
src/pages/api/time-entries/manual.ts
Normal file
143
src/pages/api/time-entries/manual.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../db";
|
||||||
|
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import {
|
||||||
|
validateTimeEntryResources,
|
||||||
|
validateTimeRange,
|
||||||
|
} from "../../../lib/validation";
|
||||||
|
|
||||||
|
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 timeValidation = validateTimeRange(startTime, endTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!timeValidation.valid ||
|
||||||
|
!timeValidation.startDate ||
|
||||||
|
!timeValidation.endDate
|
||||||
|
) {
|
||||||
|
return new Response(JSON.stringify({ error: timeValidation.error }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = timeValidation;
|
||||||
|
|
||||||
|
// 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceValidation = await validateTimeEntryResources({
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
clientId,
|
||||||
|
categoryId,
|
||||||
|
tagIds: Array.isArray(tags) ? tags : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resourceValidation.valid) {
|
||||||
|
return new Response(JSON.stringify({ error: resourceValidation.error }), {
|
||||||
|
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" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,51 +1,58 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
|
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
||||||
import { eq, and, isNull } from 'drizzle-orm';
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
|
import { validateTimeEntryResources } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
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 body = await request.json();
|
||||||
const description = body.description || '';
|
const description = body.description || "";
|
||||||
const clientId = body.clientId;
|
const clientId = body.clientId;
|
||||||
const categoryId = body.categoryId;
|
const categoryId = body.categoryId;
|
||||||
const tags = body.tags || [];
|
const tags = body.tags || [];
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return new Response('Client is required', { status: 400 });
|
return new Response("Client is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!categoryId) {
|
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(
|
const runningEntry = await db
|
||||||
and(
|
.select()
|
||||||
eq(timeEntries.userId, locals.user.id),
|
.from(timeEntries)
|
||||||
isNull(timeEntries.endTime)
|
.where(
|
||||||
|
and(eq(timeEntries.userId, locals.user.id), isNull(timeEntries.endTime)),
|
||||||
)
|
)
|
||||||
).get();
|
.get();
|
||||||
|
|
||||||
if (runningEntry) {
|
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) {
|
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 validation = await validateTimeEntryResources({
|
||||||
and(
|
organizationId: member.organizationId,
|
||||||
eq(categories.id, categoryId),
|
clientId,
|
||||||
eq(categories.organizationId, member.organizationId)
|
categoryId,
|
||||||
)
|
tagIds: tags,
|
||||||
).get();
|
});
|
||||||
|
|
||||||
if (!category) {
|
if (!validation.valid) {
|
||||||
return new Response('Invalid category', { status: 400 });
|
return new Response(validation.error, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = new Date();
|
const startTime = new Date();
|
||||||
@@ -59,6 +66,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
categoryId,
|
categoryId,
|
||||||
startTime,
|
startTime,
|
||||||
description,
|
description,
|
||||||
|
isManual: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tags.length > 0) {
|
if (tags.length > 0) {
|
||||||
@@ -66,7 +74,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
tags.map((tagId: string) => ({
|
tags.map((tagId: string) => ({
|
||||||
timeEntryId: id,
|
timeEntryId: id,
|
||||||
tagId,
|
tagId,
|
||||||
}))
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
|||||||
name="name"
|
name="name"
|
||||||
value={client.name}
|
value={client.name}
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
class="input input-bordered"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,11 +72,101 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
|||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={client.email || ''}
|
value={client.email || ''}
|
||||||
placeholder="contact@acme.com"
|
placeholder="jason.borne@cia.com"
|
||||||
class="input input-bordered"
|
class="input input-bordered w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="phone">
|
||||||
|
<span class="label-text">Phone (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
value={client.phone || ''}
|
||||||
|
placeholder="+1 (780) 420-1337"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider">Address Details</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="street">
|
||||||
|
<span class="label-text">Street Address (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="street"
|
||||||
|
name="street"
|
||||||
|
value={client.street || ''}
|
||||||
|
placeholder="123 Business Rd"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="city">
|
||||||
|
<span class="label-text">City (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="city"
|
||||||
|
name="city"
|
||||||
|
value={client.city || ''}
|
||||||
|
placeholder="Edmonton"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="state">
|
||||||
|
<span class="label-text">State / Province (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="state"
|
||||||
|
name="state"
|
||||||
|
value={client.state || ''}
|
||||||
|
placeholder="AB"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="zip">
|
||||||
|
<span class="label-text">Zip / Postal Code (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="zip"
|
||||||
|
name="zip"
|
||||||
|
value={client.zip || ''}
|
||||||
|
placeholder="10001"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="country">
|
||||||
|
<span class="label-text">Country (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="country"
|
||||||
|
name="country"
|
||||||
|
value={client.country || ''}
|
||||||
|
placeholder="Canada"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-between mt-6">
|
<div class="card-actions justify-between mt-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -86,12 +86,34 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
||||||
{client.email && (
|
<div class="space-y-2 mb-4">
|
||||||
<div class="flex items-center gap-2 text-base-content/70 mb-4">
|
{client.email && (
|
||||||
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
<div class="flex items-center gap-2 text-base-content/70">
|
||||||
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
||||||
</div>
|
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
{client.phone && (
|
||||||
|
<div class="flex items-center gap-2 text-base-content/70">
|
||||||
|
<Icon name="heroicons:phone" class="w-4 h-4" />
|
||||||
|
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(client.street || client.city || client.state || client.zip || client.country) && (
|
||||||
|
<div class="flex items-start gap-2 text-base-content/70">
|
||||||
|
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
|
||||||
|
<div class="text-sm space-y-0.5">
|
||||||
|
{client.street && <div>{client.street}</div>}
|
||||||
|
{(client.city || client.state || client.zip) && (
|
||||||
|
<div>
|
||||||
|
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.country && <div>{client.country}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
||||||
|
|||||||
@@ -8,20 +8,20 @@ if (!user) return Astro.redirect('/login');
|
|||||||
<DashboardLayout title="New Client - Chronus">
|
<DashboardLayout title="New Client - Chronus">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
||||||
|
|
||||||
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
|
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="name">
|
<label class="label" for="name">
|
||||||
<span class="label-text">Client Name</span>
|
<span class="label-text">Client Name</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
class="input input-bordered"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,15 +29,99 @@ if (!user) return Astro.redirect('/login');
|
|||||||
<label class="label" for="email">
|
<label class="label" for="email">
|
||||||
<span class="label-text">Email (optional)</span>
|
<span class="label-text">Email (optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="contact@acme.com"
|
placeholder="jason.borne@cia.com"
|
||||||
class="input input-bordered"
|
class="input input-bordered w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="phone">
|
||||||
|
<span class="label-text">Phone (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
placeholder="+1 (780) 420-1337"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider">Address Details</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="street">
|
||||||
|
<span class="label-text">Street Address (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="street"
|
||||||
|
name="street"
|
||||||
|
placeholder="123 Business Rd"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="city">
|
||||||
|
<span class="label-text">City (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="city"
|
||||||
|
name="city"
|
||||||
|
placeholder="Edmonton"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="state">
|
||||||
|
<span class="label-text">State / Province (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="state"
|
||||||
|
name="state"
|
||||||
|
placeholder="AB"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="zip">
|
||||||
|
<span class="label-text">Zip / Postal Code (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="zip"
|
||||||
|
name="zip"
|
||||||
|
placeholder="10001"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="country">
|
||||||
|
<span class="label-text">Country (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="country"
|
||||||
|
name="country"
|
||||||
|
placeholder="Canada"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-6">
|
<div class="card-actions justify-end mt-6">
|
||||||
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Create Client</button>
|
<button type="submit" class="btn btn-primary">Create Client</button>
|
||||||
|
|||||||
@@ -41,52 +41,48 @@ if (currentOrg) {
|
|||||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const weekEntries = await db.select()
|
const weekStats = await db.select({
|
||||||
|
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||||
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
gte(timeEntries.startTime, weekAgo)
|
gte(timeEntries.startTime, weekAgo),
|
||||||
|
sql`${timeEntries.endTime} IS NOT NULL`
|
||||||
))
|
))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
|
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
|
||||||
if (e.endTime) {
|
|
||||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const monthEntries = await db.select()
|
const monthStats = await db.select({
|
||||||
|
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||||
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
gte(timeEntries.startTime, monthAgo)
|
gte(timeEntries.startTime, monthAgo),
|
||||||
|
sql`${timeEntries.endTime} IS NOT NULL`
|
||||||
))
|
))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
|
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
|
||||||
if (e.endTime) {
|
|
||||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const activeCount = await db.select()
|
const activeCount = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||||
isNull(timeEntries.endTime)
|
isNull(timeEntries.endTime)
|
||||||
))
|
))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.activeTimers = activeCount.length;
|
stats.activeTimers = activeCount?.count || 0;
|
||||||
|
|
||||||
const clientCount = await db.select()
|
const clientCount = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.organizationId, currentOrg.organizationId))
|
.where(eq(clients.organizationId, currentOrg.organizationId))
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
stats.totalClients = clientCount.length;
|
stats.totalClients = clientCount?.count || 0;
|
||||||
|
|
||||||
stats.recentEntries = await db.select({
|
stats.recentEntries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
@@ -107,7 +103,7 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Dashboard - Chronus">
|
<DashboardLayout title="Dashboard - Chronus">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold text-primary mb-2">
|
<h1 class="text-4xl font-bold text-primary mb-2">
|
||||||
Dashboard
|
Dashboard
|
||||||
|
|||||||
367
src/pages/dashboard/invoices/[id].astro
Normal file
367
src/pages/dashboard/invoices/[id].astro
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
---
|
||||||
|
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 !== 'paid' && invoice.status !== 'void' && 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">
|
||||||
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||||
|
Mark Paid
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && 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">
|
||||||
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||||
|
Mark Accepted
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{(invoice.type === 'quote' && invoice.status === 'accepted') && (
|
||||||
|
<form method="POST" action={`/api/invoices/${invoice.id}/convert`}>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<Icon name="heroicons:document-duplicate" class="w-5 h-5" />
|
||||||
|
Convert to Invoice
|
||||||
|
</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>
|
||||||
|
{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>
|
||||||
|
{client.email && <div class="text-base-content/70">{client.email}</div>}
|
||||||
|
{client.phone && <div class="text-base-content/70">{client.phone}</div>}
|
||||||
|
{(client.street || client.city || client.state || client.zip || client.country) && (
|
||||||
|
<div class="text-sm text-base-content/70 mt-2 space-y-0.5">
|
||||||
|
{client.street && <div>{client.street}</div>}
|
||||||
|
{(client.city || client.state || client.zip) && (
|
||||||
|
<div>
|
||||||
|
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.country && <div>{client.country}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="italic text-base-content/40">Client deleted</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Table -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full min-w-150">
|
||||||
|
<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>
|
||||||
|
</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 || isDraft) && (
|
||||||
|
<div class="flex justify-between text-sm items-center group">
|
||||||
|
<span class="text-base-content/60 flex items-center gap-2">
|
||||||
|
Tax ({invoice.taxRate ?? 0}%)
|
||||||
|
{isDraft && (
|
||||||
|
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
|
||||||
|
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</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-primary">Edit Details</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tax Modal -->
|
||||||
|
<dialog id="tax_modal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Update Tax Rate</h3>
|
||||||
|
<p class="py-4">Enter the tax percentage to apply to the subtotal.</p>
|
||||||
|
<form method="POST" action={`/api/invoices/${invoice.id}/update-tax`}>
|
||||||
|
<div class="form-control mb-6">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Tax Rate (%)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="taxRate"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
value={invoice.taxRate ?? 0}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" onclick="document.getElementById('tax_modal').close()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Update</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</DashboardLayout>
|
||||||
155
src/pages/dashboard/invoices/[id]/edit.astro
Normal file
155
src/pages/dashboard/invoices/[id]/edit.astro
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
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">
|
||||||
|
{invoice.type === 'quote' ? 'Valid Until' : '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 flex flex-col">
|
||||||
|
<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>
|
||||||
217
src/pages/dashboard/invoices/index.astro
Normal file
217
src/pages/dashboard/invoices/index.astro
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
---
|
||||||
|
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">
|
||||||
|
<div class="overflow-x-auto md:overflow-visible pb-32 md:pb-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>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
241
src/pages/dashboard/invoices/new.astro
Normal file
241
src/pages/dashboard/invoices/new.astro
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
---
|
||||||
|
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;
|
||||||
|
let prefix = lastInvoice.number.replace(match[0], '');
|
||||||
|
// Ensure we don't carry over an EST- prefix to an invoice
|
||||||
|
if (prefix === 'EST-') prefix = 'INV-';
|
||||||
|
nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
let prefix = lastQuote.number.replace(match[0], '');
|
||||||
|
// Ensure we don't carry over an INV- prefix to a quote
|
||||||
|
if (prefix === 'INV-') prefix = 'EST-';
|
||||||
|
nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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" id="dueDateLabel">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;
|
||||||
|
const dueDateLabel = document.getElementById('dueDateLabel');
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dueDateLabel) {
|
||||||
|
dueDateLabel.textContent = target.value === 'quote' ? 'Valid Until' : 'Due Date';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -5,7 +5,7 @@ import CategoryChart from '../../components/CategoryChart.vue';
|
|||||||
import ClientChart from '../../components/ClientChart.vue';
|
import ClientChart from '../../components/ClientChart.vue';
|
||||||
import MemberChart from '../../components/MemberChart.vue';
|
import MemberChart from '../../components/MemberChart.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, members, users, clients, categories } from '../../db/schema';
|
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
|
||||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ const userMemberships = await db.select()
|
|||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
// Use current team or fallback to first membership
|
||||||
const userMembership = currentTeamId
|
const userMembership = currentTeamId
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||||
: userMemberships[0];
|
: userMemberships[0];
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ const statsByMember = teamMembers.map(member => {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
member,
|
member,
|
||||||
totalTime,
|
totalTime,
|
||||||
@@ -136,7 +136,7 @@ const statsByCategory = allCategories.map(category => {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
category,
|
category,
|
||||||
totalTime,
|
totalTime,
|
||||||
@@ -152,7 +152,7 @@ const statsByClient = allClients.map(client => {
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
totalTime,
|
totalTime,
|
||||||
@@ -167,6 +167,81 @@ const totalTime = entries.reduce((sum, e) => {
|
|||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
|
// Fetch invoices and quotes for the same time period
|
||||||
|
const invoiceConditions = [
|
||||||
|
eq(invoices.organizationId, userMembership.organizationId),
|
||||||
|
gte(invoices.issueDate, startDate),
|
||||||
|
lte(invoices.issueDate, endDate),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (selectedClientId) {
|
||||||
|
invoiceConditions.push(eq(invoices.clientId, selectedClientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allInvoices = await db.select({
|
||||||
|
invoice: invoices,
|
||||||
|
client: clients,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||||
|
.where(and(...invoiceConditions))
|
||||||
|
.orderBy(desc(invoices.issueDate))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Invoice statistics
|
||||||
|
const invoiceStats = {
|
||||||
|
total: allInvoices.filter(i => i.invoice.type === 'invoice').length,
|
||||||
|
paid: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid').length,
|
||||||
|
sent: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent').length,
|
||||||
|
draft: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'draft').length,
|
||||||
|
void: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'void').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Quote statistics
|
||||||
|
const quoteStats = {
|
||||||
|
total: allInvoices.filter(i => i.invoice.type === 'quote').length,
|
||||||
|
accepted: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'accepted').length,
|
||||||
|
sent: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length,
|
||||||
|
declined: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'declined').length,
|
||||||
|
draft: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'draft').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revenue statistics
|
||||||
|
const revenueStats = {
|
||||||
|
total: allInvoices
|
||||||
|
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||||
|
.reduce((sum, i) => sum + i.invoice.total, 0),
|
||||||
|
pending: allInvoices
|
||||||
|
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent')
|
||||||
|
.reduce((sum, i) => sum + i.invoice.total, 0),
|
||||||
|
quotedValue: allInvoices
|
||||||
|
.filter(i => i.invoice.type === 'quote' && (i.invoice.status === 'sent' || i.invoice.status === 'accepted'))
|
||||||
|
.reduce((sum, i) => sum + i.invoice.total, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revenue by client
|
||||||
|
const revenueByClient = allClients.map(client => {
|
||||||
|
const clientInvoices = allInvoices.filter(i =>
|
||||||
|
i.client?.id === client.id &&
|
||||||
|
i.invoice.type === 'invoice' &&
|
||||||
|
i.invoice.status === 'paid'
|
||||||
|
);
|
||||||
|
const revenue = clientInvoices.reduce((sum, i) => sum + i.invoice.total, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
revenue,
|
||||||
|
invoiceCount: clientInvoices.length,
|
||||||
|
};
|
||||||
|
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
|
||||||
|
|
||||||
|
function formatCurrency(amount: number, currency: string = 'USD') {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount / 100);
|
||||||
|
}
|
||||||
|
|
||||||
function getTimeRangeLabel(range: string) {
|
function getTimeRangeLabel(range: string) {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': return 'Today';
|
case 'today': return 'Today';
|
||||||
@@ -243,11 +318,25 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
form {
|
||||||
|
align-items: stretch !important;
|
||||||
|
}
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-primary">
|
<div class="stat-figure text-primary">
|
||||||
@@ -270,6 +359,17 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stats shadow border border-base-300">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-figure text-success">
|
||||||
|
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Revenue</div>
|
||||||
|
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
|
||||||
|
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-accent">
|
<div class="stat-figure text-accent">
|
||||||
@@ -282,6 +382,121 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Invoice & Quote Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">
|
||||||
|
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||||
|
Invoices Overview
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="stat bg-base-200 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Total Invoices</div>
|
||||||
|
<div class="stat-value text-2xl">{invoiceStats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-success/10 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Paid</div>
|
||||||
|
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-info/10 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Sent</div>
|
||||||
|
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-200 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Draft</div>
|
||||||
|
<div class="stat-value text-2xl">{invoiceStats.draft}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-base-content/60">Revenue (Paid)</span>
|
||||||
|
<span class="font-bold text-success">{formatCurrency(revenueStats.total)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-base-content/60">Pending (Sent)</span>
|
||||||
|
<span class="font-bold text-warning">{formatCurrency(revenueStats.pending)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">
|
||||||
|
<Icon name="heroicons:clipboard-document-list" class="w-6 h-6" />
|
||||||
|
Quotes Overview
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="stat bg-base-200 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Total Quotes</div>
|
||||||
|
<div class="stat-value text-2xl">{quoteStats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-success/10 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Accepted</div>
|
||||||
|
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-info/10 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Pending</div>
|
||||||
|
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-error/10 rounded-lg">
|
||||||
|
<div class="stat-title text-xs">Declined</div>
|
||||||
|
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-base-content/60">Quoted Value</span>
|
||||||
|
<span class="font-bold">{formatCurrency(revenueStats.quotedValue)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-base-content/60">Conversion Rate</span>
|
||||||
|
<span class="font-bold">
|
||||||
|
{quoteStats.total > 0 ? Math.round((quoteStats.accepted / quoteStats.total) * 100) : 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
|
||||||
|
{!selectedClientId && revenueByClient.length > 0 && (
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title mb-4">
|
||||||
|
<Icon name="heroicons:banknotes" class="w-6 h-6" />
|
||||||
|
Revenue by Client
|
||||||
|
</h2>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Revenue</th>
|
||||||
|
<th>Invoices</th>
|
||||||
|
<th>Avg Invoice</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{revenueByClient.slice(0, 10).map(stat => (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="font-bold">{stat.client.name}</div>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td>
|
||||||
|
<td>{stat.invoiceCount}</td>
|
||||||
|
<td class="font-mono">
|
||||||
|
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Charts Section - Only show if there's data */}
|
{/* Charts Section - Only show if there's data */}
|
||||||
{totalTime > 0 && (
|
{totalTime > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -295,8 +510,8 @@ function getTimeRangeLabel(range: string) {
|
|||||||
Category Distribution
|
Category Distribution
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<CategoryChart
|
<CategoryChart
|
||||||
client:load
|
client:load
|
||||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.category.name,
|
name: s.category.name,
|
||||||
totalTime: s.totalTime,
|
totalTime: s.totalTime,
|
||||||
@@ -317,8 +532,8 @@ function getTimeRangeLabel(range: string) {
|
|||||||
Time by Client
|
Time by Client
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<ClientChart
|
<ClientChart
|
||||||
client:load
|
client:load
|
||||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.client.name,
|
name: s.client.name,
|
||||||
totalTime: s.totalTime
|
totalTime: s.totalTime
|
||||||
@@ -339,8 +554,8 @@ function getTimeRangeLabel(range: string) {
|
|||||||
Time by Team Member
|
Time by Team Member
|
||||||
</h2>
|
</h2>
|
||||||
<div class="h-64 w-full">
|
<div class="h-64 w-full">
|
||||||
<MemberChart
|
<MemberChart
|
||||||
client:load
|
client:load
|
||||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||||
name: s.member.name,
|
name: s.member.name,
|
||||||
totalTime: s.totalTime
|
totalTime: s.totalTime
|
||||||
@@ -427,9 +642,9 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<td>{stat.entryCount}</td>
|
<td>{stat.entryCount}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<progress
|
<progress
|
||||||
class="progress progress-primary w-20"
|
class="progress progress-primary w-20"
|
||||||
value={stat.totalTime}
|
value={stat.totalTime}
|
||||||
max={totalTime}
|
max={totalTime}
|
||||||
></progress>
|
></progress>
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
@@ -472,9 +687,9 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<td>{stat.entryCount}</td>
|
<td>{stat.entryCount}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<progress
|
<progress
|
||||||
class="progress progress-secondary w-20"
|
class="progress progress-secondary w-20"
|
||||||
value={stat.totalTime}
|
value={stat.totalTime}
|
||||||
max={totalTime}
|
max={totalTime}
|
||||||
></progress>
|
></progress>
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
@@ -532,7 +747,7 @@ function getTimeRangeLabel(range: string) {
|
|||||||
</td>
|
</td>
|
||||||
<td>{e.entry.description || '-'}</td>
|
<td>{e.entry.description || '-'}</td>
|
||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{e.entry.endTime
|
{e.entry.endTime
|
||||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||||
: 'Running...'
|
: 'Running...'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
|
import Avatar from '../../components/Avatar.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { members, users } from '../../db/schema';
|
import { members, users } from '../../db/schema';
|
||||||
@@ -37,7 +38,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
|||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Team - Chronus">
|
<DashboardLayout title="Team - Chronus">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||||
<h1 class="text-3xl font-bold">Team Members</h1>
|
<h1 class="text-3xl font-bold">Team Members</h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -70,11 +71,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="avatar placeholder">
|
<Avatar name={teamUser.name} />
|
||||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
|
||||||
<span>{teamUser.name.charAt(0)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold">{teamUser.name}</div>
|
<div class="font-bold">{teamUser.name}</div>
|
||||||
{teamUser.id === user.id && (
|
{teamUser.id === user.id && (
|
||||||
|
|||||||
@@ -63,13 +63,55 @@ const successType = url.searchParams.get('success');
|
|||||||
{successType === 'org-name' && (
|
{successType === 'org-name' && (
|
||||||
<div class="alert alert-success mb-4">
|
<div class="alert alert-success mb-4">
|
||||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||||
<span>Team name updated successfully!</span>
|
<span>Team information updated successfully!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form action="/api/organizations/update-name" method="POST" class="space-y-4">
|
<form
|
||||||
|
action="/api/organizations/update-name"
|
||||||
|
method="POST"
|
||||||
|
class="space-y-4"
|
||||||
|
enctype="multipart/form-data"
|
||||||
|
>
|
||||||
<input type="hidden" name="organizationId" value={organization.id} />
|
<input type="hidden" name="organizationId" value={organization.id} />
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text font-medium">Team Logo</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-6">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-base-200 text-neutral-content rounded-xl w-24 border border-base-300 flex items-center justify-center overflow-hidden">
|
||||||
|
{organization.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={organization.logoUrl}
|
||||||
|
alt={organization.name}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
name="heroicons:photo"
|
||||||
|
class="w-8 h-8 opacity-40 text-base-content"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="logo"
|
||||||
|
accept="image/png, image/jpeg"
|
||||||
|
class="file-input file-input-bordered w-full max-w-xs"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-base-content/60 mt-2">
|
||||||
|
Upload a company logo (PNG, JPG).
|
||||||
|
<br />
|
||||||
|
Will be displayed on invoices and quotes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-medium">Team Name</span>
|
<span class="label-text font-medium">Team Name</span>
|
||||||
@@ -87,8 +129,83 @@ const successType = url.searchParams.get('success');
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="divider">Address Information</div>
|
||||||
<button type="submit" class="btn btn-primary">
|
|
||||||
|
<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="flex flex-col sm:flex-row justify-between items-center gap-4 mt-6">
|
||||||
|
<span class="text-xs text-base-content/60 text-center sm:text-left">
|
||||||
|
Address information appears on invoices and quotes
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import Timer from '../../components/Timer.vue';
|
import Timer from '../../components/Timer.vue';
|
||||||
|
import ManualEntry from '../../components/ManualEntry.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
||||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||||
@@ -21,7 +22,7 @@ const userMemberships = await db.select()
|
|||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
// Use current team or fallback to first membership
|
||||||
const userMembership = currentTeamId
|
const userMembership = currentTeamId
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||||
: userMemberships[0];
|
: userMemberships[0];
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ const offset = (page - 1) * pageSize;
|
|||||||
const filterClient = url.searchParams.get('client') || '';
|
const filterClient = url.searchParams.get('client') || '';
|
||||||
const filterCategory = url.searchParams.get('category') || '';
|
const filterCategory = url.searchParams.get('category') || '';
|
||||||
const filterStatus = url.searchParams.get('status') || '';
|
const filterStatus = url.searchParams.get('status') || '';
|
||||||
|
const filterType = url.searchParams.get('type') || '';
|
||||||
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
||||||
const searchTerm = url.searchParams.get('search') || '';
|
const searchTerm = url.searchParams.get('search') || '';
|
||||||
|
|
||||||
@@ -74,6 +76,12 @@ if (searchTerm) {
|
|||||||
conditions.push(like(timeEntries.description, `%${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(*)` })
|
const totalCount = await db.select({ count: sql<number>`count(*)` })
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
@@ -127,7 +135,7 @@ const runningEntry = await db.select({
|
|||||||
function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
||||||
const pages: number[] = [];
|
const pages: number[] = [];
|
||||||
const numPagesToShow = Math.min(5, totalPages);
|
const numPagesToShow = Math.min(5, totalPages);
|
||||||
|
|
||||||
for (let i = 0; i < numPagesToShow; i++) {
|
for (let i = 0; i < numPagesToShow; i++) {
|
||||||
let pageNum;
|
let pageNum;
|
||||||
if (totalPages <= 5) {
|
if (totalPages <= 5) {
|
||||||
@@ -141,7 +149,7 @@ function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
|||||||
}
|
}
|
||||||
pages.push(pageNum);
|
pages.push(pageNum);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,45 +158,81 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
|
|
||||||
<DashboardLayout title="Time Tracker - Chronus">
|
<DashboardLayout title="Time Tracker - Chronus">
|
||||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
<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 flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
|
||||||
|
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||||
|
</div>
|
||||||
|
) : allCategories.length === 0 ? (
|
||||||
|
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<span class="flex-1 text-center sm:text-left">You need to create a category before tracking time.</span>
|
||||||
|
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Timer
|
||||||
|
client:load
|
||||||
|
initialRunningEntry={runningEntry ? {
|
||||||
|
startTime: runningEntry.entry.startTime.getTime(),
|
||||||
|
description: runningEntry.entry.description,
|
||||||
|
clientId: runningEntry.entry.clientId,
|
||||||
|
categoryId: runningEntry.entry.categoryId,
|
||||||
|
} : null}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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 flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
|
||||||
|
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||||
|
</div>
|
||||||
|
) : allCategories.length === 0 ? (
|
||||||
|
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||||
|
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<span class="flex-1 text-center sm:text-left">You need to create a category before adding time entries.</span>
|
||||||
|
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">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 ? (
|
{allClients.length === 0 ? (
|
||||||
<div class="alert alert-warning mb-6">
|
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
|
||||||
<span>You need to create a client before tracking time.</span>
|
) : null}
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Timer
|
|
||||||
client:load
|
|
||||||
initialRunningEntry={runningEntry ? {
|
|
||||||
startTime: runningEntry.entry.startTime.getTime(),
|
|
||||||
description: runningEntry.entry.description,
|
|
||||||
clientId: runningEntry.entry.clientId,
|
|
||||||
categoryId: runningEntry.entry.categoryId,
|
|
||||||
} : null}
|
|
||||||
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 }))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<!-- Filters and Search -->
|
<!-- 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">
|
<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">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Search</span>
|
<span class="label-text font-medium">Search</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="search"
|
name="search"
|
||||||
placeholder="Search descriptions..."
|
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 w-full"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,7 +241,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Client</span>
|
<span class="label-text font-medium">Client</span>
|
||||||
</label>
|
</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 w-full" onchange="this.form.submit()">
|
||||||
<option value="">All Clients</option>
|
<option value="">All Clients</option>
|
||||||
{allClients.map(client => (
|
{allClients.map(client => (
|
||||||
<option value={client.id} selected={filterClient === client.id}>
|
<option value={client.id} selected={filterClient === client.id}>
|
||||||
@@ -211,7 +255,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Category</span>
|
<span class="label-text font-medium">Category</span>
|
||||||
</label>
|
</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 w-full" onchange="this.form.submit()">
|
||||||
<option value="">All Categories</option>
|
<option value="">All Categories</option>
|
||||||
{allCategories.map(category => (
|
{allCategories.map(category => (
|
||||||
<option value={category.id} selected={filterCategory === category.id}>
|
<option value={category.id} selected={filterCategory === category.id}>
|
||||||
@@ -225,18 +269,29 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Status</span>
|
<span class="label-text font-medium">Status</span>
|
||||||
</label>
|
</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 w-full" onchange="this.form.submit()">
|
||||||
<option value="" selected={filterStatus === ''}>All Entries</option>
|
<option value="" selected={filterStatus === ''}>All Entries</option>
|
||||||
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
||||||
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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 w-full" 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">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-medium">Sort By</span>
|
<span class="label-text font-medium">Sort By</span>
|
||||||
</label>
|
</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 w-full" onchange="this.form.submit()">
|
||||||
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
|
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
|
||||||
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
||||||
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
||||||
@@ -245,8 +300,8 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="hidden" name="page" value="1" />
|
<input type="hidden" name="page" value="1" />
|
||||||
<div class="form-control md:col-span-2 lg:col-span-5">
|
<div class="form-control md:col-span-2 lg:col-span-6">
|
||||||
<button type="submit" class="btn btn-primary">
|
<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" />
|
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
@@ -255,24 +310,25 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</div>
|
</div>
|
||||||
</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="card-body">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="card-title">
|
<h2 class="card-title">
|
||||||
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
||||||
Time Entries ({totalCount?.count || 0} total)
|
Time Entries ({totalCount?.count || 0} total)
|
||||||
</h2>
|
</h2>
|
||||||
{(filterClient || filterCategory || filterStatus || searchTerm) && (
|
{(filterClient || filterCategory || filterStatus || filterType || searchTerm) && (
|
||||||
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost">
|
<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" />
|
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||||
Clear Filters
|
Clear Filters
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="bg-base-300/30">
|
||||||
|
<th>Type</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
@@ -285,17 +341,30 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map(({ entry, client, category, user: entryUser }) => (
|
{entries.map(({ entry, client, category, user: entryUser }) => (
|
||||||
<tr>
|
<tr class="hover:bg-base-300/20 transition-colors">
|
||||||
<td>{client?.name || 'Unknown'}</td>
|
<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>
|
<td>
|
||||||
{category ? (
|
{category ? (
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<span>{category.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>{entry.description || '-'}</td>
|
<td class="text-base-content/80">{entry.description || '-'}</td>
|
||||||
<td>{entryUser?.name || 'Unknown'}</td>
|
<td>{entryUser?.name || 'Unknown'}</td>
|
||||||
<td class="whitespace-nowrap">
|
<td class="whitespace-nowrap">
|
||||||
{entry.startTime.toLocaleDateString()}<br/>
|
{entry.startTime.toLocaleDateString()}<br/>
|
||||||
@@ -312,15 +381,15 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span class="badge badge-success">Running</span>
|
<span class="badge badge-success shadow-sm">Running</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
<td>
|
||||||
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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?')"
|
onclick="return confirm('Are you sure you want to delete this entry?')"
|
||||||
>
|
>
|
||||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||||
@@ -336,28 +405,28 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div class="flex justify-center items-center gap-2 mt-6">
|
<div class="flex justify-center items-center gap-2 mt-6">
|
||||||
<a
|
<a
|
||||||
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
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 ${page === 1 ? 'btn-disabled' : ''}`}
|
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" />
|
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
|
||||||
Previous
|
Previous
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
{paginationPages.map(pageNum => (
|
{paginationPages.map(pageNum => (
|
||||||
<a
|
<a
|
||||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
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 ${page === pageNum ? 'btn-active' : ''}`}
|
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
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 ${page === totalPages ? 'btn-disabled' : ''}`}
|
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ if (Astro.locals.user) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Chronus - Time Tracking">
|
<Layout title="Chronus - Time Tracking">
|
||||||
<div class="hero h-full bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
|
<div class="hero flex-1 bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
|
||||||
<div class="hero-content text-center">
|
<div class="hero-content text-center">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
<img src="/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
||||||
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
|
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
|
||||||
Chronus
|
Chronus
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
|
||||||
if (Astro.locals.user) {
|
if (Astro.locals.user) {
|
||||||
return Astro.redirect('/dashboard');
|
return Astro.redirect('/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const error = Astro.url.searchParams.get('error');
|
||||||
|
const errorMessage =
|
||||||
|
error === 'invalid_credentials'
|
||||||
|
? 'Invalid email or password'
|
||||||
|
: error === 'missing_fields'
|
||||||
|
? 'Please fill in all fields'
|
||||||
|
: null;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Login - Chronus">
|
<Layout title="Login - Chronus">
|
||||||
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||||
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
||||||
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div role="alert" class="alert alert-error mb-4">
|
||||||
|
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form action="/api/auth/login" method="POST" class="space-y-4">
|
<form action="/api/auth/login" method="POST" class="space-y-4">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
|
|||||||
@@ -20,16 +20,33 @@ if (!isFirstUser) {
|
|||||||
.get();
|
.get();
|
||||||
registrationDisabled = registrationSetting?.value !== 'true';
|
registrationDisabled = registrationSetting?.value !== 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const error = Astro.url.searchParams.get('error');
|
||||||
|
const errorMessage =
|
||||||
|
error === 'user_exists'
|
||||||
|
? 'An account with this email already exists'
|
||||||
|
: error === 'missing_fields'
|
||||||
|
? 'Please fill in all fields'
|
||||||
|
: error === 'registration_disabled'
|
||||||
|
? 'Registration is currently disabled'
|
||||||
|
: null;
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Sign Up - Chronus">
|
<Layout title="Sign Up - Chronus">
|
||||||
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||||
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
||||||
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div role="alert" class="alert alert-error mb-4">
|
||||||
|
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{registrationDisabled ? (
|
{registrationDisabled ? (
|
||||||
<>
|
<>
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
|
|||||||
70
src/pages/uploads/[...path].ts
Normal file
70
src/pages/uploads/[...path].ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { promises as fs, constants } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
const filePathParam = params.path;
|
||||||
|
|
||||||
|
if (!filePathParam) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR
|
||||||
|
? process.env.DATA_DIR
|
||||||
|
: import.meta.env.DATA_DIR;
|
||||||
|
|
||||||
|
if (!dataDir) {
|
||||||
|
return new Response("DATA_DIR environment variable is not set", {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = path.join(dataDir, "uploads");
|
||||||
|
|
||||||
|
const safePath = path.normalize(filePathParam).replace(/^(\.\.[\/\\])+/, "");
|
||||||
|
const fullPath = path.join(uploadDir, safePath);
|
||||||
|
|
||||||
|
if (!fullPath.startsWith(uploadDir)) {
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath, constants.R_OK);
|
||||||
|
const fileStats = await fs.stat(fullPath);
|
||||||
|
|
||||||
|
if (!fileStats.isFile()) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileContent = await fs.readFile(fullPath);
|
||||||
|
|
||||||
|
const ext = path.extname(fullPath).toLowerCase();
|
||||||
|
let contentType = "application/octet-stream";
|
||||||
|
|
||||||
|
switch (ext) {
|
||||||
|
case ".png":
|
||||||
|
contentType = "image/png";
|
||||||
|
break;
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg":
|
||||||
|
contentType = "image/jpeg";
|
||||||
|
break;
|
||||||
|
case ".gif":
|
||||||
|
contentType = "image/gif";
|
||||||
|
break;
|
||||||
|
case ".svg":
|
||||||
|
contentType = "image/svg+xml";
|
||||||
|
break;
|
||||||
|
// WebP is intentionally omitted as it is not supported in PDF generation
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(fileContent, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
};
|
||||||
543
src/pdf/generateInvoicePDF.ts
Normal file
543
src/pdf/generateInvoicePDF.ts
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
import { h } from "vue";
|
||||||
|
import { Document, Page, Text, View, Image } from "@ceereals/vue-pdf";
|
||||||
|
import { readFileSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
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;
|
||||||
|
street: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
country: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
name: string;
|
||||||
|
street: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
country: string | null;
|
||||||
|
logoUrl?: 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,
|
||||||
|
logo: {
|
||||||
|
height: 40,
|
||||||
|
marginBottom: 8,
|
||||||
|
objectFit: "contain",
|
||||||
|
objectPosition: "left",
|
||||||
|
} 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,
|
||||||
|
|
||||||
|
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,
|
||||||
|
clientAddress: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: "#6B7280",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
} 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",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return h(Document, [
|
||||||
|
h(
|
||||||
|
Page,
|
||||||
|
{ size: "A4", style: styles.page },
|
||||||
|
[
|
||||||
|
// Header
|
||||||
|
h(View, { style: styles.header }, [
|
||||||
|
h(View, { style: styles.headerLeft }, [
|
||||||
|
(() => {
|
||||||
|
if (organization.logoUrl) {
|
||||||
|
try {
|
||||||
|
let logoPath;
|
||||||
|
// Handle uploads directory which might be external to public/
|
||||||
|
if (organization.logoUrl.startsWith("/uploads/")) {
|
||||||
|
const dataDir = process.env.DATA_DIR
|
||||||
|
? process.env.DATA_DIR
|
||||||
|
: import.meta.env.DATA_DIR;
|
||||||
|
|
||||||
|
if (!dataDir) {
|
||||||
|
throw new Error(
|
||||||
|
"DATA_DIR environment variable is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadDir = join(dataDir, "uploads");
|
||||||
|
|
||||||
|
const filename = organization.logoUrl.replace(
|
||||||
|
"/uploads/",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
logoPath = join(uploadDir, filename);
|
||||||
|
} else {
|
||||||
|
logoPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
"public",
|
||||||
|
organization.logoUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(logoPath)) {
|
||||||
|
const ext = logoPath.split(".").pop()?.toLowerCase();
|
||||||
|
if (ext === "png" || ext === "jpg" || ext === "jpeg") {
|
||||||
|
return h(Image, {
|
||||||
|
src: {
|
||||||
|
data: readFileSync(logoPath),
|
||||||
|
format: ext === "png" ? "png" : "jpg",
|
||||||
|
},
|
||||||
|
style: styles.logo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})(),
|
||||||
|
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: 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),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
invoice.type !== "quote"
|
||||||
|
? h(View, { style: styles.metaRow }, [
|
||||||
|
h(Text, { style: styles.metaLabel }, "Due Date"),
|
||||||
|
h(
|
||||||
|
Text,
|
||||||
|
{ style: styles.metaValue },
|
||||||
|
formatDate(invoice.dueDate),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: null,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
// Bill To
|
||||||
|
h(
|
||||||
|
View,
|
||||||
|
{ style: styles.billToSection },
|
||||||
|
[
|
||||||
|
h(Text, { style: styles.sectionLabel }, "Bill To"),
|
||||||
|
h(Text, { style: styles.clientName }, client.name),
|
||||||
|
client.street ||
|
||||||
|
client.city ||
|
||||||
|
client.state ||
|
||||||
|
client.zip ||
|
||||||
|
client.country
|
||||||
|
? h(
|
||||||
|
View,
|
||||||
|
{ style: styles.clientAddress },
|
||||||
|
[
|
||||||
|
client.street ? h(Text, client.street) : null,
|
||||||
|
client.city || client.state || client.zip
|
||||||
|
? h(
|
||||||
|
Text,
|
||||||
|
[client.city, client.state, client.zip]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
client.country ? h(Text, client.country) : null,
|
||||||
|
].filter(Boolean),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
||||||
|
|
||||||
export default createCatppuccinPlugin(
|
export default createCatppuccinPlugin(
|
||||||
"mocha",
|
"macchiato",
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
44
src/utils/invoice.ts
Normal file
44
src/utils/invoice.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import { invoices, invoiceItems } from "../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculates the subtotal, tax amount, and total for a specific invoice
|
||||||
|
* based on its items and tax rate.
|
||||||
|
*/
|
||||||
|
export async function recalculateInvoiceTotals(invoiceId: string) {
|
||||||
|
// Fetch invoice to get tax rate
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) return;
|
||||||
|
|
||||||
|
// Fetch all items
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(invoiceItems)
|
||||||
|
.where(eq(invoiceItems.invoiceId, invoiceId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
// Note: amounts are in cents
|
||||||
|
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
||||||
|
|
||||||
|
const taxRate = invoice.taxRate || 0;
|
||||||
|
const taxAmount = Math.round(subtotal * (taxRate / 100));
|
||||||
|
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
|
// Update invoice
|
||||||
|
await db
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
subtotal,
|
||||||
|
taxAmount,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoiceId));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user