20 Commits

Author SHA1 Message Date
3d4b8762e5 Oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m8s
2026-01-18 14:47:45 -07:00
5e70dd6bb8 2.0.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m49s
2026-01-18 14:27:47 -07:00
ce47de9e56 Fixed icons for Vue... I guess we need to be consistent.
All checks were successful
Docker Deploy / build-and-push (push) Successful in 8m22s
2026-01-18 13:46:03 -07:00
db1d180afc Removed footer
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m52s
2026-01-18 01:43:21 -07:00
82e1b8a626 Style updates
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-18 01:40:22 -07:00
253c24c89b Last try!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-01-17 22:41:56 -07:00
39c51b1115 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m50s
2026-01-17 22:30:54 -07:00
091766d6e4 Fixed a few things lol
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m11s
2026-01-17 22:19:10 -07:00
0cd77677f2 FINISHED
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
2026-01-17 15:56:25 -07:00
3734b2693a Moved to lbSQL fully
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m33s
2026-01-17 10:58:10 -07:00
996092d14e 1.3.0 - Invoices, Manual entries, and Auto Migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m55s
2026-01-17 01:39:12 -07:00
aae8693dd3 Trying this again...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-01-17 01:32:07 -07:00
bebc4b2743 Responsive updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m36s
2026-01-17 01:01:53 -07:00
7026435cd3 Changed DB driver
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m42s
2026-01-16 18:45:28 -07:00
85750a5c79 Fixed docker
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-01-16 18:20:47 -07:00
6aa4548a38 ????
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m40s
2026-01-16 18:00:55 -07:00
42fbea6ae7 pls
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m19s
2026-01-16 17:55:36 -07:00
c4ecc0b899 :|
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m40s
2026-01-16 17:39:57 -07:00
5aa9388678 Trying this...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-16 17:24:50 -07:00
15b903f1af Adding manual entries + UI cleanup 2026-01-16 16:28:06 -07:00
62 changed files with 7590 additions and 372 deletions

View File

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

@@ -1,5 +1,6 @@
# build output # build output
dist/ dist/
data/
# generated types # generated types
.astro/ .astro/

View File

@@ -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"]

View File

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

View File

@@ -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",
}), }),
}); });

View File

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

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

View File

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

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';
@@ -40,7 +41,7 @@ const allUsers = await db.select().from(users).all();
<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>
@@ -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>

View File

@@ -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");
}; };

View File

@@ -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");
}; };

View File

@@ -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,

View File

@@ -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(
JSON.stringify({
id,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}),
{
status: 201, status: 201,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); },
);
} }
return redirect("/dashboard/clients"); return redirect("/dashboard/clients");

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

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

View File

@@ -0,0 +1,88 @@
import type { APIRoute } from "astro";
import { db } from "../../../../../db";
import { invoiceItems, invoices, members } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence and check status
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
// Only allow editing if draft
if (invoice.status !== "draft") {
return new Response("Cannot edit a finalized invoice", { status: 400 });
}
const formData = await request.formData();
const description = formData.get("description") as string;
const quantityStr = formData.get("quantity") as string;
const unitPriceStr = formData.get("unitPrice") as string;
if (!description || !quantityStr || !unitPriceStr) {
return new Response("Missing required fields", { status: 400 });
}
const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr);
// Convert to cents
const unitPrice = Math.round(unitPriceMajor * 100);
const amount = Math.round(quantity * unitPrice);
try {
await db.insert(invoiceItems).values({
invoiceId,
description,
quantity,
unitPrice,
amount,
});
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error adding invoice item:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -0,0 +1,84 @@
import type { APIRoute } from "astro";
import { db } from "../../../../../db";
import { invoiceItems, invoices, members } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence and check status
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
// Only allow editing if draft
if (invoice.status !== "draft") {
return new Response("Cannot edit a finalized invoice", { status: 400 });
}
const formData = await request.formData();
const itemId = formData.get("itemId") as string;
if (!itemId) {
return new Response("Item ID required", { status: 400 });
}
// Verify item belongs to invoice
const item = await db
.select()
.from(invoiceItems)
.where(and(eq(invoiceItems.id, itemId), eq(invoiceItems.invoiceId, invoiceId)))
.get();
if (!item) {
return new Response("Item not found", { status: 404 });
}
try {
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error deleting invoice item:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -0,0 +1,76 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
const formData = await request.formData();
const status = formData.get("status") as string;
const validStatuses = [
"draft",
"sent",
"paid",
"void",
"accepted",
"declined",
];
if (!status || !validStatuses.includes(status)) {
return new Response("Invalid status", { status: 400 });
}
// Fetch invoice to verify existence and check ownership
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
try {
await db
.update(invoices)
.set({ status: status as any })
.where(eq(invoices.id, invoiceId));
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error updating invoice status:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -0,0 +1,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 });
}
};

View File

@@ -0,0 +1,87 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const number = formData.get("number") as string;
const currency = formData.get("currency") as string;
const issueDateStr = formData.get("issueDate") as string;
const dueDateStr = formData.get("dueDate") as string;
const taxRateStr = formData.get("taxRate") as string;
const notes = formData.get("notes") as string;
if (!number || !currency || !issueDateStr || !dueDateStr) {
return new Response("Missing required fields", { status: 400 });
}
try {
const issueDate = new Date(issueDateStr);
const dueDate = new Date(dueDateStr);
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
await db
.update(invoices)
.set({
number,
currency,
issueDate,
dueDate,
taxRate,
notes: notes || null,
})
.where(eq(invoices.id, invoiceId));
// Recalculate totals in case tax rate changed
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error updating invoice:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -0,0 +1,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");
};

View File

@@ -0,0 +1,58 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { invoices, invoiceItems, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, redirect, locals }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const formData = await request.formData();
const invoiceId = formData.get("id") as string;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence and check ownership
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
try {
// Delete invoice items first (manual cascade)
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
// Delete the invoice
await db.delete(invoices).where(eq(invoices.id, invoiceId));
return redirect("/dashboard/invoices");
} catch (error) {
console.error("Error deleting invoice:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

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

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

View File

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

View File

@@ -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"

View File

@@ -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>
<div class="space-y-2 mb-4">
{client.email && ( {client.email && (
<div class="flex items-center gap-2 text-base-content/70 mb-4"> <div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:envelope" class="w-4 h-4" /> <Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a> <a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div> </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">

View File

@@ -20,7 +20,7 @@ if (!user) return Astro.redirect('/login');
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>
@@ -33,11 +33,95 @@ if (!user) return Astro.redirect('/login');
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>

View File

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

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

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

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

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

View File

@@ -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';
@@ -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 && (
<> <>

View File

@@ -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 && (

View File

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

View File

@@ -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';
@@ -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))
@@ -151,15 +159,21 @@ 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 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning mb-6"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<span>You need to create a client before tracking time.</span> <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>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a> <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> </div>
) : allCategories.length === 0 ? ( ) : allCategories.length === 0 ? (
<div class="alert alert-warning mb-6"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<span>You need to create a category before tracking time.</span> <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>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a> <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> </div>
) : ( ) : (
<Timer <Timer
@@ -175,11 +189,41 @@ const paginationPages = getPaginationPages(page, totalPages);
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.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 ? (
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
) : null}
<!-- 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>
@@ -188,7 +232,7 @@ const paginationPages = getPaginationPages(page, totalPages);
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" />
@@ -337,8 +406,8 @@ const paginationPages = getPaginationPages(page, totalPages);
{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
@@ -347,8 +416,8 @@ const paginationPages = getPaginationPages(page, totalPages);
<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>
@@ -356,8 +425,8 @@ const paginationPages = getPaginationPages(page, totalPages);
</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" />

View File

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

View File

@@ -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">

View File

@@ -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">

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

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

View File

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

@@ -0,0 +1,44 @@
import { db } from "../db";
import { invoices, invoiceItems } from "../db/schema";
import { eq } from "drizzle-orm";
/**
* Recalculates the subtotal, tax amount, and total for a specific invoice
* based on its items and tax rate.
*/
export async function recalculateInvoiceTotals(invoiceId: string) {
// Fetch invoice to get tax rate
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) return;
// Fetch all items
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoiceId))
.all();
// Calculate totals
// Note: amounts are in cents
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(subtotal * (taxRate / 100));
const total = subtotal + taxAmount;
// Update invoice
await db
.update(invoices)
.set({
subtotal,
taxAmount,
total,
})
.where(eq(invoices.id, invoiceId));
}