23 Commits

Author SHA1 Message Date
1063bf99f1 2.1.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m24s
2026-01-19 10:06:23 -07:00
ea0a83f44d Added discounts to invoices
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-19 10:06:04 -07:00
fa2c92644a Forgot these...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m4s
2026-01-18 14:57:32 -07:00
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
66 changed files with 10240 additions and 574 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,16 @@
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `categories_organization_id_idx` ON `categories` (`organization_id`);--> statement-breakpoint
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
CREATE INDEX `invoices_organization_id_idx` ON `invoices` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_user_id_idx` ON `time_entries` (`user_id`);--> statement-breakpoint
CREATE INDEX `time_entries_organization_id_idx` ON `time_entries` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_client_id_idx` ON `time_entries` (`client_id`);--> statement-breakpoint
CREATE INDEX `time_entries_start_time_idx` ON `time_entries` (`start_time`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_time_entry_id_idx` ON `time_entry_tags` (`time_entry_id`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_tag_id_idx` ON `time_entry_tags` (`tag_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `invoices` ADD `discount_value` real DEFAULT 0;--> statement-breakpoint
ALTER TABLE `invoices` ADD `discount_type` text DEFAULT 'percentage';--> statement-breakpoint
ALTER TABLE `invoices` ADD `discount_amount` integer DEFAULT 0;

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
{
"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
},
{
"idx": 2,
"version": "6",
"when": 1768773436601,
"tag": "0002_chilly_cyclops",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1768842088321,
"tag": "0003_amusing_wendigo",
"breakpoints": true
}
]
}

View File

@@ -1,37 +1,41 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "1.2.0", "version": "2.1.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",
"dotenv": "^17.2.3",
"drizzle-orm": "0.45.1", "drizzle-orm": "0.45.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vue": "^3.5.26", "vue": "^3.5.27",
"vue-chartjs": "^5.3.3" "vue-chartjs": "^5.3.3"
}, },
"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"
} }
} }

1517
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,69 @@ 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
discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
discountAmount: integer("discount_amount").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(
status: 201, JSON.stringify({
headers: { "Content-Type": "application/json" }, id,
}); name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
},
);
} }
return redirect("/dashboard/clients"); return redirect("/dashboard/clients");

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,91 @@
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 discountType = (formData.get("discountType") as string) || "percentage";
const discountValueStr = formData.get("discountValue") 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;
let discountValue = discountValueStr ? parseFloat(discountValueStr) : 0;
if (discountType === "fixed") {
discountValue = Math.round(discountValue * 100);
}
await db
.update(invoices)
.set({
number,
currency,
issueDate,
dueDate,
taxRate,
discountType: discountType as "percentage" | "fixed",
discountValue,
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>
{client.email && ( <div class="space-y-2 mb-4">
<div class="flex items-center gap-2 text-base-content/70 mb-4"> {client.email && (
<Icon name="heroicons:envelope" class="w-4 h-4" /> <div class="flex items-center gap-2 text-base-content/70">
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a> <Icon name="heroicons:envelope" class="w-4 h-4" />
</div> <a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
)} </div>
)}
{client.phone && (
<div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:phone" class="w-4 h-4" />
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
</div>
)}
{(client.street || client.city || client.state || client.zip || client.country) && (
<div class="flex items-start gap-2 text-base-content/70">
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
<div class="text-sm space-y-0.5">
{client.street && <div>{client.street}</div>}
{(client.city || client.state || client.zip) && (
<div>
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
</div>
)}
{client.country && <div>{client.country}</div>}
</div>
</div>
)}
</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm"> <a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">

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,376 @@
---
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.discountAmount > 0) && (
<div class="flex justify-between text-sm">
<span class="text-base-content/60">
Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</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,180 @@
---
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];
const discountValueDisplay = invoice.discountType === 'fixed'
? (invoice.discountValue || 0) / 100
: (invoice.discountValue || 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>
<!-- Discount -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Discount</span>
</label>
<div class="join w-full">
<select name="discountType" class="select select-bordered join-item">
<option value="percentage" selected={!invoice.discountType || invoice.discountType === 'percentage'}>%</option>
<option value="fixed" selected={invoice.discountType === 'fixed'}>Fixed</option>
</select>
<input
type="number"
name="discountValue"
step="0.01"
min="0"
class="input input-bordered join-item w-full"
value={discountValueDisplay}
/>
</div>
</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,35 +159,71 @@ const paginationPages = getPaginationPages(page, totalPages);
<DashboardLayout title="Time Tracker - Chronus"> <DashboardLayout title="Time Tracker - Chronus">
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1> <h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry -->
<div role="tablist" class="tabs tabs-lifted mb-6">
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="flex-1 text-center sm:text-left">You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
</div>
) : (
<Timer
client:load
initialRunningEntry={runningEntry ? {
startTime: runningEntry.entry.startTime.getTime(),
description: runningEntry.entry.description,
clientId: runningEntry.entry.clientId,
categoryId: runningEntry.entry.categoryId,
} : null}
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
</div>
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span class="flex-1 text-center sm:text-left">You need to create a category before adding time entries.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
</div>
) : (
<ManualEntry
client:load
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
</div>
</div>
{allClients.length === 0 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning mb-6"> <!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
<span>You need to create a client before tracking time.</span> ) : null}
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning mb-6">
<span>You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
</div>
) : (
<Timer
client:load
initialRunningEntry={runningEntry ? {
startTime: runningEntry.entry.startTime.getTime(),
description: runningEntry.entry.description,
clientId: runningEntry.entry.clientId,
categoryId: runningEntry.entry.categoryId,
} : null}
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
<!-- Filters and Search --> <!-- Filters and Search -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6"> <div class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200 mb-6">
<div class="card-body"> <div class="card-body">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4"> <form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text font-medium">Search</span> <span class="label-text font-medium">Search</span>
@@ -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,564 @@
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;
discountValue: number | null;
discountType: string | null;
discountAmount: number | null;
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.discountAmount ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(
Text,
{ style: styles.totalLabel },
`Discount${
invoice.discountType === "percentage"
? ` (${invoice.discountValue}%)`
: ""
}`,
),
h(
Text,
{ style: styles.totalValue },
`-${formatCurrency(invoice.discountAmount ?? 0)}`,
),
])
: null,
(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,

62
src/utils/invoice.ts Normal file
View File

@@ -0,0 +1,62 @@
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);
// Calculate discount
const discountType = invoice.discountType || "percentage";
const discountValue = invoice.discountValue || 0;
let discountAmount = 0;
if (discountType === "percentage") {
discountAmount = Math.round(subtotal * (discountValue / 100));
} else {
// Fixed amount is assumed to be in cents
discountAmount = Math.round(discountValue);
}
// Ensure discount doesn't exceed subtotal
discountAmount = Math.max(0, Math.min(discountAmount, subtotal));
const taxableAmount = subtotal - discountAmount;
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(taxableAmount * (taxRate / 100));
const total = taxableAmount + taxAmount;
// Update invoice
await db
.update(invoices)
.set({
subtotal,
discountAmount,
taxAmount,
total,
})
.where(eq(invoices.id, invoiceId));
}