40 Commits

Author SHA1 Message Date
abbf39f160 Theme select & Accessability :^)
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m0s
2026-01-27 14:26:14 -07:00
e2949a28ef Update new.astro
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2026-01-20 12:59:32 -07:00
8b91ec7a71 Updated to Astro 6 beta
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m52s
2026-01-20 12:37:08 -07:00
815c08dd50 Schema fixes
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s
2026-01-20 12:08:06 -07:00
55eb03165e Fixed migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m2s
2026-01-20 11:36:52 -07:00
a4071d6e40 Fixed charts
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-20 11:24:41 -07:00
fff0e14a4b Fixed
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m7s
2026-01-20 11:10:31 -07:00
ad7dc18780 Switch to tags
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-20 11:09:09 -07:00
de5b1063b7 Migrate
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m24s
2026-01-20 10:47:43 -07:00
82b45fdfe4 O_O
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m2s
2026-01-20 10:32:14 -07:00
b5ac2e0608 Oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m0s
2026-01-20 01:21:56 -07:00
6bed4b4709 Last fix for the night...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2026-01-20 01:06:06 -07:00
54cac49b70 OOOOOPS
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m56s
2026-01-19 23:39:00 -07:00
effc6ac37e oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
2026-01-19 22:25:25 -07:00
df82a02f41 2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-19 21:08:46 -07:00
8a3932a013 Optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2026-01-19 20:55:47 -07:00
d4a2c5853b 2.2.0 Migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-01-19 19:41:56 -07:00
ee9807e8e0 Passkeys!
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-19 15:53:05 -07:00
bf2a1816db Added custom ranges for report filtering + CSV exports
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s
2026-01-19 15:18:34 -07:00
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
98 changed files with 7225 additions and 2693 deletions

View File

@@ -1,3 +1,4 @@
HOST=0.0.0.0
PORT=4321
DATABASE_URL=chronus.db
DATA_DIR=./data
ROOT_DIR=./data
APP_PORT=4321
IMAGE=git.atri.dad/atash/chronus:latest

1
.gitignore vendored
View File

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

View File

@@ -1,33 +1,35 @@
FROM node:lts-alpine AS builder
FROM node:lts-alpine AS base
WORKDIR /app
RUN npm i -g pnpm
FROM base AS prod-deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
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 . .
RUN pnpm run build
FROM node:lts-alpine AS runtime
FROM base AS runtime
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/drizzle ./drizzle
COPY --from=builder /app/scripts ./scripts
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --prod
RUN mkdir -p /app/data
COPY package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
ENV DATABASE_URL=/app/data/chronus.db
EXPOSE 4321
CMD ["sh", "-c", "pnpm run migrate && node ./dist/server/entry.mjs"]
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"]

View File

@@ -1,2 +1,10 @@
# Chronus
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,22 @@
// @ts-check
import { defineConfig } from 'astro/config';
import vue from '@astrojs/vue';
import tailwindcss from '@tailwindcss/vite';
import icon from 'astro-icon';
import { defineConfig } from "astro/config";
import vue from "@astrojs/vue";
import tailwindcss from "@tailwindcss/vite";
import icon from "astro-icon";
import node from '@astrojs/node';
import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
output: 'server',
output: "server",
integrations: [vue(), icon()],
security: {
csp: process.env.NODE_ENV === "production",
},
vite: {
plugins: [tailwindcss()],
ssr: {
external: ['better-sqlite3'],
},
},
adapter: node({
mode: 'standalone',
mode: "standalone",
}),
});

View File

@@ -7,7 +7,7 @@ services:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=4321
- DATABASE_URL=/app/data/chronus.db
- DATA_DIR=/app/data
volumes:
- ${ROOT_DIR}:/app/data
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({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "turso",
dbCredentials: {
url: process.env.DATABASE_URL || 'chronus.db',
url: dbUrl,
},
});

View File

@@ -10,24 +10,23 @@ CREATE TABLE `api_tokens` (
);
--> 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 INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE TABLE `clients` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`name` text NOT NULL,
`email` text,
`phone` text,
`street` text,
`city` text,
`state` text,
`zip` text,
`country` text,
`created_at` integer,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
CREATE TABLE `invoice_items` (
`id` text PRIMARY KEY NOT NULL,
`invoice_id` text NOT NULL,
@@ -38,6 +37,7 @@ CREATE TABLE `invoice_items` (
FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
CREATE TABLE `invoices` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
@@ -50,6 +50,9 @@ CREATE TABLE `invoices` (
`notes` text,
`currency` text DEFAULT 'USD' NOT NULL,
`subtotal` integer DEFAULT 0 NOT NULL,
`discount_value` real DEFAULT 0,
`discount_type` text DEFAULT 'percentage',
`discount_amount` integer DEFAULT 0,
`tax_rate` real DEFAULT 0,
`tax_amount` integer DEFAULT 0 NOT NULL,
`total` integer DEFAULT 0 NOT NULL,
@@ -58,6 +61,8 @@ CREATE TABLE `invoices` (
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
);
--> 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 TABLE `members` (
`user_id` text NOT NULL,
`organization_id` text NOT NULL,
@@ -68,17 +73,44 @@ CREATE TABLE `members` (
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
);
--> 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 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,
`default_tax_rate` real DEFAULT 0,
`default_currency` text DEFAULT 'USD',
`created_at` integer
);
--> statement-breakpoint
CREATE TABLE `passkey_challenges` (
`id` text PRIMARY KEY NOT NULL,
`challenge` text NOT NULL,
`user_id` text,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `passkey_challenges_challenge_unique` ON `passkey_challenges` (`challenge`);--> statement-breakpoint
CREATE TABLE `passkeys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`public_key` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`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 INDEX `passkeys_user_id_idx` ON `passkeys` (`user_id`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
@@ -86,6 +118,7 @@ CREATE TABLE `sessions` (
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE TABLE `site_settings` (
`id` text PRIMARY KEY NOT NULL,
`key` text NOT NULL,
@@ -99,27 +132,33 @@ CREATE TABLE `tags` (
`organization_id` text NOT NULL,
`name` text NOT NULL,
`color` text,
`rate` integer DEFAULT 0,
`created_at` integer,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> 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,
`invoice_id` 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
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
);
--> 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_entries_invoice_id_idx` ON `time_entries` (`invoice_id`);--> statement-breakpoint
CREATE TABLE `time_entry_tags` (
`time_entry_id` text NOT NULL,
`tag_id` text NOT NULL,
@@ -128,6 +167,8 @@ CREATE TABLE `time_entry_tags` (
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE no action
);
--> 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`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,

View File

@@ -0,0 +1,3 @@
DROP TABLE `time_entry_tags`;--> statement-breakpoint
ALTER TABLE `time_entries` ADD `tag_id` text REFERENCES tags(id);--> statement-breakpoint
CREATE INDEX `time_entries_tag_id_idx` ON `time_entries` (`tag_id`);

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cfa98c92-215e-4dbc-b8d4-23a655684d1b",
"id": "8343b003-264b-444a-9782-07d736dd3407",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"api_tokens": {
@@ -65,6 +65,13 @@
"token"
],
"isUnique": true
},
"api_tokens_user_id_idx": {
"name": "api_tokens_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
@@ -86,65 +93,6 @@
"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": {
@@ -176,6 +124,48 @@
"notNull": false,
"autoincrement": false
},
"phone": {
"name": "phone",
"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",
@@ -184,7 +174,15 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"clients_organization_id_idx": {
"name": "clients_organization_id_idx",
"columns": [
"organization_id"
],
"isUnique": false
}
},
"foreignKeys": {
"clients_organization_id_organizations_id_fk": {
"name": "clients_organization_id_organizations_id_fk",
@@ -253,7 +251,15 @@
"default": 0
}
},
"indexes": {},
"indexes": {
"invoice_items_invoice_id_idx": {
"name": "invoice_items_invoice_id_idx",
"columns": [
"invoice_id"
],
"isUnique": false
}
},
"foreignKeys": {
"invoice_items_invoice_id_invoices_id_fk": {
"name": "invoice_items_invoice_id_invoices_id_fk",
@@ -357,6 +363,30 @@
"autoincrement": false,
"default": 0
},
"discount_value": {
"name": "discount_value",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"discount_type": {
"name": "discount_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'percentage'"
},
"discount_amount": {
"name": "discount_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"tax_rate": {
"name": "tax_rate",
"type": "real",
@@ -389,7 +419,22 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"invoices_organization_id_idx": {
"name": "invoices_organization_id_idx",
"columns": [
"organization_id"
],
"isUnique": false
},
"invoices_client_id_idx": {
"name": "invoices_client_id_idx",
"columns": [
"client_id"
],
"isUnique": false
}
},
"foreignKeys": {
"invoices_organization_id_organizations_id_fk": {
"name": "invoices_organization_id_organizations_id_fk",
@@ -455,7 +500,22 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"members_user_id_idx": {
"name": "members_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"members_organization_id_idx": {
"name": "members_organization_id_idx",
"columns": [
"organization_id"
],
"isUnique": false
}
},
"foreignKeys": {
"members_user_id_users_id_fk": {
"name": "members_user_id_users_id_fk",
@@ -513,6 +573,13 @@
"notNull": true,
"autoincrement": false
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"street": {
"name": "street",
"type": "text",
@@ -548,6 +615,22 @@
"notNull": false,
"autoincrement": false
},
"default_tax_rate": {
"name": "default_tax_rate",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"default_currency": {
"name": "default_currency",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'USD'"
},
"created_at": {
"name": "created_at",
"type": "integer",
@@ -562,6 +645,147 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey_challenges": {
"name": "passkey_challenges",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"passkey_challenges_challenge_unique": {
"name": "passkey_challenges_challenge_unique",
"columns": [
"challenge"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkeys": {
"name": "passkeys",
"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
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"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": {
"passkeys_user_id_idx": {
"name": "passkeys_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkeys_user_id_users_id_fk": {
"name": "passkeys_user_id_users_id_fk",
"tableFrom": "passkeys",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
@@ -587,7 +811,15 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
@@ -684,6 +916,14 @@
"notNull": false,
"autoincrement": false
},
"rate": {
"name": "rate",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
@@ -692,7 +932,15 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"tags_organization_id_idx": {
"name": "tags_organization_id_idx",
"columns": [
"organization_id"
],
"isUnique": false
}
},
"foreignKeys": {
"tags_organization_id_organizations_id_fk": {
"name": "tags_organization_id_organizations_id_fk",
@@ -743,13 +991,6 @@
"notNull": true,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "integer",
@@ -771,6 +1012,13 @@
"notNull": false,
"autoincrement": false
},
"invoice_id": {
"name": "invoice_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_manual": {
"name": "is_manual",
"type": "integer",
@@ -787,7 +1035,43 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"time_entries_user_id_idx": {
"name": "time_entries_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"time_entries_organization_id_idx": {
"name": "time_entries_organization_id_idx",
"columns": [
"organization_id"
],
"isUnique": false
},
"time_entries_client_id_idx": {
"name": "time_entries_client_id_idx",
"columns": [
"client_id"
],
"isUnique": false
},
"time_entries_start_time_idx": {
"name": "time_entries_start_time_idx",
"columns": [
"start_time"
],
"isUnique": false
},
"time_entries_invoice_id_idx": {
"name": "time_entries_invoice_id_idx",
"columns": [
"invoice_id"
],
"isUnique": false
}
},
"foreignKeys": {
"time_entries_user_id_users_id_fk": {
"name": "time_entries_user_id_users_id_fk",
@@ -827,19 +1111,6 @@
],
"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": {},
@@ -864,7 +1135,22 @@
"autoincrement": false
}
},
"indexes": {},
"indexes": {
"time_entry_tags_time_entry_id_idx": {
"name": "time_entry_tags_time_entry_id_idx",
"columns": [
"time_entry_id"
],
"isUnique": false
},
"time_entry_tags_tag_id_idx": {
"name": "time_entry_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"time_entry_tags_time_entry_id_time_entries_id_fk": {
"name": "time_entry_tags_time_entry_id_time_entries_id_fk",

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,15 @@
{
"idx": 0,
"version": "6",
"when": 1768609277648,
"tag": "0000_mixed_morlocks",
"when": 1768934194146,
"tag": "0000_lazy_rictor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768935234392,
"tag": "0001_demonic_red_skull",
"breakpoints": true
}
]

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "1.2.0",
"version": "2.3.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -12,29 +12,32 @@
"migrate": "node scripts/migrate.js"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.2",
"@astrojs/vue": "^5.1.4",
"@astrojs/check": "0.9.6",
"@astrojs/node": "10.0.0-beta.0",
"@astrojs/vue": "6.0.0-beta.0",
"@ceereals/vue-pdf": "^0.2.1",
"@iconify/vue": "^5.0.0",
"@libsql/client": "^0.17.0",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.11",
"astro": "6.0.0-beta.1",
"astro-icon": "^1.1.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.0",
"chart.js": "^4.5.1",
"daisyui": "^5.5.14",
"dotenv": "^17.2.3",
"drizzle-orm": "0.45.1",
"nanoid": "^5.1.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.26",
"vue": "^3.5.27",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@catppuccin/daisyui": "^2.1.1",
"@iconify-json/heroicons": "^1.2.3",
"@react-pdf/types": "^2.9.2",
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "0.31.8"
}
}

1585
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

View File

@@ -1,45 +1,40 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
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";
const runMigrations = () => {
console.log("Starting database migrations...");
async function runMigrate() {
console.log("Running migrations...");
const dbUrl =
process.env.DATABASE_URL || path.resolve(process.cwd(), "chronus.db");
const dbDir = path.dirname(dbUrl);
const dataDir = process.env.DATA_DIR;
if (!fs.existsSync(dbDir)) {
console.log(`Creating directory for database: ${dbDir}`);
fs.mkdirSync(dbDir, { recursive: true });
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
console.log(`Using database at: ${dbUrl}`);
const sqlite = new Database(dbUrl);
const db = drizzle(sqlite);
const migrationsFolder = path.resolve(process.cwd(), "drizzle");
if (!fs.existsSync(migrationsFolder)) {
console.error(`Migrations folder not found at: ${migrationsFolder}`);
console.error(
"Did you run `drizzle-kit generate` and copy the folder to the container?",
);
process.exit(1);
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 {
migrate(db, { migrationsFolder });
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations completed successfully");
} catch (error) {
console.error("Migration failed:", error);
process.exit(1);
} finally {
client.close();
}
}
sqlite.close();
};
runMigrations();
runMigrate();

View File

@@ -1,64 +0,0 @@
<template>
<div style="position: relative; height: 100%; width: 100%;">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Doughnut } from 'vue-chartjs';
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
DoughnutController
} from 'chart.js';
ChartJS.register(ArcElement, Tooltip, Legend, DoughnutController);
interface CategoryData {
name: string;
totalTime: number;
color: string;
}
const props = defineProps<{
categories: CategoryData[];
}>();
const chartData = computed(() => ({
labels: props.categories.map(c => c.name),
datasets: [{
data: props.categories.map(c => c.totalTime),
backgroundColor: props.categories.map(c => c.color || '#3b82f6'),
borderWidth: 2,
borderColor: '#1e293b',
}]
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
color: '#e2e8f0',
padding: 15,
font: { size: 12 }
}
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw / (1000 * 60));
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${context.label}: ${hours}h ${mins}m`;
}
}
}
}
};
</script>

View File

@@ -1,12 +1,12 @@
<template>
<div style="position: relative; height: 100%; width: 100%;">
<div style="position: relative; height: 100%; width: 100%">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import { computed } from "vue";
import { Bar } from "vue-chartjs";
import {
Chart as ChartJS,
BarElement,
@@ -14,10 +14,18 @@ import {
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
BarController,
type ChartOptions,
} from "chart.js";
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController,
);
interface ClientData {
name: string;
@@ -29,57 +37,61 @@ const props = defineProps<{
}>();
const chartData = computed(() => ({
labels: props.clients.map(c => c.name),
datasets: [{
label: 'Time Tracked',
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#6366f1',
borderColor: '#4f46e5',
borderWidth: 1,
}]
labels: props.clients.map((c) => c.name),
datasets: [
{
label: "Time Tracked",
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: "#6366f1",
borderColor: "#4f46e5",
borderWidth: 1,
},
],
}));
const chartOptions = {
const chartOptions: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
color: "#e2e8f0",
callback: function (value: string | number) {
const numValue =
typeof value === "string" ? parseFloat(value) : value;
const hours = Math.floor(numValue / 60);
const mins = Math.round(numValue % 60);
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
},
grid: {
color: '#334155'
}
color: "#334155",
},
},
x: {
ticks: {
color: '#e2e8f0'
color: "#e2e8f0",
},
grid: {
display: false
}
}
display: false,
},
},
},
plugins: {
legend: {
display: false
display: false,
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
},
},
},
},
};
</script>

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

@@ -3,7 +3,6 @@ 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 }[];
}>();
@@ -13,8 +12,7 @@ const emit = defineEmits<{
const description = ref("");
const selectedClientId = ref("");
const selectedCategoryId = ref("");
const selectedTags = ref<string[]>([]);
const selectedTagId = ref<string | null>(null);
const startDate = ref("");
const startTime = ref("");
const endDate = ref("");
@@ -23,17 +21,15 @@ 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);
if (selectedTagId.value === tagId) {
selectedTagId.value = null;
} else {
selectedTags.value.push(tagId);
selectedTagId.value = tagId;
}
}
@@ -54,10 +50,6 @@ function validateForm(): string | null {
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";
}
@@ -102,10 +94,9 @@ async function submitManualEntry() {
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
startTime: startDateTime,
endTime: endDateTime,
tags: selectedTags.value,
tagId: selectedTagId.value,
}),
});
@@ -114,22 +105,18 @@ async function submitManualEntry() {
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 = [];
selectedTagId.value = null;
startDate.value = today;
endDate.value = today;
startTime.value = "";
endTime.value = "";
// Emit event and reload after a short delay
setTimeout(() => {
emit("entryCreated");
window.location.reload();
@@ -148,8 +135,7 @@ async function submitManualEntry() {
function clearForm() {
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
selectedTagId.value = null;
startDate.value = today;
endDate.value = today;
startTime.value = "";
@@ -212,59 +198,32 @@ function clearForm() {
<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>
<!-- Client Row -->
<div class="form-control">
<label class="label pb-2 font-medium" for="manual-client">
Client <span class="label-text-alt text-error">*</span>
</label>
<select
id="manual-client"
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>
<!-- 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 class="label pb-2 font-medium" for="manual-start-date">
Start Date <span class="label-text-alt text-error">*</span>
</label>
<input
id="manual-start-date"
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"
@@ -273,11 +232,11 @@ function clearForm() {
</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 class="label pb-2 font-medium" for="manual-start-time">
Start Time <span class="label-text-alt text-error">*</span>
</label>
<input
id="manual-start-time"
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"
@@ -289,11 +248,11 @@ function clearForm() {
<!-- 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 class="label pb-2 font-medium" for="manual-end-date">
End Date <span class="label-text-alt text-error">*</span>
</label>
<input
id="manual-end-date"
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"
@@ -302,11 +261,11 @@ function clearForm() {
</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 class="label pb-2 font-medium" for="manual-end-time">
End Time <span class="label-text-alt text-error">*</span>
</label>
<input
id="manual-end-time"
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"
@@ -317,10 +276,11 @@ function clearForm() {
<!-- Description Row -->
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Description</span>
<label class="label pb-2 font-medium" for="manual-description">
Description
</label>
<input
id="manual-description"
v-model="description"
type="text"
placeholder="What did you work on?"
@@ -331,9 +291,7 @@ function clearForm() {
<!-- 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>
<label class="label pb-2 font-medium" for="manual-tags"> Tags </label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in tags"
@@ -341,7 +299,7 @@ function clearForm() {
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTags.includes(tag.id)
selectedTagId === tag.id
? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50',
]"

View File

@@ -1,12 +1,12 @@
<template>
<div style="position: relative; height: 100%; width: 100%;">
<div style="position: relative; height: 100%; width: 100%">
<Bar :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import { computed } from "vue";
import { Bar } from "vue-chartjs";
import {
Chart as ChartJS,
BarElement,
@@ -14,10 +14,18 @@ import {
LinearScale,
Tooltip,
Legend,
BarController
} from 'chart.js';
BarController,
type ChartOptions,
} from "chart.js";
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController,
);
interface MemberData {
name: string;
@@ -29,58 +37,62 @@ const props = defineProps<{
}>();
const chartData = computed(() => ({
labels: props.members.map(m => m.name),
datasets: [{
label: 'Time Tracked',
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: '#10b981',
borderColor: '#059669',
borderWidth: 1,
}]
labels: props.members.map((m) => m.name),
datasets: [
{
label: "Time Tracked",
data: props.members.map((m) => m.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: "#10b981",
borderColor: "#059669",
borderWidth: 1,
},
],
}));
const chartOptions = {
indexAxis: 'y' as const,
const chartOptions: ChartOptions<"bar"> = {
indexAxis: "y" as const,
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
beginAtZero: true,
ticks: {
color: '#e2e8f0',
callback: function(value: number) {
const hours = Math.floor(value / 60);
const mins = value % 60;
color: "#e2e8f0",
callback: function (value: string | number) {
const numValue =
typeof value === "string" ? parseFloat(value) : value;
const hours = Math.floor(numValue / 60);
const mins = Math.round(numValue % 60);
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
},
},
grid: {
color: '#334155'
}
color: "#334155",
},
},
y: {
ticks: {
color: '#e2e8f0'
color: "#e2e8f0",
},
grid: {
display: false
}
}
display: false,
},
},
},
plugins: {
legend: {
display: false
display: false,
},
tooltip: {
callbacks: {
label: function(context: any) {
const minutes = Math.round(context.raw);
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
}
}
}
}
},
},
},
},
};
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div style="position: relative; height: 100%; width: 100%">
<Doughnut :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { Doughnut } from "vue-chartjs";
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend,
type ChartOptions,
} from "chart.js";
ChartJS.register(ArcElement, Tooltip, Legend);
interface TagData {
name: string;
totalTime: number;
color: string;
}
const props = defineProps<{
tags: TagData[];
}>();
const chartData = computed(() => ({
labels: props.tags.map((t) => t.name),
datasets: [
{
data: props.tags.map((t) => t.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: props.tags.map((t) => t.color),
borderColor: "#1e293b", // Matches typical dark mode bg
borderWidth: 2,
},
],
}));
const chartOptions: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: "#e2e8f0",
usePointStyle: true,
padding: 20,
},
},
tooltip: {
callbacks: {
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
},
},
},
},
cutout: "70%",
};
</script>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
const theme = ref('macchiato');
onMounted(() => {
const stored = localStorage.getItem('theme');
if (stored) {
theme.value = stored;
document.documentElement.setAttribute('data-theme', stored);
}
});
function toggleTheme() {
const newTheme = theme.value === 'macchiato' ? 'latte' : 'macchiato';
theme.value = newTheme;
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
</script>
<template>
<button
@click="toggleTheme"
class="btn btn-ghost btn-circle"
aria-label="Toggle Theme"
>
<Icon
:icon="theme === 'macchiato' ? 'heroicons:moon' : 'heroicons:sun'"
class="w-5 h-5"
/>
</button>
</template>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { Icon } from "@iconify/vue";
const props = defineProps<{
initialRunningEntry: {
startTime: number;
description: string | null;
clientId: string;
categoryId: string;
tagId?: string;
} | null;
clients: { id: string; name: string }[];
categories: { id: string; name: string; color: string | null }[];
tags: { id: string; name: string; color: string | null }[];
}>();
@@ -18,8 +18,7 @@ const startTime = ref<number | null>(null);
const elapsedTime = ref(0);
const description = ref("");
const selectedClientId = ref("");
const selectedCategoryId = ref("");
const selectedTags = ref<string[]>([]);
const selectedTagId = ref<string | null>(null);
let interval: ReturnType<typeof setInterval> | null = null;
function formatTime(ms: number) {
@@ -30,7 +29,6 @@ function formatTime(ms: number) {
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
// Calculate rounded version
const totalMinutes = Math.round(ms / 1000 / 60);
const roundedHours = Math.floor(totalMinutes / 60);
const roundedMinutes = totalMinutes % 60;
@@ -49,11 +47,10 @@ function formatTime(ms: number) {
}
function toggleTag(tagId: string) {
const index = selectedTags.value.indexOf(tagId);
if (index > -1) {
selectedTags.value.splice(index, 1);
if (selectedTagId.value === tagId) {
selectedTagId.value = null;
} else {
selectedTags.value.push(tagId);
selectedTagId.value = tagId;
}
}
@@ -63,7 +60,7 @@ onMounted(() => {
startTime.value = props.initialRunningEntry.startTime;
description.value = props.initialRunningEntry.description || "";
selectedClientId.value = props.initialRunningEntry.clientId;
selectedCategoryId.value = props.initialRunningEntry.categoryId;
selectedTagId.value = props.initialRunningEntry.tagId || null;
elapsedTime.value = Date.now() - startTime.value;
interval = setInterval(() => {
elapsedTime.value = Date.now() - startTime.value!;
@@ -81,19 +78,13 @@ async function startTimer() {
return;
}
if (!selectedCategoryId.value) {
alert("Please select a category");
return;
}
const res = await fetch("/api/time-entries/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: description.value,
clientId: selectedClientId.value,
categoryId: selectedCategoryId.value,
tags: selectedTags.value,
tagId: selectedTagId.value,
}),
});
@@ -119,8 +110,7 @@ async function stopTimer() {
startTime.value = null;
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
selectedTagId.value = null;
window.location.reload();
}
}
@@ -131,55 +121,31 @@ async function stopTimer() {
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200"
>
<div class="card-body gap-6">
<!-- Client and Description Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Client</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="isRunning"
>
<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>
</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="isRunning"
>
<option value="">Select a category...</option>
<option
v-for="category in categories"
:key="category.id"
:value="category.id"
>
{{ category.name }}
</option>
</select>
</div>
<!-- Client Row -->
<div class="form-control">
<label class="label pb-2 font-medium" for="timer-client">
Client
</label>
<select
id="timer-client"
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="isRunning"
>
<option value="">Select a client...</option>
<option v-for="client in clients" :key="client.id" :value="client.id">
{{ client.name }}
</option>
</select>
</div>
<!-- Description Row -->
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Description</span>
<label class="label pb-2 font-medium" for="timer-description">
Description
</label>
<input
id="timer-description"
v-model="description"
type="text"
placeholder="What are you working on?"
@@ -190,9 +156,7 @@ async function stopTimer() {
<!-- 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>
<label class="label pb-2 font-medium" for="timer-tags"> Tags </label>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in tags"
@@ -200,7 +164,7 @@ async function stopTimer() {
@click="toggleTag(tag.id)"
:class="[
'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTags.includes(tag.id)
selectedTagId === tag.id
? 'badge-primary shadow-lg shadow-primary/20'
: 'badge-outline hover:bg-base-300/50',
]"
@@ -224,14 +188,16 @@ async function stopTimer() {
@click="startTimer"
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
v-else
@click="stopTimer"
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
>
Stop Timer
<Icon icon="heroicons:stop" class="w-5 h-5" />
Stop Timer
</button>
</div>
</div>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
import { startAuthentication } from "@simplewebauthn/browser";
const loading = ref(false);
const error = ref<string | null>(null);
async function handlePasskeyLogin() {
loading.value = true;
error.value = null;
try {
const resp = await fetch("/api/auth/passkey/login/start");
if (!resp.ok) {
throw new Error("Failed to start passkey login");
}
const options = await resp.json();
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON: options });
} catch (err) {
if ((err as any).name === "NotAllowedError") {
return;
}
console.error(err);
error.value = "Failed to authenticate with passkey";
return;
}
const verificationResp = await fetch("/api/auth/passkey/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(asseResp),
});
const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) {
window.location.href = "/dashboard";
} else {
error.value = "Login failed. Please try again.";
}
} catch (err) {
console.error("Error during passkey login:", err);
error.value = "An error occurred during login";
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<button
class="btn btn-secondary w-full"
@click="handlePasskeyLogin"
:disabled="loading"
>
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<Icon v-else icon="heroicons:finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey
</button>
<div v-if="error" role="alert" class="alert alert-error mt-4">
<Icon icon="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{{ error }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
interface ApiToken {
id: string;
name: string;
lastUsedAt: string | null;
createdAt: string;
}
const props = defineProps<{
initialTokens: ApiToken[];
}>();
const tokens = ref<ApiToken[]>(props.initialTokens);
const createModalOpen = ref(false);
const showTokenModalOpen = ref(false);
const newTokenName = ref("");
const newTokenValue = ref("");
const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return "Never";
return new Date(dateString).toLocaleDateString();
}
async function createToken() {
if (!newTokenName.value) return;
loading.value = true;
try {
const response = await fetch("/api/user/tokens", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: newTokenName.value }),
});
if (response.ok) {
const data = await response.json();
const { token, ...tokenMeta } = data;
tokens.value.unshift({
id: tokenMeta.id,
name: tokenMeta.name,
lastUsedAt: tokenMeta.lastUsedAt,
createdAt: tokenMeta.createdAt,
});
newTokenValue.value = token;
createModalOpen.value = false;
showTokenModalOpen.value = true;
newTokenName.value = "";
} else {
alert("Failed to create token");
}
} catch (error) {
console.error("Error creating token:", error);
alert("An error occurred");
} finally {
loading.value = false;
}
}
async function deleteToken(id: string) {
if (
!confirm(
"Are you sure you want to revoke this token? Any applications using it will stop working.",
)
) {
return;
}
try {
const response = await fetch(`/api/user/tokens/${id}`, {
method: "DELETE",
});
if (response.ok) {
tokens.value = tokens.value.filter((t) => t.id !== id);
} else {
alert("Failed to delete token");
}
} catch (error) {
console.error("Error deleting token:", error);
alert("An error occurred");
}
}
function copyToken() {
navigator.clipboard.writeText(newTokenValue.value);
}
function closeShowTokenModal() {
showTokenModalOpen.value = false;
newTokenValue.value = "";
}
</script>
<template>
<div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon
icon="heroicons:code-bracket-square"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
API Tokens
</h2>
<button
class="btn btn-primary btn-sm"
@click="createModalOpen = true"
>
<Icon icon="heroicons:plus" class="w-4 h-4" />
Create Token
</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="tokens.length === 0">
<td colspan="4" class="text-center text-base-content/60 py-4">
No API tokens found. Create one to access the API.
</td>
</tr>
<tr v-else v-for="token in tokens" :key="token.id">
<td class="font-medium">{{ token.name }}</td>
<td class="text-sm">
<span v-if="isMounted">{{
formatDate(token.lastUsedAt)
}}</span>
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
<span v-if="isMounted">{{
formatDate(token.createdAt)
}}</span>
<span v-else>{{ token.createdAt }}</span>
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
@click="deleteToken(token.id)"
>
<Icon icon="heroicons:trash" class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Create Token Modal -->
<dialog class="modal" :class="{ 'modal-open': createModalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg">Create API Token</h3>
<p class="py-4 text-sm text-base-content/70">
API tokens allow you to authenticate with the API programmatically.
Give your token a descriptive name.
</p>
<form @submit.prevent="createToken" class="space-y-4">
<div class="form-control">
<label class="label pb-2 font-medium" for="token-name">
Token Name
</label>
<input
type="text"
id="token-name"
v-model="newTokenName"
placeholder="e.g. CI/CD Pipeline"
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" class="btn" @click="createModalOpen = false">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span
v-if="loading"
class="loading loading-spinner loading-sm"
></span>
Generate Token
</button>
</div>
</form>
</div>
<form
method="dialog"
class="modal-backdrop"
@click="createModalOpen = false"
>
<button>close</button>
</form>
</dialog>
<!-- Show Token Modal -->
<dialog class="modal" :class="{ 'modal-open': showTokenModalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg text-success flex items-center gap-2">
<Icon icon="heroicons:check-circle" class="w-6 h-6" />
Token Created
</h3>
<p class="py-4">
Make sure to copy your personal access token now. You won't be able to
see it again!
</p>
<div
class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group"
>
<span>{{ newTokenValue }}</span>
<button
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
@click="copyToken"
title="Copy to clipboard"
>
<Icon icon="heroicons:clipboard" class="w-4 h-4" />
</button>
</div>
<div class="modal-action">
<button class="btn btn-primary" @click="closeShowTokenModal">
Done
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
<button>close</button>
</form>
</dialog>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
import { startRegistration } from "@simplewebauthn/browser";
interface Passkey {
id: string;
deviceType: string;
backedUp: boolean;
lastUsedAt: string | null;
createdAt: string | null;
}
const props = defineProps<{
initialPasskeys: Passkey[];
}>();
const passkeys = ref<Passkey[]>(props.initialPasskeys);
const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return "N/A";
return new Date(dateString).toLocaleDateString();
}
async function registerPasskey() {
loading.value = true;
try {
const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) {
throw new Error("Failed to start registration");
}
const options = await resp.json();
let attResp;
try {
attResp = await startRegistration({ optionsJSON: options });
} catch (error) {
if ((error as any).name === "NotAllowedError") {
return;
}
console.error(error);
alert("Failed to register passkey: " + (error as any).message);
return;
}
const verificationResp = await fetch("/api/auth/passkey/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attResp),
});
const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) {
window.location.reload();
} else {
alert("Passkey registration failed");
}
} catch (error) {
console.error("Error registering passkey:", error);
alert("An error occurred");
} finally {
loading.value = false;
}
}
async function deletePasskey(id: string) {
if (!confirm("Are you sure you want to remove this passkey?")) {
return;
}
try {
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
method: "DELETE",
});
if (response.ok) {
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
} else {
alert("Failed to delete passkey");
}
} catch (error) {
console.error("Error deleting passkey:", error);
alert("An error occurred");
}
}
</script>
<template>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
Passkeys
</h2>
<button
class="btn btn-primary btn-sm"
@click="registerPasskey"
:disabled="loading"
>
<span
v-if="loading"
class="loading loading-spinner loading-xs"
></span>
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
Add Passkey
</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Device Type</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="passkeys.length === 0">
<td colspan="4" class="text-center text-base-content/60 py-4">
No passkeys found. Add one to sign in without a password.
</td>
</tr>
<tr v-else v-for="pk in passkeys" :key="pk.id">
<td class="font-medium">
{{
pk.deviceType === "singleDevice"
? "This Device"
: "Cross-Platform (Phone/Key)"
}}
<span v-if="pk.backedUp" class="badge badge-xs badge-info ml-2"
>Backed Up</span
>
</td>
<td class="text-sm">
<span v-if="isMounted">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
</span>
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
<span v-if="isMounted">{{ formatDate(pk.createdAt) }}</span>
<span v-else>{{ pk.createdAt || "N/A" }}</span>
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
@click="deletePasskey(pk.id)"
>
<Icon icon="heroicons:trash" class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
const currentPassword = ref("");
const newPassword = ref("");
const confirmPassword = ref("");
const loading = ref(false);
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
message.value = { type: "error", text: "New passwords do not match" };
return;
}
if (newPassword.value.length < 8) {
message.value = {
type: "error",
text: "Password must be at least 8 characters",
};
return;
}
loading.value = true;
message.value = null;
try {
const response = await fetch("/api/user/change-password", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword: currentPassword.value,
newPassword: newPassword.value,
confirmPassword: confirmPassword.value,
}),
});
if (response.ok) {
message.value = {
type: "success",
text: "Password changed successfully!",
};
currentPassword.value = "";
newPassword.value = "";
confirmPassword.value = "";
setTimeout(() => {
message.value = null;
}, 3000);
} else {
const data = await response.json().catch(() => ({}));
message.value = {
type: "error",
text: data.error || "Failed to change password",
};
}
} catch (error) {
message.value = { type: "error", text: "An error occurred" };
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- Success/Error Message Display -->
<div
v-if="message"
:class="[
'alert mb-6',
message.type === 'success' ? 'alert-success' : 'alert-error',
]"
>
<Icon
:icon="
message.type === 'success'
? 'heroicons:check-circle'
: 'heroicons:exclamation-circle'
"
class="w-6 h-6 shrink-0"
/>
<span>{{ message.text }}</span>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon icon="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
Change Password
</h2>
<form @submit.prevent="changePassword" class="space-y-5">
<div class="form-control">
<label
class="label pb-2 font-medium text-sm sm:text-base"
for="current-password"
>
Current Password
</label>
<input
type="password"
id="current-password"
v-model="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label
class="label pb-2 font-medium text-sm sm:text-base"
for="new-password"
>
New Password
</label>
<input
type="password"
id="new-password"
v-model="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
minlength="8"
/>
<div class="label pt-2">
<span
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
>Minimum 8 characters</span
>
</div>
</div>
<div class="form-control">
<label
class="label pb-2 font-medium text-sm sm:text-base"
for="confirm-password"
>
Confirm New Password
</label>
<input
type="password"
id="confirm-password"
v-model="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
minlength="8"
/>
</div>
<div class="flex justify-end pt-4">
<button
type="submit"
class="btn btn-primary w-full sm:w-auto"
:disabled="loading"
>
<span
v-if="loading"
class="loading loading-spinner loading-sm"
></span>
<Icon v-else icon="heroicons:lock-closed" class="w-5 h-5" />
Update Password
</button>
</div>
</form>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
const props = defineProps<{
user: {
id: string;
name: string;
email: string;
};
}>();
const name = ref(props.user.name);
const loading = ref(false);
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
async function updateProfile() {
loading.value = true;
message.value = null;
try {
const response = await fetch("/api/user/update-profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: name.value }),
});
if (response.ok) {
message.value = {
type: "success",
text: "Profile updated successfully!",
};
setTimeout(() => {
message.value = null;
}, 3000);
} else {
const data = await response.json().catch(() => ({}));
message.value = {
type: "error",
text: data.error || "Failed to update profile",
};
}
} catch (error) {
message.value = { type: "error", text: "An error occurred" };
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<!-- Success/Error Message Display -->
<div
v-if="message"
:class="[
'alert mb-6',
message.type === 'success' ? 'alert-success' : 'alert-error',
]"
>
<Icon
:icon="
message.type === 'success'
? 'heroicons:check-circle'
: 'heroicons:exclamation-circle'
"
class="w-6 h-6 shrink-0"
/>
<span>{{ message.text }}</span>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon icon="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
Profile Information
</h2>
<form @submit.prevent="updateProfile" class="space-y-5">
<div class="form-control">
<label
class="label pb-2 font-medium text-sm sm:text-base"
for="profile-name"
>
Full Name
</label>
<input
type="text"
id="profile-name"
v-model="name"
placeholder="Your full name"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label
class="label pb-2 font-medium text-sm sm:text-base"
for="profile-email"
>
Email
</label>
<input
type="email"
id="profile-email"
:value="props.user.email"
class="input input-bordered w-full"
disabled
/>
<div class="label pt-2">
<span
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
>Email cannot be changed</span
>
</div>
</div>
<div class="flex justify-end pt-4">
<button
type="submit"
class="btn btn-primary w-full sm:w-auto"
:disabled="loading"
>
<span
v-if="loading"
class="loading loading-spinner loading-sm"
></span>
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
</template>

View File

@@ -1,22 +1,41 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
import path from "path";
import fs from "fs";
let _db: ReturnType<typeof drizzle> | null = null;
type Database = ReturnType<typeof drizzle<typeof schema>>;
function initDb() {
let _db: Database | null = null;
function initDb(): Database {
if (!_db) {
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'chronus.db');
const sqlite = new Database(dbUrl, { readonly: false });
_db = drizzle(sqlite, { schema });
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");
}
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;
}
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
export const db = new Proxy({} as Database, {
get(_target, prop) {
const database = initDb();
return database[prop as keyof typeof database];
}
return database[prop as keyof Database];
},
});

View File

@@ -5,6 +5,7 @@ import {
real,
primaryKey,
foreignKey,
index,
} from "drizzle-orm/sqlite-core";
import { nanoid } from "nanoid";
@@ -26,11 +27,14 @@ export const organizations = sqliteTable("organizations", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
logoUrl: text("logo_url"),
street: text("street"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
defaultTaxRate: real("default_tax_rate").default(0),
defaultCurrency: text("default_currency").default("USD"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -56,6 +60,10 @@ export const members = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
userIdIdx: index("members_user_id_idx").on(table.userId),
organizationIdIdx: index("members_organization_id_idx").on(
table.organizationId,
),
}),
);
@@ -68,6 +76,12 @@ export const clients = sqliteTable(
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
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(
() => new Date(),
),
@@ -77,11 +91,14 @@ export const clients = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("clients_organization_id_idx").on(
table.organizationId,
),
}),
);
export const categories = sqliteTable(
"categories",
export const tags = sqliteTable(
"tags",
{
id: text("id")
.primaryKey()
@@ -89,6 +106,7 @@ export const categories = sqliteTable(
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
color: text("color"),
rate: integer("rate").default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -98,6 +116,9 @@ export const categories = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("tags_organization_id_idx").on(
table.organizationId,
),
}),
);
@@ -110,10 +131,11 @@ export const timeEntries = sqliteTable(
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(),
categoryId: text("category_id").notNull(),
tagId: text("tag_id"),
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
endTime: integer("end_time", { mode: "timestamp" }),
description: text("description"),
invoiceId: text("invoice_id"),
isManual: integer("is_manual", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
@@ -132,50 +154,18 @@ export const timeEntries = sqliteTable(
columns: [table.clientId],
foreignColumns: [clients.id],
}),
categoryFk: foreignKey({
columns: [table.categoryId],
foreignColumns: [categories.id],
}),
}),
);
export const tags = sqliteTable(
"tags",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
color: text("color"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
}),
);
export const timeEntryTags = sqliteTable(
"time_entry_tags",
{
timeEntryId: text("time_entry_id").notNull(),
tagId: text("tag_id").notNull(),
},
(table: any) => ({
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
timeEntryFk: foreignKey({
columns: [table.timeEntryId],
foreignColumns: [timeEntries.id],
}),
tagFk: foreignKey({
columns: [table.tagId],
foreignColumns: [tags.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),
tagIdIdx: index("time_entries_tag_id_idx").on(table.tagId),
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
}),
);
@@ -191,6 +181,7 @@ export const sessions = sqliteTable(
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("sessions_user_id_idx").on(table.userId),
}),
);
@@ -225,6 +216,7 @@ export const apiTokens = sqliteTable(
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
}),
);
@@ -237,16 +229,19 @@ export const invoices = sqliteTable(
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'
type: text("type").notNull().default("invoice"),
status: text("status").notNull().default("draft"),
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
notes: text("notes"),
currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().default(0), // in cents
taxRate: real("tax_rate").default(0), // percentage
taxAmount: integer("tax_amount").notNull().default(0), // in cents
total: integer("total").notNull().default(0), // in cents
subtotal: integer("subtotal").notNull().default(0),
discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"),
discountAmount: integer("discount_amount").default(0),
taxRate: real("tax_rate").default(0),
taxAmount: integer("tax_amount").notNull().default(0),
total: integer("total").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -260,6 +255,10 @@ export const invoices = sqliteTable(
columns: [table.clientId],
foreignColumns: [clients.id],
}),
organizationIdIdx: index("invoices_organization_id_idx").on(
table.organizationId,
),
clientIdIdx: index("invoices_client_id_idx").on(table.clientId),
}),
);
@@ -272,13 +271,47 @@ export const invoiceItems = sqliteTable(
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
unitPrice: integer("unit_price").notNull().default(0),
amount: integer("amount").notNull().default(0),
},
(table: any) => ({
invoiceFk: foreignKey({
columns: [table.invoiceId],
foreignColumns: [invoices.id],
}),
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
}),
);
export const passkeys = sqliteTable(
"passkeys",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
publicKey: text("public_key").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("passkeys_user_id_idx").on(table.userId),
}),
);
export const passkeyChallenges = sqliteTable("passkey_challenges", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
challenge: text("challenge").notNull().unique(),
userId: text("user_id"),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -4,8 +4,8 @@ import { Icon } from 'astro-icon/components';
import { db } from '../db';
import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm';
import Footer from '../components/Footer.astro';
import Avatar from '../components/Avatar.astro';
import ThemeToggle from '../components/ThemeToggle.vue';
import { ClientRouter } from "astro:transitions";
interface Props {
@@ -19,7 +19,6 @@ if (!user) {
return Astro.redirect('/login');
}
// Get user's team memberships
const userMemberships = await db.select({
membership: members,
organization: organizations,
@@ -29,13 +28,12 @@ const userMemberships = await db.select({
.where(eq(members.userId, user.id))
.all();
// Get current team from cookie or use first membership
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
---
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Chronus Dashboard" />
@@ -44,6 +42,10 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
<script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato';
document.documentElement.setAttribute('data-theme', theme);
</script>
</head>
<body class="bg-base-100 h-screen flex flex-col overflow-hidden">
<div class="drawer lg:drawer-open flex-1 overflow-auto">
@@ -57,9 +59,12 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</label>
</div>
<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>
</div>
<div class="flex-none">
<ThemeToggle client:load />
</div>
</div>
<!-- Page content here -->
@@ -73,7 +78,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<!-- Sidebar content here -->
<li class="mb-6">
<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
</a>
</li>
@@ -181,8 +186,15 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</li>
<li>
<form action="/api/auth/logout" method="POST">
<button type="submit" class="w-full text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
<div class="flex justify-between items-center p-2 hover:bg-transparent">
<span class="font-semibold text-sm text-base-content/70 pl-2">Theme</span>
<ThemeToggle client:load />
</div>
</li>
<li>
<form action="/api/auth/logout" method="POST" class="contents">
<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" />
Logout
</button>
@@ -192,6 +204,5 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</div>
</div>
<Footer />
</body>
</html>

View File

@@ -1,6 +1,5 @@
---
import '../styles/global.css';
import Footer from '../components/Footer.astro';
import { ClientRouter } from "astro:transitions";
interface Props {
@@ -11,7 +10,7 @@ const { title } = Astro.props;
---
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Chronus Time Tracking" />
@@ -20,11 +19,14 @@ const { title } = Astro.props;
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
<script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato';
document.documentElement.setAttribute('data-theme', theme);
</script>
</head>
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
<div class="flex-1 overflow-auto">
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
<div class="flex-1 flex flex-col">
<slot />
</div>
<Footer />
</body>
</html>

View File

@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
return null;
}
// Update last used at
await db
.update(apiTokens)
.set({ lastUsedAt: new Date() })

View File

@@ -4,26 +4,14 @@
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
*/
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
const totalMinutes = Math.round(ms / 1000 / 60);
const roundedHours = Math.floor(totalMinutes / 60);
const roundedMinutes = totalMinutes % 60;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
let roundedStr = '';
if (roundedHours > 0) {
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`;
} else {
roundedStr = `${roundedMinutes}m`;
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
return `${timeStr} (${roundedStr})`;
return `${minutes}m`;
}
/**
@@ -33,7 +21,7 @@ export function formatDuration(ms: number): string {
* @returns Formatted duration string or "Running..."
*/
export function formatTimeRange(start: Date, end: Date | null): string {
if (!end) return 'Running...';
if (!end) return "Running...";
const ms = end.getTime() - start.getTime();
return formatDuration(ms);
}

62
src/lib/validation.ts Normal file
View File

@@ -0,0 +1,62 @@
import { db } from "../db";
import { clients, tags as tagsTable } from "../db/schema";
import { eq, and } from "drizzle-orm";
export async function validateTimeEntryResources({
organizationId,
clientId,
tagId,
}: {
organizationId: string;
clientId: string;
tagId?: string | null;
}) {
const client = await db
.select()
.from(clients)
.where(
and(eq(clients.id, clientId), eq(clients.organizationId, organizationId)),
)
.get();
if (!client) {
return { valid: false, error: "Invalid client" };
}
if (tagId) {
const validTag = await db
.select()
.from(tagsTable)
.where(
and(
eq(tagsTable.id, tagId),
eq(tagsTable.organizationId, organizationId),
),
)
.get();
if (!validTag) {
return { valid: false, error: "Invalid tag" };
}
}
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

@@ -40,18 +40,18 @@ const allUsers = await db.select().from(users).all();
<form method="POST" action="/api/admin/settings">
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">
<div class="font-semibold">Allow New Registrations</div>
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
</span>
<input
type="checkbox"
name="registration_enabled"
class="toggle toggle-primary"
checked={registrationEnabled}
/>
<label for="registration_enabled" class="label pb-2 font-medium text-sm sm:text-base">
Allow New Registrations
</label>
<br>
<input
type="checkbox"
name="registration_enabled"
class="toggle toggle-primary shrink-0 mt-1"
checked={registrationEnabled}
/>
</div>
<div class="card-actions justify-end mt-6">

View File

@@ -1,33 +1,37 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { verifyPassword, createSession } from '../../../lib/auth';
import { eq } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { verifyPassword, createSession } from "../../../lib/auth";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const formData = await request.formData();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
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))) {
return new Response('Invalid email or password', { status: 400 });
return redirect("/login?error=invalid_credentials");
}
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set('session_id', sessionId, {
path: '/',
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
sameSite: "lax",
expires: expiresAt,
});
return redirect('/dashboard');
return redirect("/dashboard");
};

View File

@@ -0,0 +1,35 @@
import type { APIRoute } from "astro";
import { db } from "../../../../../db";
import { passkeys } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const DELETE: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
const url = new URL(request.url);
const id = url.searchParams.get("id");
if (!id) {
return new Response(JSON.stringify({ error: "Passkey ID is required" }), {
status: 400,
});
}
try {
await db
.delete(passkeys)
.where(and(eq(passkeys.id, id), eq(passkeys.userId, user.id)));
return new Response(JSON.stringify({ success: true }));
} catch (error) {
return new Response(JSON.stringify({ error: "Failed to delete passkey" }), {
status: 500,
});
}
};

View File

@@ -0,0 +1,102 @@
import type { APIRoute } from "astro";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm";
import { createSession } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, cookies }) => {
const body = await request.json();
const { id } = body;
const passkey = await db.query.passkeys.findFirst({
where: eq(passkeys.id, id),
});
if (!passkey) {
return new Response(JSON.stringify({ error: "Passkey not found" }), {
status: 400,
});
}
const user = await db.query.users.findFirst({
where: eq(users.id, passkey.userId),
});
if (!user) return new Response(null, { status: 400 });
const clientDataJSON = Buffer.from(
body.response.clientDataJSON,
"base64url",
).toString("utf-8");
const clientData = JSON.parse(clientDataJSON);
const challenge = clientData.challenge;
const dbChallenge = await db.query.passkeyChallenges.findFirst({
where: and(
eq(passkeyChallenges.challenge, challenge),
gt(passkeyChallenges.expiresAt, new Date()),
),
});
if (!dbChallenge) {
return new Response(
JSON.stringify({ error: "Invalid or expired challenge" }),
{
status: 400,
},
);
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challenge as string,
expectedOrigin: new URL(request.url).origin,
expectedRPID: new URL(request.url).hostname,
credential: {
id: passkey.id,
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
counter: passkey.counter,
transports: passkey.transports
? JSON.parse(passkey.transports)
: undefined,
},
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 400,
});
}
if (verification.verified) {
const { authenticationInfo } = verification;
await db
.update(passkeys)
.set({
counter: authenticationInfo.newCounter,
lastUsedAt: new Date(),
})
.where(eq(passkeys.id, passkey.id));
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
await db
.delete(passkeyChallenges)
.where(eq(passkeyChallenges.challenge, challenge));
return new Response(JSON.stringify({ verified: true }));
}
return new Response(JSON.stringify({ verified: false }), { status: 400 });
};

View File

@@ -0,0 +1,18 @@
import type { APIRoute } from "astro";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeyChallenges } from "../../../../../db/schema";
export const GET: APIRoute = async ({ request }) => {
const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname,
userVerification: "preferred",
});
await db.insert(passkeyChallenges).values({
challenge: options.challenge,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
return new Response(JSON.stringify(options));
};

View File

@@ -0,0 +1,81 @@
import type { APIRoute } from "astro";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
const body = await request.json();
const clientDataJSON = Buffer.from(
body.response.clientDataJSON,
"base64url",
).toString("utf-8");
const clientData = JSON.parse(clientDataJSON);
const challenge = clientData.challenge;
const dbChallenge = await db.query.passkeyChallenges.findFirst({
where: and(
eq(passkeyChallenges.challenge, challenge),
eq(passkeyChallenges.userId, user.id),
gt(passkeyChallenges.expiresAt, new Date()),
),
});
if (!dbChallenge) {
return new Response(
JSON.stringify({ error: "Invalid or expired challenge" }),
{
status: 400,
},
);
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challenge,
expectedOrigin: new URL(request.url).origin,
expectedRPID: new URL(request.url).hostname,
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 400,
});
}
if (verification.verified && verification.registrationInfo) {
const { registrationInfo } = verification;
const { credential, credentialDeviceType, credentialBackedUp } =
registrationInfo;
await db.insert(passkeys).values({
id: credential.id,
userId: user.id,
publicKey: Buffer.from(credential.publicKey).toString("base64"),
counter: credential.counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: body.response.transports
? JSON.stringify(body.response.transports)
: undefined,
});
await db
.delete(passkeyChallenges)
.where(eq(passkeyChallenges.challenge, challenge));
return new Response(JSON.stringify({ verified: true }));
}
return new Response(JSON.stringify({ verified: false }), { status: 400 });
};

View File

@@ -0,0 +1,44 @@
import type { APIRoute } from "astro";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id),
});
const options = await generateRegistrationOptions({
rpName: "Chronus",
rpID: new URL(request.url).hostname,
userName: user.email,
attestationType: "none",
excludeCredentials: userPasskeys.map((passkey) => ({
id: passkey.id,
transports: passkey.transports
? JSON.parse(passkey.transports)
: undefined,
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await db.insert(passkeyChallenges).values({
challenge: options.challenge,
userId: user.id,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
});
return new Response(JSON.stringify(options));
};

View File

@@ -1,39 +1,53 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, organizations, members, siteSettings } from '../../../db/schema';
import { hashPassword, createSession } from '../../../lib/auth';
import { eq, count, sql } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
users,
organizations,
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 }) => {
const userCountResult = await db.select({ count: count() }).from(users).get();
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
if (!isFirstUser) {
const registrationSetting = await db.select()
const registrationSetting = await db
.select()
.from(siteSettings)
.where(eq(siteSettings.key, 'registration_enabled'))
.where(eq(siteSettings.key, "registration_enabled"))
.get();
const registrationEnabled = registrationSetting?.value === 'true';
const registrationEnabled = registrationSetting?.value === "true";
if (!registrationEnabled) {
return new Response('Registration is currently disabled', { status: 403 });
return redirect("/signup?error=registration_disabled");
}
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
const password = formData.get('password')?.toString();
const name = formData.get("name")?.toString();
const email = formData.get("email")?.toString();
const password = formData.get("password")?.toString();
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) {
return new Response('User already exists', { status: 400 });
return redirect("/signup?error=user_exists");
}
const passwordHash = await hashPassword(password);
@@ -56,18 +70,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
await db.insert(members).values({
userId,
organizationId: orgId,
role: 'owner',
role: "owner",
});
const { sessionId, expiresAt } = await createSession(userId);
cookies.set('session_id', sessionId, {
path: '/',
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'lax',
sameSite: "lax",
expires: expiresAt,
});
return redirect('/dashboard');
return redirect("/dashboard");
};

View File

@@ -1,67 +0,0 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
redirectTo = formData.get("redirectTo")?.toString();
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response("No organization found", { status: 400 });
}
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdmin) {
return new Response("Forbidden", { status: 403 });
}
const hasEntries = await db
.select()
.from(timeEntries)
.where(eq(timeEntries.categoryId, id!))
.get();
if (hasEntries) {
return new Response("Cannot delete category with time entries", {
status: 400,
});
}
await db
.delete(categories)
.where(
and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId),
),
);
if (locals.scopes) {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,72 +0,0 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
redirectTo = formData.get("redirectTo")?.toString();
}
if (!name) {
return new Response("Name is required", { status: 400 });
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response("No organization found", { status: 400 });
}
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdmin) {
return new Response("Forbidden", { status: 403 });
}
await db
.update(categories)
.set({
name,
color: color || null,
})
.where(
and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId),
),
);
if (locals.scopes) {
return new Response(
JSON.stringify({ success: true, id, name, color: color || null }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,59 +0,0 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { categories, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
redirectTo = body.redirectTo;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
redirectTo = formData.get("redirectTo")?.toString();
}
if (!name) {
return new Response("Name is required", { status: 400 });
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
if (!userOrg) {
return new Response("No organization found", { status: 400 });
}
const id = nanoid();
await db.insert(categories).values({
id,
organizationId: userOrg.organizationId,
name,
color: color || null,
});
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, color: color || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,12 +1,7 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import {
clients,
members,
timeEntries,
timeEntryTags,
} from "../../../../db/schema";
import { eq, and, inArray } from "drizzle-orm";
import { clients, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
@@ -57,22 +52,7 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 });
}
const clientEntries = await db
.select({ id: timeEntries.id })
.from(timeEntries)
.where(eq(timeEntries.clientId, id))
.all();
const entryIds = clientEntries.map((e) => e.id);
if (entryIds.length > 0) {
await db
.delete(timeEntryTags)
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.run();
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
}
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
await db.delete(clients).where(eq(clients.id, id)).run();

View File

@@ -16,15 +16,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
let name: 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")) {
const body = await request.json();
name = body.name;
email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else {
const formData = await request.formData();
name = formData.get("name")?.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) {
@@ -74,6 +92,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.set({
name: name.trim(),
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))
.run();
@@ -85,6 +109,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
id,
name: name.trim(),
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,

View File

@@ -12,15 +12,33 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
let name: 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")) {
const body = await request.json();
name = body.name;
email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else {
const formData = await request.formData();
name = formData.get("name")?.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) {
@@ -44,13 +62,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
organizationId: userOrg.organizationId,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
});
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, email: email || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({
id,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect("/dashboard/clients");

View File

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

@@ -1,4 +1,5 @@
import type { APIRoute } from "astro";
import { renderToStream } from "@ceereals/vue-pdf";
import { db } from "../../../../db";
import {
invoices,
@@ -8,97 +9,109 @@ import {
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 }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
if (!id) {
return new Response("Invoice ID is required", { status: 400 });
}
// 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 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("Not authorized", { status: 403 });
}
// Fetch items
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
try {
const { id } = params;
const user = locals.user;
const document = createInvoiceDocument({
invoice: {
...invoice,
notes: invoice.notes || null,
// Ensure null safety for optional fields that might be undefined in some runtimes depending on driver
discountValue: invoice.discountValue ?? null,
discountType: invoice.discountType ?? null,
discountAmount: invoice.discountAmount ?? null,
taxRate: invoice.taxRate ?? null,
},
items,
client: {
name: client?.name || "Deleted Client",
email: client?.email || null,
street: client?.street || null,
city: client?.city || null,
state: client?.state || null,
zip: client?.zip || null,
country: client?.country || null,
},
organization: {
name: organization.name,
street: organization.street || null,
city: organization.city || null,
state: organization.state || null,
zip: organization.zip || null,
country: organization.country || null,
logoUrl: organization.logoUrl || null,
},
});
if (!user || !id) {
return new Response("Unauthorized", { status: 401 });
const stream = await renderToStream(document);
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
}
const buffer = Buffer.concat(chunks);
// Fetch invoice with related data
const invoiceResult = await db
.select({
invoice: invoices,
client: clients,
organization: organizations,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
.where(eq(invoices.id, id))
.get();
if (!invoiceResult) {
return new Response("Invoice not found", { status: 404 });
}
const { invoice, client, organization } = invoiceResult;
// Verify access
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId),
),
)
.get();
if (!membership) {
return new Response("Forbidden", { status: 403 });
}
// Fetch items
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
if (!client) {
return new Response("Client not found", { status: 404 });
}
// Generate PDF using Vue PDF
// Suppress verbose logging from PDF renderer
const originalConsoleLog = console.log;
console.log = () => {};
try {
const pdfDocument = createInvoiceDocument({
invoice,
items,
client,
organization,
});
const stream = await renderToStream(pdfDocument);
// Restore console.log
console.log = originalConsoleLog;
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
return new Response(stream as any, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (pdfError) {
// Restore console.log on error
console.log = originalConsoleLog;
throw pdfError;
}
return new Response(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`,
},
});
} catch (error) {
console.error("Error generating PDF:", error);
return new Response("Error generating PDF", { status: 500 });
return new Response("Failed to generate PDF", { status: 500 });
}
};

View File

@@ -0,0 +1,276 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import {
invoices,
invoiceItems,
timeEntries,
members,
tags,
} from "../../../../db/schema";
import {
eq,
and,
gte,
lte,
isNull,
isNotNull,
inArray,
sql,
desc,
} from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
if (!id) {
return new Response("Invoice ID is required", { status: 400 });
}
const formData = await request.formData();
const startDateStr = formData.get("startDate") as string;
const endDateStr = formData.get("endDate") as string;
const groupByDay = formData.get("groupByDay") === "on";
if (!startDateStr || !endDateStr) {
return new Response("Start date and end date are required", {
status: 400,
});
}
const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr);
// Set end date to end of day
endDate.setHours(23, 59, 59, 999);
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, id))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
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("Not authorized", { status: 403 });
}
if (invoice.status !== "draft") {
return new Response("Can only import time into draft invoices", {
status: 400,
});
}
const entries = await db
.select({
entry: timeEntries,
tag: tags,
})
.from(timeEntries)
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(
and(
eq(timeEntries.organizationId, invoice.organizationId),
eq(timeEntries.clientId, invoice.clientId),
isNull(timeEntries.invoiceId),
isNotNull(timeEntries.endTime),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate),
),
)
.orderBy(desc(timeEntries.startTime));
const processedEntries = new Map<
string,
{
entry: typeof timeEntries.$inferSelect;
rates: number[];
tagNames: string[];
}
>();
for (const { entry, tag } of entries) {
if (!processedEntries.has(entry.id)) {
processedEntries.set(entry.id, {
entry,
rates: [],
tagNames: [],
});
}
const current = processedEntries.get(entry.id)!;
if (tag) {
if (tag.rate && tag.rate > 0) {
current.rates.push(tag.rate);
}
current.tagNames.push(tag.name);
}
}
const newItems: {
id: string;
invoiceId: string;
description: string;
quantity: number;
unitPrice: number;
amount: number;
}[] = [];
const entryIdsToUpdate: string[] = [];
if (groupByDay) {
// Group by YYYY-MM-DD
const days = new Map<
string,
{
date: string;
totalDuration: number; // milliseconds
totalAmount: number; // cents
entries: string[]; // ids
}
>();
for (const { entry, rates } of processedEntries.values()) {
if (!entry.endTime) continue;
const dateKey = entry.startTime.toISOString().split("T")[0];
const duration = entry.endTime.getTime() - entry.startTime.getTime();
const hours = duration / (1000 * 60 * 60);
// Determine rate: max of tags, or 0
const rate = rates.length > 0 ? Math.max(...rates) : 0;
const amount = Math.round(hours * rate);
if (!days.has(dateKey)) {
days.set(dateKey, {
date: dateKey,
totalDuration: 0,
totalAmount: 0,
entries: [],
});
}
const day = days.get(dateKey)!;
day.totalDuration += duration;
day.totalAmount += amount;
day.entries.push(entry.id);
entryIdsToUpdate.push(entry.id);
}
for (const day of days.values()) {
const hours = day.totalDuration / (1000 * 60 * 60);
// Avoid division by zero
const unitPrice = hours > 0 ? Math.round(day.totalAmount / hours) : 0;
newItems.push({
id: nanoid(),
invoiceId: invoice.id,
description: `Time entries for ${day.date} (${day.entries.length} entries)`,
quantity: parseFloat(hours.toFixed(2)),
unitPrice,
amount: day.totalAmount,
});
}
} else {
// Individual items
for (const { entry, rates, tagNames } of processedEntries.values()) {
if (!entry.endTime) continue;
const duration = entry.endTime.getTime() - entry.startTime.getTime();
const hours = duration / (1000 * 60 * 60);
// Determine rate: max of tags, or 0
const rate = rates.length > 0 ? Math.max(...rates) : 0;
const amount = Math.round(hours * rate);
let description = entry.description || "Time Entry";
const dateStr = entry.startTime.toLocaleDateString();
description = `[${dateStr}] ${description}`;
if (tagNames.length > 0) {
description += ` (${tagNames.join(", ")})`;
}
newItems.push({
id: nanoid(),
invoiceId: invoice.id,
description,
quantity: parseFloat(hours.toFixed(2)),
unitPrice: rate,
amount,
});
entryIdsToUpdate.push(entry.id);
}
}
if (newItems.length === 0) {
return redirect(`/dashboard/invoices/${id}?error=no-entries`);
}
// Transaction-like operations
try {
await db.insert(invoiceItems).values(newItems);
if (entryIdsToUpdate.length > 0) {
await db
.update(timeEntries)
.set({ invoiceId: invoice.id })
.where(inArray(timeEntries.id, entryIdsToUpdate));
}
const allItems = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id));
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
let discountAmount = 0;
if (invoice.discountType === "percentage") {
discountAmount = Math.round(
subtotal * ((invoice.discountValue || 0) / 100),
);
} else {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
if (invoice.discountValue && invoice.discountValue > 0) {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
}
}
const taxableAmount = Math.max(0, subtotal - discountAmount);
const taxAmount = Math.round(
taxableAmount * ((invoice.taxRate || 0) / 100),
);
const total = subtotal - discountAmount + taxAmount;
await db
.update(invoices)
.set({
subtotal,
discountAmount,
taxAmount,
total,
})
.where(eq(invoices.id, invoice.id));
return redirect(`/dashboard/invoices/${id}?success=imported`);
} catch (error) {
console.error("Error importing time entries:", error);
return new Response("Failed to import time entries", { status: 500 });
}
};

View File

@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr);
// Convert to cents
const unitPrice = Math.round(unitPriceMajor * 100);
const amount = Math.round(quantity * unitPrice);
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
amount,
});
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);

View File

@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence and check status
const invoice = await db
.select()
.from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
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 });
}
@@ -59,7 +56,6 @@ export const POST: APIRoute = async ({
return new Response("Item ID required", { status: 400 });
}
// Verify item belongs to invoice
const item = await db
.select()
.from(invoiceItems)
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
try {
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);

View File

@@ -35,7 +35,6 @@ export const POST: APIRoute = async ({
return new Response("Invalid status", { status: 400 });
}
// Fetch invoice to verify existence and check ownership
const invoice = await db
.select()
.from(invoices)
@@ -46,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)

View File

@@ -0,0 +1,77 @@
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 });
}
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
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

@@ -4,12 +4,7 @@ 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,
}) => {
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
@@ -38,8 +33,8 @@ export const POST: APIRoute = async ({
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
eq(members.organizationId, invoice.organizationId),
),
)
.get();
@@ -53,6 +48,8 @@ export const POST: APIRoute = async ({
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) {
@@ -64,6 +61,11 @@ export const POST: APIRoute = async ({
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({
@@ -72,6 +74,8 @@ export const POST: APIRoute = async ({
issueDate,
dueDate,
taxRate,
discountType: discountType as "percentage" | "fixed",
discountValue,
notes: notes || null,
})
.where(eq(invoices.id, invoiceId));

View File

@@ -3,7 +3,12 @@ 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 }) => {
export const POST: APIRoute = async ({
request,
redirect,
locals,
cookies,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
@@ -36,7 +41,8 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
}
const membership = currentTeamId
? userMemberships.find((m) => m.organizationId === currentTeamId)
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
if (!membership) {
@@ -72,3 +78,7 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
return new Response("Internal Server Error", { status: 500 });
}
};
export const GET: APIRoute = async ({ redirect }) => {
return redirect("/dashboard/invoices/new");
};

View File

@@ -1,4 +1,6 @@
import type { APIRoute } from "astro";
import { promises as fs } from "fs";
import path from "path";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
@@ -17,6 +19,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
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 defaultTaxRate = formData.get("defaultTaxRate") as string | null;
const defaultCurrency = formData.get("defaultCurrency") as string | null;
const logo = formData.get("logo") as File | null;
if (!organizationId || !name || name.trim().length === 0) {
return new Response("Organization ID and name are required", {
@@ -49,17 +54,61 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
);
}
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,
defaultTaxRate: defaultTaxRate ? parseFloat(defaultTaxRate) : 0,
defaultCurrency: defaultCurrency || "USD",
};
if (logoUrl) {
updateData.logoUrl = logoUrl;
}
await db
.update(organizations)
.set({
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
})
.set(updateData)
.where(eq(organizations.id, organizationId))
.run();

View File

@@ -0,0 +1,162 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members, users, clients, tags } from "../../../db/schema";
import { eq, and, gte, lte, desc } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals, cookies }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
// Get current team from cookie
const currentTeamId = cookies.get("currentTeamId")?.value;
const userMemberships = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) {
return new Response("No organization found", { status: 404 });
}
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
const url = new URL(request.url);
const selectedMemberId = url.searchParams.get("member") || "";
const selectedClientId = url.searchParams.get("client") || "";
const timeRange = url.searchParams.get("range") || "week";
const customFrom = url.searchParams.get("from");
const customTo = url.searchParams.get("to");
const now = new Date();
let startDate = new Date();
let endDate = new Date();
switch (timeRange) {
case "today":
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
break;
case "week":
startDate.setDate(now.getDate() - 7);
break;
case "month":
startDate.setMonth(now.getMonth() - 1);
break;
case "mtd":
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case "ytd":
startDate = new Date(now.getFullYear(), 0, 1);
break;
case "last-month":
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case "custom":
if (customFrom) {
const parts = customFrom.split("-");
startDate = new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
0,
0,
0,
0,
);
}
if (customTo) {
const parts = customTo.split("-");
endDate = new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
23,
59,
59,
999,
);
}
break;
}
const conditions = [
eq(timeEntries.organizationId, userMembership.organizationId),
gte(timeEntries.startTime, startDate),
lte(timeEntries.startTime, endDate),
];
if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entries = await db
.select({
entry: timeEntries,
user: users,
client: clients,
tag: tags,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
// Generate CSV
const headers = [
"Date",
"Start Time",
"End Time",
"Duration (h)",
"Member",
"Client",
"Tag",
"Description",
];
const rows = entries.map((e) => {
const start = e.entry.startTime;
const end = e.entry.endTime;
let duration = 0;
if (end) {
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
}
const tagsStr = e.tag?.name || "";
return [
start.toLocaleDateString(),
start.toLocaleTimeString(),
end ? end.toLocaleTimeString() : "",
end ? duration.toFixed(2) : "Running",
`"${(e.user.name || "").replace(/"/g, '""')}"`,
`"${(e.client.name || "").replace(/"/g, '""')}"`,
`"${tagsStr.replace(/"/g, '""')}"`,
`"${(e.entry.description || "").replace(/"/g, '""')}"`,
].join(",");
});
const csvContent = [headers.join(","), ...rows].join("\n");
return new Response(csvContent, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="time-entries-${startDate.toISOString().split("T")[0]}-to-${endDate.toISOString().split("T")[0]}.csv"`,
},
});
};

View File

@@ -0,0 +1,57 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { tags, members, timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
if (!id) {
return new Response("Tag ID is required", { status: 400 });
}
// Get the tag to check organization
const tag = await db.select().from(tags).where(eq(tags.id, id)).get();
if (!tag) {
return new Response("Tag not found", { status: 404 });
}
// Verify membership and permissions
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, tag.organizationId),
),
)
.get();
if (!membership) {
return new Response("Not authorized", { status: 403 });
}
const isAdmin = membership.role === "owner" || membership.role === "admin";
if (!isAdmin) {
return new Response("Only owners and admins can manage tags", {
status: 403,
});
}
// Remove tag from time entries
await db
.update(timeEntries)
.set({ tagId: null })
.where(eq(timeEntries.tagId, id));
// Delete the tag
await db.delete(tags).where(eq(tags.id, id));
return redirect("/dashboard/team/settings?success=tags");
};

View File

@@ -0,0 +1,77 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { tags, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({
request,
params,
locals,
redirect,
}) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
if (!id) {
return new Response("Tag ID is required", { status: 400 });
}
let name: string | undefined;
let color: string | undefined;
let rate: number | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
rate = body.rate !== undefined ? parseInt(body.rate) : undefined;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
const rateStr = formData.get("rate")?.toString();
rate = rateStr ? parseInt(rateStr) : undefined;
}
// Get the tag to check organization
const tag = await db.select().from(tags).where(eq(tags.id, id)).get();
if (!tag) {
return new Response("Tag not found", { status: 404 });
}
// Verify membership and permissions
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, tag.organizationId),
),
)
.get();
if (!membership) {
return new Response("Not authorized", { status: 403 });
}
const isAdmin = membership.role === "owner" || membership.role === "admin";
if (!isAdmin) {
return new Response("Only owners and admins can manage tags", {
status: 403,
});
}
const updateData: any = {};
if (name) updateData.name = name;
if (color) updateData.color = color;
if (rate !== undefined) updateData.rate = rate;
await db.update(tags).set(updateData).where(eq(tags.id, id));
return redirect("/dashboard/team/settings?success=tags");
};

View File

@@ -0,0 +1,72 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { tags, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
let name: string | undefined;
let color: string | undefined;
let rate: number | undefined;
let organizationId: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
color = body.color;
rate = body.rate ? parseInt(body.rate) : 0;
organizationId = body.organizationId;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
color = formData.get("color")?.toString();
const rateStr = formData.get("rate")?.toString();
rate = rateStr ? parseInt(rateStr) : 0;
organizationId = formData.get("organizationId")?.toString();
}
if (!name || !organizationId) {
return new Response("Name and Organization ID are required", {
status: 400,
});
}
// Verify membership and permissions
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, organizationId),
),
)
.get();
if (!membership) {
return new Response("Not authorized", { status: 403 });
}
const isAdmin = membership.role === "owner" || membership.role === "admin";
if (!isAdmin) {
return new Response("Only owners and admins can manage tags", {
status: 403,
});
}
const id = nanoid();
await db.insert(tags).values({
id,
organizationId,
name,
color: color || null,
rate: rate || 0,
});
return redirect("/dashboard/team/settings?success=tags");
};

View File

@@ -1,86 +1,60 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members } 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' }
}
);
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;
const { description, clientId, startTime, endTime, tagId } = 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' }
}
);
return new Response(JSON.stringify({ error: "Client 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' }
}
);
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' }
}
);
return new Response(JSON.stringify({ error: "End time is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const startDate = new Date(startTime);
const endDate = new Date(endTime);
const timeValidation = validateTimeRange(startTime, endTime);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return new Response(
JSON.stringify({ error: 'Invalid date format' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
if (
!timeValidation.valid ||
!timeValidation.startDate ||
!timeValidation.endDate
) {
return new Response(JSON.stringify({ error: timeValidation.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (endDate <= startDate) {
return new Response(
JSON.stringify({ error: 'End time must be after start time' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
const { startDate, endDate } = timeValidation;
// Get user's organization
const member = await db
@@ -91,57 +65,23 @@ export const POST: APIRoute = async ({ request, locals }) => {
.get();
if (!member) {
return new Response(
JSON.stringify({ error: 'No organization found' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "No organization found" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Verify category belongs to organization
const category = await db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
)
)
.get();
const resourceValidation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
tagId: tagId || null,
});
if (!category) {
return new Response(
JSON.stringify({ error: 'Invalid category' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Verify client belongs to organization
const client = await db
.select()
.from(clients)
.where(
and(
eq(clients.id, clientId),
eq(clients.organizationId, member.organizationId)
)
)
.get();
if (!client) {
return new Response(
JSON.stringify({ error: 'Invalid client' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
if (!resourceValidation.valid) {
return new Response(JSON.stringify({ error: resourceValidation.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const id = nanoid();
@@ -153,23 +93,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
tagId: tagId || null,
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,
@@ -179,17 +109,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' }
}
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error('Error creating manual time entry:', error);
console.error("Error creating manual time entry:", error);
return new Response(
JSON.stringify({ error: 'Failed to create time entry' }),
JSON.stringify({ error: "Failed to create time entry" }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
headers: { "Content-Type": "application/json" },
},
);
}
};

View File

@@ -1,13 +1,9 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
timeEntries,
members,
timeEntryTags,
categories,
} from "../../../db/schema";
import { timeEntries, members } from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid";
import { validateTimeEntryResources } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response("Unauthorized", { status: 401 });
@@ -15,17 +11,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
const body = await request.json();
const description = body.description || "";
const clientId = body.clientId;
const categoryId = body.categoryId;
const tags = body.tags || [];
const tagId = body.tagId || null;
if (!clientId) {
return new Response("Client is required", { status: 400 });
}
if (!categoryId) {
return new Response("Category is required", { status: 400 });
}
const runningEntry = await db
.select()
.from(timeEntries)
@@ -48,19 +39,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
return new Response("No organization found", { status: 400 });
}
const category = await db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId),
),
)
.get();
const validation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
tagId,
});
if (!category) {
return new Response("Invalid category", { status: 400 });
if (!validation.valid) {
return new Response(validation.error, { status: 400 });
}
const startTime = new Date();
@@ -71,20 +57,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
categoryId,
tagId,
startTime,
description,
isManual: false,
});
if (tags.length > 0) {
await db.insert(timeEntryTags).values(
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
})),
);
}
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
};

View File

@@ -1,61 +1,104 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!user) {
return redirect('/login');
if (isJson) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
return redirect("/login");
}
const formData = await request.formData();
const currentPassword = formData.get('currentPassword') as string;
const newPassword = formData.get('newPassword') as string;
const confirmPassword = formData.get('confirmPassword') as string;
let currentPassword, newPassword, confirmPassword;
if (isJson) {
const body = await request.json();
currentPassword = body.currentPassword;
newPassword = body.newPassword;
confirmPassword = body.confirmPassword;
} else {
const formData = await request.formData();
currentPassword = formData.get("currentPassword") as string;
newPassword = formData.get("newPassword") as string;
confirmPassword = formData.get("confirmPassword") as string;
}
if (!currentPassword || !newPassword || !confirmPassword) {
return new Response('All fields are required', { status: 400 });
const msg = "All fields are required";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
if (newPassword !== confirmPassword) {
return new Response('New passwords do not match', { status: 400 });
const msg = "New passwords do not match";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
if (newPassword.length < 8) {
return new Response('Password must be at least 8 characters', { status: 400 });
const msg = "Password must be at least 8 characters";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
try {
// Get current user from database
const dbUser = await db.select()
const dbUser = await db
.select()
.from(users)
.where(eq(users.id, user.id))
.get();
if (!dbUser) {
return new Response('User not found', { status: 404 });
const msg = "User not found";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 404 });
return new Response(msg, { status: 404 });
}
// Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
const passwordMatch = await bcrypt.compare(
currentPassword,
dbUser.passwordHash,
);
if (!passwordMatch) {
return new Response('Current password is incorrect', { status: 400 });
const msg = "Current password is incorrect";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db.update(users)
await db
.update(users)
.set({ passwordHash: hashedPassword })
.where(eq(users.id, user.id))
.run();
return redirect('/dashboard/settings?success=password');
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
return redirect("/dashboard/settings?success=password");
} catch (error) {
console.error('Error changing password:', error);
return new Response('Failed to change password', { status: 500 });
console.error("Error changing password:", error);
const msg = "Failed to change password";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 500 });
return new Response(msg, { status: 500 });
}
};

View File

@@ -12,8 +12,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
const formData = await request.formData();
const name = formData.get("name")?.toString();
let name: string | undefined;
const contentType = request.headers.get("content-type");
if (contentType?.includes("application/json")) {
const body = await request.json();
name = body.name;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
}
if (!name) {
return new Response(JSON.stringify({ error: "Name is required" }), {

View File

@@ -1,30 +1,58 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!user) {
return redirect('/login');
if (isJson) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
return redirect("/login");
}
const formData = await request.formData();
const name = formData.get('name') as string;
let name: string | undefined;
if (isJson) {
const body = await request.json();
name = body.name;
} else {
const formData = await request.formData();
name = formData.get("name") as string;
}
if (!name || name.trim().length === 0) {
return new Response('Name is required', { status: 400 });
const msg = "Name is required";
if (isJson) {
return new Response(JSON.stringify({ error: msg }), { status: 400 });
}
return new Response(msg, { status: 400 });
}
try {
await db.update(users)
await db
.update(users)
.set({ name: name.trim() })
.where(eq(users.id, user.id))
.run();
return redirect('/dashboard/settings?success=profile');
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
return redirect("/dashboard/settings?success=profile");
} catch (error) {
console.error('Error updating profile:', error);
return new Response('Failed to update profile', { status: 500 });
console.error("Error updating profile:", error);
const msg = "Failed to update profile";
if (isJson) {
return new Response(JSON.stringify({ error: msg }), { status: 500 });
}
return new Response(msg, { status: 500 });
}
};

View File

@@ -1,62 +0,0 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { categories, members } from '../../db/schema';
import { eq } 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 allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.all();
---
<DashboardLayout title="Categories - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Categories</h1>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allCategories.map(category => (
<div class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<h2 class="card-title">
{category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${category.color}`}></span>
)}
{category.name}
</h2>
<p class="text-xs text-base-content/60">Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-4">
<a href={`/dashboard/categories/${category.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
</div>
</div>
</div>
))}
</div>
{allCategories.length === 0 && (
<div class="text-center py-12">
<p class="text-base-content/60 mb-4">No categories yet</p>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Your First Category</a>
</div>
)}
</DashboardLayout>

View File

@@ -1,99 +0,0 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { categories, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
// 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 isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/categories');
const category = await db.select()
.from(categories)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userMembership.organizationId)
))
.get();
if (!category) return Astro.redirect('/dashboard/categories');
---
<DashboardLayout title="Edit Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/categories" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Edit Category</h1>
</div>
<div class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<form id="update-form" method="POST" action={`/api/categories/${id}/update`}>
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span>
</label>
<input
type="text"
id="name"
name="name"
value={category.name}
placeholder="Development"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control mt-4">
<label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span>
</label>
<input
type="color"
id="color"
name="color"
value={category.color || '#3b82f6'}
class="input input-bordered w-full h-12"
/>
</div>
</form>
<div class="card-actions justify-between mt-6">
<form method="POST" action={`/api/categories/${id}/delete`} onsubmit="return confirm('Are you sure you want to delete this category?');">
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
<button type="submit" class="btn btn-error btn-outline">Delete Category</button>
</form>
<div class="flex gap-2">
<a href="/dashboard/categories" class="btn btn-ghost">Cancel</a>
<button type="submit" form="update-form" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -1,54 +0,0 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="New Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/categories" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Add New Category</h1>
</div>
<form method="POST" action="/api/categories/create" class="card bg-base-200 shadow-xl border border-base-300">
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
<div class="card-body">
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Development"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span>
</label>
<input
type="color"
id="color"
name="color"
class="input input-bordered w-full h-12"
/>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard/categories" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Category</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -50,7 +50,7 @@ if (!client) return Astro.redirect('/dashboard/clients');
<div class="card-body">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Client Name</span>
Client Name
</label>
<input
type="text"
@@ -58,25 +58,115 @@ if (!client) return Astro.redirect('/dashboard/clients');
name="name"
value={client.name}
placeholder="Acme Corp"
class="input input-bordered"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
Email (optional)
</label>
<input
type="email"
id="email"
name="email"
value={client.email || ''}
placeholder="contact@acme.com"
class="input input-bordered"
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="phone">
Phone (optional)
</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">
Street Address (optional)
</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">
City (optional)
</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">
State / Province (optional)
</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">
Zip / Postal Code (optional)
</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">
Country (optional)
</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">
<button
type="button"

View File

@@ -2,7 +2,7 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime';
@@ -40,12 +40,12 @@ if (!client) return Astro.redirect('/dashboard/clients');
// Get recent activity
const recentEntries = await db.select({
entry: timeEntries,
category: categories,
user: users,
tag: tags,
})
.from(timeEntries)
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(eq(timeEntries.clientId, client.id))
.orderBy(desc(timeEntries.startTime))
.limit(10)
@@ -86,12 +86,34 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
{client.email && (
<div class="flex items-center gap-2 text-base-content/70 mb-4">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div>
)}
<div class="space-y-2 mb-4">
{client.email && (
<div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<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 class="flex gap-2">
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
@@ -159,21 +181,23 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<thead>
<tr>
<th>Description</th>
<th>Category</th>
<th>Tag</th>
<th>User</th>
<th>Date</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
{recentEntries.map(({ entry, category, user: entryUser }) => (
{recentEntries.map(({ entry, tag, user: entryUser }) => (
<tr>
<td>{entry.description || '-'}</td>
<td>
{category ? (
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
{tag ? (
<div class="badge badge-sm badge-outline flex items-center gap-1">
{tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
)}
<span>{tag.name}</span>
</div>
) : '-'}
</td>

View File

@@ -13,31 +13,115 @@ if (!user) return Astro.redirect('/login');
<div class="card-body">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Client Name</span>
Client Name
</label>
<input
type="text"
id="name"
name="name"
placeholder="Acme Corp"
class="input input-bordered"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
Email (optional)
</label>
<input
type="email"
id="email"
name="email"
placeholder="contact@acme.com"
class="input input-bordered"
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="phone">
Phone (optional)
</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">
Street Address (optional)
</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">
City (optional)
</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">
State / Province (optional)
</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">
Zip / Postal Code (optional)
</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">
Country (optional)
</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">
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Client</button>

View File

@@ -2,7 +2,7 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
import { formatDuration } from '../../lib/formatTime';
@@ -41,61 +41,57 @@ if (currentOrg) {
const weekAgo = new Date(now.getTime() - 7 * 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)
.where(and(
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) => {
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
const monthEntries = await db.select()
const monthStats = await db.select({
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries)
.where(and(
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) => {
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
const activeCount = await db.select()
const activeCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, currentOrg.organizationId),
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)
.where(eq(clients.organizationId, currentOrg.organizationId))
.all();
.get();
stats.totalClients = clientCount.length;
stats.totalClients = clientCount?.count || 0;
stats.recentEntries = await db.select({
entry: timeEntries,
client: clients,
category: categories,
tag: tags,
})
.from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
.orderBy(desc(timeEntries.startTime))
.limit(5)
@@ -107,7 +103,7 @@ const hasMembership = userOrgs.length > 0;
---
<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>
<h1 class="text-4xl font-bold text-primary mb-2">
Dashboard
@@ -209,11 +205,16 @@ const hasMembership = userOrgs.length > 0;
</h2>
{stats.recentEntries.length > 0 ? (
<ul class="space-y-3 mt-4">
{stats.recentEntries.map(({ entry, client, category }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
{stats.recentEntries.map(({ entry, client, tag }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tag?.color || '#3b82f6'}`}>
<div class="font-semibold text-sm">{client.name}</div>
<div class="text-xs text-base-content/60 mt-1">
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
<div class="text-xs text-base-content/60 mt-1 flex flex-wrap gap-2 items-center">
<span class="flex gap-1 flex-wrap">
{tag ? (
<span class="badge badge-xs badge-outline">{tag.name}</span>
) : <span class="italic opacity-50">No tag</span>}
</span>
<span>• {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
</div>
</li>
))}

View File

@@ -90,24 +90,32 @@ const isDraft = invoice.status === 'draft';
</button>
</form>
)}
{(invoice.status === 'sent' && invoice.type === 'invoice') && (
{(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 text-white">
<button type="submit" class="btn btn-success">
<Icon name="heroicons:check" class="w-5 h-5" />
Mark Paid
</button>
</form>
)}
{(invoice.status === 'sent' && invoice.type === 'quote') && (
{(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 text-white">
<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" />
@@ -125,12 +133,6 @@ const isDraft = invoice.status === 'draft';
Download PDF
</a>
</li>
<li>
<button type="button" onclick="window.print()">
<Icon name="heroicons:printer" class="w-4 h-4" />
Print
</button>
</li>
{invoice.status !== 'void' && invoice.status !== 'draft' && (
<li>
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
@@ -196,7 +198,19 @@ const isDraft = invoice.status === 'draft';
{client ? (
<div>
<div class="font-bold text-lg">{client.name}</div>
<div class="text-base-content/70">{client.email}</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>
@@ -205,7 +219,8 @@ const isDraft = invoice.status === 'draft';
<!-- Items Table -->
<div class="mb-8">
<table class="w-full">
<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>
@@ -242,25 +257,33 @@ const isDraft = invoice.status === 'draft';
</tr>
)}
</tbody>
</table>
</table>
</div>
</div>
<!-- Add Item Form (Only if Draft) -->
{isDraft && (
<div class="flex justify-end mb-4">
<button onclick="document.getElementById('import_time_modal').showModal()" class="btn btn-sm btn-outline gap-2">
<Icon name="heroicons:clock" class="w-4 h-4" />
Import Time
</button>
</div>
<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..." />
<label class="label text-xs pt-0" for="item-description">Description</label>
<input type="text" id="item-description" 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" />
<label class="label text-xs pt-0" for="item-quantity">Qty</label>
<input type="number" id="item-quantity" 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" />
<label class="label text-xs pt-0" for="item-unit-price">Unit Price ({invoice.currency})</label>
<input type="number" id="item-unit-price" 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">
@@ -278,9 +301,25 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
</div>
{(invoice.taxRate ?? 0) > 0 && (
{(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Tax ({invoice.taxRate}%)</span>
<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>
)}
@@ -303,10 +342,78 @@ const isDraft = invoice.status === 'draft';
{/* Edit Notes (Draft Only) - Simplistic approach */}
{isDraft && !invoice.notes && (
<div class="mt-8 text-center">
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-ghost">Add Notes</a>
<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" for="tax-rate">
Tax Rate (%)
</label>
<input
type="number"
id="tax-rate"
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>
<!-- Import Time Modal -->
<dialog id="import_time_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Import Time Entries</h3>
<p class="py-4">Import billable time entries for this client.</p>
<form method="POST" action={`/api/invoices/${invoice.id}/import-time`}>
<div class="grid grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label" for="start-date">Start Date</label>
<input type="date" id="start-date" name="startDate" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label" for="end-date">End Date</label>
<input type="date" id="end-date" name="endDate" class="input input-bordered" required />
</div>
</div>
<div class="form-control mb-6">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" name="groupByDay" class="checkbox" />
<span class="label-text">Group entries by day</span>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('import_time_modal').close()">Cancel</button>
<button type="submit" class="btn btn-primary">Import</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</DashboardLayout>

View File

@@ -38,6 +38,10 @@ if (!membership) {
// 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`}>
@@ -56,11 +60,12 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
<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 class="label font-semibold" for="invoice-number">
Number
</label>
<input
type="text"
id="invoice-number"
name="number"
class="input input-bordered font-mono"
value={invoice.number}
@@ -70,10 +75,10 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
<!-- Currency -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Currency</span>
<label class="label font-semibold" for="invoice-currency">
Currency
</label>
<select name="currency" class="select select-bordered w-full">
<select id="invoice-currency" 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>
@@ -84,11 +89,12 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
<!-- Issue Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Issue Date</span>
<label class="label font-semibold" for="invoice-issue-date">
Issue Date
</label>
<input
type="date"
id="invoice-issue-date"
name="issueDate"
class="input input-bordered"
value={issueDateStr}
@@ -98,11 +104,12 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Due Date</span>
<label class="label font-semibold" for="invoice-due-date">
{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}
</label>
<input
type="date"
id="invoice-due-date"
name="dueDate"
class="input input-bordered"
value={dueDateStr}
@@ -110,29 +117,53 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
/>
</div>
<!-- Discount -->
<div class="form-control">
<label class="label font-semibold" for="invoice-discount-type">
Discount
</label>
<div class="join w-full">
<select id="invoice-discount-type" 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"
id="invoice-discount-value"
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>
<label class="label font-semibold" for="invoice-tax-rate">
Tax Rate (%)
</label>
<input
type="number"
id="invoice-tax-rate"
name="taxRate"
step="0.01"
min="0"
max="100"
class="input input-bordered"
value={invoice.taxRate}
/>
</div>
</div>
<!-- Notes -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Notes / Terms</span>
<div class="form-control flex flex-col">
<label class="label font-semibold" for="invoice-notes">
Notes / Terms
</label>
<textarea
id="invoice-notes"
name="notes"
class="textarea textarea-bordered h-32 font-mono text-sm"
placeholder="Payment terms, bank details, or thank you notes..."

View File

@@ -3,7 +3,7 @@ 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';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -25,17 +25,77 @@ const userMembership = currentTeamId
const currentTeamIdResolved = userMembership.organizationId;
// Fetch invoices and quotes
const allInvoices = await db.select({
// Get filter parameters
const currentYear = new Date().getFullYear();
const yearParam = Astro.url.searchParams.get('year');
const selectedYear = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam);
const selectedType = Astro.url.searchParams.get('type') || 'all';
const selectedStatus = Astro.url.searchParams.get('status') || 'all';
const sortBy = Astro.url.searchParams.get('sort') || 'date-desc';
// Fetch all invoices for the organization (for year dropdown)
const allInvoicesRaw = 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();
// Get unique years from invoices
const availableYears = [...new Set(allInvoicesRaw.map(i => i.invoice.issueDate.getFullYear()))].sort((a, b) => b - a);
// Ensure current year is in the list
if (!availableYears.includes(currentYear)) {
availableYears.unshift(currentYear);
}
// Filter by year
const yearStart = selectedYear === 'current' ? new Date(currentYear, 0, 1) : new Date(selectedYear, 0, 1);
const yearEnd = selectedYear === 'current' ? new Date() : new Date(selectedYear, 11, 31, 23, 59, 59);
let filteredInvoices = allInvoicesRaw.filter(i => {
const issueDate = i.invoice.issueDate;
return issueDate >= yearStart && issueDate <= yearEnd;
});
// Filter by type
if (selectedType !== 'all') {
filteredInvoices = filteredInvoices.filter(i => i.invoice.type === selectedType);
}
// Filter by status
if (selectedStatus !== 'all') {
filteredInvoices = filteredInvoices.filter(i => i.invoice.status === selectedStatus);
}
// Sort invoices
const allInvoices = filteredInvoices.sort((a, b) => {
switch (sortBy) {
case 'date-desc':
return b.invoice.issueDate.getTime() - a.invoice.issueDate.getTime();
case 'date-asc':
return a.invoice.issueDate.getTime() - b.invoice.issueDate.getTime();
case 'amount-desc':
return b.invoice.total - a.invoice.total;
case 'amount-asc':
return a.invoice.total - b.invoice.total;
case 'number-desc':
return b.invoice.number.localeCompare(a.invoice.number);
case 'number-asc':
return a.invoice.number.localeCompare(b.invoice.number);
default:
return b.invoice.issueDate.getTime() - a.invoice.issueDate.getTime();
}
});
// Calculate stats for the selected year
const yearInvoices = allInvoicesRaw.filter(i => {
const issueDate = i.invoice.issueDate;
return issueDate >= yearStart && issueDate <= yearEnd;
});
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -75,8 +135,8 @@ const getStatusColor = (status: string) => {
<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 class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div>
</div>
</div>
@@ -86,7 +146,7 @@ const getStatusColor = (status: string) => {
<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-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
<div class="stat-desc">Waiting for approval</div>
</div>
</div>
@@ -98,18 +158,94 @@ const getStatusColor = (status: string) => {
</div>
<div class="stat-title">Total Revenue</div>
<div class="stat-value text-success">
{formatCurrency(allInvoices
{formatCurrency(yearInvoices
.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 class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Year</span>
</label>
<select name="year" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
{availableYears.map(year => (
<option value={year} selected={year === selectedYear}>{year}</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Type</span>
</label>
<select name="type" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select name="status" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Sort By</span>
</label>
<select name="sort" class="select select-bordered w-full" onchange="this.form.submit()">
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
</select>
</div>
</form>
{(selectedYear !== 'current' || selectedType !== 'all' || selectedStatus !== 'all' || sortBy !== 'date-desc') && (
<div class="mt-4">
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
Clear Filters
</a>
</div>
)}
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-0">
<table class="table table-zebra">
<div class="px-6 py-4 border-b border-base-200 bg-base-200/30">
<p class="text-sm text-base-content/70">
Showing <span class="font-semibold text-base-content">{allInvoices.length}</span>
{allInvoices.length === 1 ? 'result' : 'results'}
{selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`}
</p>
</div>
<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>
@@ -162,7 +298,7 @@ const getStatusColor = (status: string) => {
<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]">
<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" />
@@ -210,6 +346,7 @@ const getStatusColor = (status: string) => {
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -2,7 +2,7 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { clients, members, invoices } from '../../../db/schema';
import { clients, members, invoices, organizations } from '../../../db/schema';
import { eq, desc, and } from 'drizzle-orm';
const user = Astro.locals.user;
@@ -25,6 +25,11 @@ const userMembership = currentTeamId
const currentTeamIdResolved = userMembership.organizationId;
const currentOrganization = await db.select()
.from(organizations)
.where(eq(organizations.id, currentTeamIdResolved))
.get();
// Fetch clients for dropdown
const teamClients = await db.select()
.from(clients)
@@ -47,7 +52,9 @@ if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
const prefix = lastInvoice.number.replace(match[0], '');
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');
}
}
@@ -68,7 +75,9 @@ if (lastQuote) {
const match = lastQuote.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
const prefix = lastQuote.number.replace(match[0], '');
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');
}
}
@@ -104,17 +113,17 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Document Type -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Document Type</span>
<label class="label font-semibold" for="document-type-invoice">
Document Type
</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 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 font-medium" for="document-type-invoice">
<input type="radio" id="document-type-invoice" name="type" value="invoice" class="radio radio-primary" checked />
Invoice
</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 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 font-medium" for="document-type-quote">
<input type="radio" id="document-type-quote" name="type" value="quote" class="radio radio-primary" />
Quote / Estimate
</label>
</div>
</div>
@@ -122,10 +131,10 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<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 class="label font-semibold" for="invoice-client">
Client
</label>
<select name="clientId" class="select select-bordered w-full" required>
<select id="invoice-client" 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>
@@ -135,8 +144,8 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Number</span>
<label class="label font-semibold" for="documentNumber">
Number
</label>
<input
type="text"
@@ -152,11 +161,12 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Issue Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Issue Date</span>
<label class="label font-semibold" for="invoice-issue-date">
Issue Date
</label>
<input
type="date"
id="invoice-issue-date"
name="issueDate"
class="input input-bordered"
value={today}
@@ -166,11 +176,12 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Due Date</span>
<label class="label font-semibold" for="invoice-due-date" id="dueDateLabel">
Due Date
</label>
<input
type="date"
id="invoice-due-date"
name="dueDate"
class="input input-bordered"
value={defaultDueDate}
@@ -180,15 +191,15 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Currency -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Currency</span>
<label class="label font-semibold" for="invoice-currency">
Currency
</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 id="invoice-currency" name="currency" class="select select-bordered w-full">
<option value="USD" selected={currentOrganization?.defaultCurrency === 'USD'}>USD ($)</option>
<option value="EUR" selected={currentOrganization?.defaultCurrency === 'EUR'}>EUR (€)</option>
<option value="GBP" selected={currentOrganization?.defaultCurrency === 'GBP'}>GBP (£)</option>
<option value="CAD" selected={currentOrganization?.defaultCurrency === 'CAD'}>CAD ($)</option>
<option value="AUD" selected={currentOrganization?.defaultCurrency === 'AUD'}>AUD ($)</option>
</select>
</div>
</div>
@@ -212,22 +223,26 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
// 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');
if (numberInput) {
const invoiceNumber = numberInput.dataset.invoiceNumber || 'INV-001';
const quoteNumber = numberInput.dataset.quoteNumber || 'EST-001';
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;
}
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

@@ -26,8 +26,8 @@ if (!user) return Astro.redirect('/login');
</div>
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Team Name</span>
<label class="label pb-2 font-medium" for="name">
Team Name
</label>
<input
type="text"

View File

@@ -1,11 +1,11 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import CategoryChart from '../../components/CategoryChart.vue';
import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, categories, invoices } from '../../db/schema';
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
@@ -37,9 +37,9 @@ const teamMembers = await db.select({
.where(eq(members.organizationId, userMembership.organizationId))
.all();
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, userMembership.organizationId))
.all();
const allClients = await db.select()
@@ -49,9 +49,11 @@ const allClients = await db.select()
const url = new URL(Astro.request.url);
const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
const selectedTagId = url.searchParams.get('tag') || '';
const selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const now = new Date();
let startDate = new Date();
@@ -78,6 +80,16 @@ switch (timeRange) {
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break;
case 'custom':
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
}
break;
}
const conditions = [
@@ -90,8 +102,8 @@ if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
if (selectedTagId) {
conditions.push(eq(timeEntries.tagId, selectedTagId));
}
if (selectedClientId) {
@@ -102,12 +114,12 @@ const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
tag: tags,
})
.from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions))
.orderBy(desc(timeEntries.startTime))
.all();
@@ -128,9 +140,9 @@ const statsByMember = teamMembers.map(member => {
};
}).sort((a, b) => b.totalTime - a.totalTime);
const statsByCategory = allCategories.map(category => {
const categoryEntries = entries.filter(e => e.category.id === category.id);
const totalTime = categoryEntries.reduce((sum, e) => {
const statsByTag = allTags.map(tag => {
const tagEntries = entries.filter(e => e.tag?.id === tag.id);
const totalTime = tagEntries.reduce((sum, e) => {
if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
}
@@ -138,9 +150,9 @@ const statsByCategory = allCategories.map(category => {
}, 0);
return {
category,
tag,
totalTime,
entryCount: categoryEntries.length,
entryCount: tagEntries.length,
};
}).sort((a, b) => b.totalTime - a.totalTime);
@@ -250,6 +262,7 @@ function getTimeRangeLabel(range: string) {
case 'mtd': return 'Month to Date';
case 'ytd': return 'Year to Date';
case 'last-month': return 'Last Month';
case 'custom': return 'Custom Range';
default: return 'Last 7 Days';
}
}
@@ -263,24 +276,56 @@ function getTimeRangeLabel(range: string) {
<div class="card-body">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Time Range</span>
<label class="label font-medium" for="reports-range">
Time Range
</label>
<select name="range" class="select select-bordered" onchange="this.form.submit()">
<select id="reports-range" name="range" class="select select-bordered" onchange="this.form.submit()">
<option value="today" selected={timeRange === 'today'}>Today</option>
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select>
</div>
{timeRange === 'custom' && (
<>
<div class="form-control">
<label class="label font-medium" for="reports-from">
From Date
</label>
<input
type="date"
id="reports-from"
name="from"
class="input input-bordered w-full"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
</div>
<div class="form-control">
<label class="label font-medium" for="reports-to">
To Date
</label>
<input
type="date"
id="reports-to"
name="to"
class="input input-bordered w-full"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
onchange="this.form.submit()"
/>
</div>
</>
)}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Team Member</span>
<label class="label font-medium" for="reports-member">
Team Member
</label>
<select name="member" class="select select-bordered" onchange="this.form.submit()">
<select id="reports-member" name="member" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Members</option>
{teamMembers.map(member => (
<option value={member.id} selected={selectedMemberId === member.id}>
@@ -291,24 +336,24 @@ function getTimeRangeLabel(range: string) {
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Category</span>
<label class="label font-medium" for="reports-tag">
Tag
</label>
<select name="category" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={selectedCategoryId === category.id}>
{category.name}
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Tags</option>
{allTags.map(tag => (
<option value={tag.id} selected={selectedTagId === tag.id}>
{tag.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Client</span>
<label class="label font-medium" for="reports-client">
Client
</label>
<select name="client" class="select select-bordered" onchange="this.form.submit()">
<select id="reports-client" name="client" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={selectedClientId === client.id}>
@@ -318,6 +363,20 @@ function getTimeRangeLabel(range: string) {
</select>
</div>
</form>
<style>
@media (max-width: 767px) {
form {
align-items: stretch !important;
}
.form-control {
width: 100%;
}
}
select, input {
width: 100%;
}
</style>
</div>
</div>
@@ -487,21 +546,21 @@ function getTimeRangeLabel(range: string) {
{totalTime > 0 && (
<>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Category Distribution Chart - Only show when no category filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
{/* Tag Distribution Chart - Only show when no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<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:chart-pie" class="w-6 h-6" />
Category Distribution
Tag Distribution
</h2>
<div class="h-64 w-full">
<CategoryChart
client:load
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
<TagChart
client:visible
tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
name: s.tag.name,
totalTime: s.totalTime,
color: s.category.color || '#3b82f6'
color: s.tag.color || '#3b82f6'
}))}
/>
</div>
@@ -519,7 +578,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<ClientChart
client:load
client:visible
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
name: s.client.name,
totalTime: s.totalTime
@@ -541,7 +600,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<MemberChart
client:load
client:visible
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
name: s.member.name,
totalTime: s.totalTime
@@ -595,33 +654,33 @@ function getTimeRangeLabel(range: string) {
</div>
)}
{/* Stats by Category - Only show if there's data and no category filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
{/* Stats by Tag - Only show if there's data and no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).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:tag" class="w-6 h-6" />
By Category
By Tag
</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Category</th>
<th>Tag</th>
<th>Total Time</th>
<th>Entries</th>
<th>% of Total</th>
</tr>
</thead>
<tbody>
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
{statsByTag.filter(s => s.totalTime > 0).map(stat => (
<tr>
<td>
<div class="flex items-center gap-2">
{stat.category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
{stat.tag.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
)}
<span>{stat.category.name}</span>
<span>{stat.tag.name}</span>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
@@ -695,10 +754,18 @@ function getTimeRangeLabel(range: string) {
{/* Detailed Entries */}
<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" />
Detailed Entries ({entries.length})
</h2>
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:document-text" class="w-6 h-6" />
Detailed Entries ({entries.length})
</h2>
{entries.length > 0 && (
<a href={`/api/reports/export${url.search}`} class="btn btn-sm btn-outline" target="_blank">
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
Export CSV
</a>
)}
</div>
{entries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table table-zebra">
@@ -707,7 +774,7 @@ function getTimeRangeLabel(range: string) {
<th>Date</th>
<th>Member</th>
<th>Client</th>
<th>Category</th>
<th>Tag</th>
<th>Description</th>
<th>Duration</th>
</tr>
@@ -724,12 +791,16 @@ function getTimeRangeLabel(range: string) {
<td>{e.user.name}</td>
<td>{e.client.name}</td>
<td>
<div class="flex items-center gap-2">
{e.category.color && (
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
)}
<span>{e.category.name}</span>
</div>
{e.tag ? (
<div class="badge badge-sm badge-outline flex items-center gap-1">
{e.tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
)}
<span>{e.tag.name}</span>
</div>
) : (
<span class="opacity-50">-</span>
)}
</td>
<td>{e.entry.description || '-'}</td>
<td class="font-mono">

View File

@@ -2,8 +2,12 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { apiTokens } from '../../db/schema';
import { apiTokens, passkeys } from '../../db/schema';
import { eq, desc } from 'drizzle-orm';
import ProfileForm from '../../components/settings/ProfileForm.vue';
import PasswordForm from '../../components/settings/PasswordForm.vue';
import ApiTokenManager from '../../components/settings/ApiTokenManager.vue';
import PasskeyManager from '../../components/settings/PasskeyManager.vue';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -16,6 +20,12 @@ const userTokens = await db.select()
.where(eq(apiTokens.userId, user.id))
.orderBy(desc(apiTokens.createdAt))
.all();
const userPasskeys = await db.select()
.from(passkeys)
.where(eq(passkeys.userId, user.id))
.orderBy(desc(passkeys.createdAt))
.all();
---
<DashboardLayout title="Account Settings - Chronus">
@@ -40,177 +50,25 @@ const userTokens = await db.select()
)}
<!-- Profile Information -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
Profile Information
</h2>
<form action="/api/user/update-profile" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
</label>
<input
type="text"
name="name"
value={user.name}
placeholder="Your full name"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Email</span>
</label>
<input
type="email"
name="email"
value={user.email}
placeholder="your@email.com"
class="input input-bordered w-full"
disabled
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
</div>
</form>
</div>
</div>
<ProfileForm client:load user={user} />
<!-- Change Password -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
Change Password
</h2>
<PasswordForm client:load />
<form action="/api/user/change-password" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
</label>
<input
type="password"
name="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
</label>
<input
type="password"
name="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
minlength="8"
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
</div>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
</label>
<input
type="password"
name="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
minlength="8"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
Update Password
</button>
</div>
</form>
</div>
</div>
<!-- Passkeys -->
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
...pk,
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
}))} />
<!-- API Tokens -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon name="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
API Tokens
</h2>
<button class="btn btn-primary btn-sm" onclick="createTokenModal.showModal()">
<Icon name="heroicons:plus" class="w-4 h-4" />
Create Token
</button>
</div>
<ApiTokenManager client:idle initialTokens={userTokens.map(t => ({
...t,
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
createdAt: t.createdAt ? t.createdAt.toISOString() : ''
}))} />
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Last Used</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{userTokens.length === 0 ? (
<tr>
<td colspan="4" class="text-center text-base-content/60 py-4">
No API tokens found. Create one to access the API.
</td>
</tr>
) : (
userTokens.map(token => (
<tr>
<td class="font-medium">{token.name}</td>
<td class="text-sm">
{token.lastUsedAt ? token.lastUsedAt.toLocaleDateString() : 'Never'}
</td>
<td class="text-sm">
{token.createdAt ? token.createdAt.toLocaleDateString() : 'N/A'}
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
onclick={`deleteToken('${token.id}')`}
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
<!-- Account Info -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title mb-6 text-lg sm:text-xl">
@@ -238,132 +96,5 @@ const userTokens = await db.select()
</div>
</div>
<!-- Create Token Modal -->
<dialog id="createTokenModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Create API Token</h3>
<p class="py-4 text-sm text-base-content/70">
API tokens allow you to authenticate with the API programmatically.
Give your token a descriptive name.
</p>
<form id="createTokenForm" method="dialog" class="space-y-4">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Token Name</span>
</label>
<input
type="text"
name="name"
id="tokenName"
placeholder="e.g. CI/CD Pipeline"
class="input input-bordered w-full"
required
/>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="createTokenModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Generate Token</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Show Token Modal -->
<dialog id="showTokenModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg text-success flex items-center gap-2">
<Icon name="heroicons:check-circle" class="w-6 h-6" />
Token Created
</h3>
<p class="py-4">
Make sure to copy your personal access token now. You won't be able to see it again!
</p>
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
<span id="newTokenDisplay"></span>
<button
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
onclick="copyToken()"
title="Copy to clipboard"
>
<Icon name="heroicons:clipboard" class="w-4 h-4" />
</button>
</div>
<div class="modal-action">
<button class="btn btn-primary" onclick="closeShowTokenModal()">Done</button>
</div>
</div>
</dialog>
<script is:inline>
// Handle Token Creation
const createTokenForm = document.getElementById('createTokenForm');
createTokenForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('tokenName').value;
const formData = new FormData();
formData.append('name', name);
try {
const response = await fetch('/api/user/tokens', {
method: 'POST',
body: formData
});
if (response.ok) {
const data = await response.json();
document.getElementById('createTokenModal').close();
document.getElementById('newTokenDisplay').innerText = data.token;
document.getElementById('showTokenModal').showModal();
document.getElementById('tokenName').value = ''; // Reset form
} else {
alert('Failed to create token');
}
} catch (error) {
console.error('Error creating token:', error);
alert('An error occurred');
}
});
// Handle Token Copy
function copyToken() {
const token = document.getElementById('newTokenDisplay').innerText;
navigator.clipboard.writeText(token);
}
// Handle Closing Show Token Modal (refresh page to show new token in list)
function closeShowTokenModal() {
document.getElementById('showTokenModal').close();
window.location.reload();
}
// Handle Token Deletion
async function deleteToken(id) {
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
return;
}
try {
const response = await fetch(`/api/user/tokens/${id}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to delete token');
}
} catch (error) {
console.error('Error deleting token:', error);
alert('An error occurred');
}
}
</script>
</DashboardLayout>

View File

@@ -38,7 +38,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
---
<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>
<div class="flex gap-2">
{isAdmin && (

View File

@@ -34,13 +34,13 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
<form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="alert alert-info mb-4">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<Icon name="heroicons:information-circle" class="w-6 h-6 shrink-0" />
<span>The user must already have an account. They'll be added to your organization.</span>
</div>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email Address</span>
Email Address
</label>
<input
type="email"
@@ -54,13 +54,13 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
<div class="form-control">
<label class="label" for="role">
<span class="label-text">Role</span>
Role
</label>
<select id="role" name="role" class="select select-bordered" required>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<label class="label">
<label class="label h-auto block">
<span class="label-text-alt">Members can track time. Admins can manage team and clients.</span>
</label>
</div>

View File

@@ -2,7 +2,7 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { categories, members, organizations } from '../../../db/schema';
import { members, organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
@@ -35,9 +35,11 @@ const organization = await db.select()
if (!organization) return Astro.redirect('/dashboard');
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, orgId))
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, orgId))
.all();
const url = new URL(Astro.request.url);
@@ -67,15 +69,58 @@ const successType = url.searchParams.get('success');
</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} />
<label class="form-control">
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Team Name</span>
<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>
<div class="form-control">
<label class="label font-medium" for="team-name">
Team Name
</label>
<input
type="text"
id="team-name"
name="name"
value={organization.name}
placeholder="Organization name"
@@ -85,87 +130,127 @@ const successType = url.searchParams.get('success');
<div class="label">
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span>
</div>
</label>
</div>
<div class="divider">Address Information</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Street Address</span>
</div>
<div class="form-control">
<label class="label font-medium" for="team-street">
Street Address
</label>
<input
type="text"
id="team-street"
name="street"
value={organization.street || ''}
placeholder="123 Main Street"
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">City</span>
</div>
<div class="form-control">
<label class="label font-medium" for="team-city">
City
</label>
<input
type="text"
id="team-city"
name="city"
value={organization.city || ''}
placeholder="City"
class="input input-bordered w-full"
/>
</label>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">State/Province</span>
</div>
<div class="form-control">
<label class="label font-medium" for="team-state">
State/Province
</label>
<input
type="text"
id="team-state"
name="state"
value={organization.state || ''}
placeholder="State/Province"
class="input input-bordered w-full"
/>
</label>
</div>
</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>
<div class="form-control">
<label class="label font-medium" for="team-zip">
Postal Code
</label>
<input
type="text"
id="team-zip"
name="zip"
value={organization.zip || ''}
placeholder="12345"
class="input input-bordered w-full"
/>
</label>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Country</span>
</div>
<div class="form-control">
<label class="label font-medium" for="team-country">
Country
</label>
<input
type="text"
id="team-country"
name="country"
value={organization.country || ''}
placeholder="Country"
class="input input-bordered w-full"
/>
</label>
</div>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
<div class="divider">Defaults</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label font-medium" for="default-tax-rate">
Default Tax Rate (%)
</label>
<input
type="number"
id="default-tax-rate"
name="defaultTaxRate"
step="0.01"
min="0"
max="100"
value={organization.defaultTaxRate || 0}
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label font-medium" for="default-currency">
Default Currency
</label>
<select
id="default-currency"
name="defaultCurrency"
class="select select-bordered w-full"
>
<option value="USD" selected={!organization.defaultCurrency || organization.defaultCurrency === 'USD'}>USD ($)</option>
<option value="EUR" selected={organization.defaultCurrency === 'EUR'}>EUR (€)</option>
<option value="GBP" selected={organization.defaultCurrency === 'GBP'}>GBP (£)</option>
<option value="CAD" selected={organization.defaultCurrency === 'CAD'}>CAD ($)</option>
<option value="AUD" selected={organization.defaultCurrency === 'AUD'}>AUD ($)</option>
</select>
</div>
</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>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:check" class="w-5 h-5" />
Save Changes
</button>
@@ -174,60 +259,159 @@ const successType = url.searchParams.get('success');
</div>
</div>
<!-- Categories Section -->
<!-- Tags Section -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:tag" class="w-6 h-6" />
Work Categories
Tags & Rates
</h2>
<a href="/dashboard/team/settings/categories/new" class="btn btn-primary btn-sm">
{/* We'll use a simple form submission for now or client-side JS for better UX later */}
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-sm">
<Icon name="heroicons:plus" class="w-5 h-5" />
Add Category
</a>
Add Tag
</button>
</div>
<p class="text-base-content/70 mb-4">
Categories help organize time tracking by type of work. All team members use the same categories.
Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes.
</p>
{allCategories.length === 0 ? (
{allTags.length === 0 ? (
<div class="alert alert-info">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<div>
<div class="font-bold">No categories yet</div>
<div class="text-sm">Create your first category to start organizing time entries.</div>
<div class="font-bold">No tags yet</div>
<div class="text-sm">Create tags to add context and rates to your time entries.</div>
</div>
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allCategories.map(category => (
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center gap-3">
{category.color && (
<span class="w-4 h-4 rounded-full shrink-0" style={`background-color: ${category.color}`}></span>
)}
<div class="grow min-w-0">
<h3 class="font-semibold truncate">{category.name}</h3>
<p class="text-xs text-base-content/60">
Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}
</p>
</div>
<a
href={`/dashboard/team/settings/categories/${category.id}/edit`}
class="btn btn-ghost btn-xs"
>
<Icon name="heroicons:pencil" class="w-4 h-4" />
</a>
</div>
</div>
</div>
))}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Rate / Hr</th>
<th class="w-20"></th>
</tr>
</thead>
<tbody>
{allTags.map(tag => (
<tr>
<td>
<div class="flex items-center gap-2">
{tag.color && (
<div class="w-3 h-3 rounded-full" style={`background-color: ${tag.color}`}></div>
)}
<span class="font-medium">{tag.name}</span>
</div>
</td>
<td>
{tag.rate ? (
<span class="font-mono">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
) : (
<span class="text-base-content/40 text-xs italic">No rate</span>
)}
</td>
<td>
<div class="flex gap-2">
<button
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`}
class="btn btn-ghost btn-xs btn-square"
>
<Icon name="heroicons:pencil" class="w-4 h-4" />
</button>
<form method="POST" action={`/api/tags/${tag.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this tag?');">
<button class="btn btn-ghost btn-xs btn-square text-error">
<Icon name="heroicons:trash" class="w-4 h-4" />
</button>
</form>
</div>
{/* Edit Modal */}
<dialog id={`edit_tag_modal_${tag.id}`} class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Edit Tag</h3>
<form method="POST" action={`/api/tags/${tag.id}/update`}>
<div class="form-control w-full mb-4">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" name="name" value={tag.name} class="input input-bordered w-full" required />
</div>
<div class="form-control w-full mb-4">
<label class="label">
<span class="label-text">Color</span>
</label>
<input type="color" name="color" value={tag.color || '#3b82f6'} class="input input-bordered w-full h-12 p-1" />
</div>
<div class="form-control w-full mb-6">
<label class="label">
<span class="label-text">Hourly Rate (cents)</span>
</label>
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<dialog id="new_tag_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">New Tag</h3>
<form method="POST" action="/api/tags/create">
<input type="hidden" name="organizationId" value={organization.id} />
<div class="form-control w-full mb-4">
<label class="label">
<span class="label-text">Name</span>
</label>
<input type="text" name="name" class="input input-bordered w-full" required placeholder="e.g. Billable, Rush" />
</div>
<div class="form-control w-full mb-4">
<label class="label">
<span class="label-text">Color</span>
</label>
<input type="color" name="color" value="#3b82f6" class="input input-bordered w-full h-12 p-1" />
</div>
<div class="form-control w-full mb-6">
<label class="label">
<span class="label-text">Hourly Rate (cents)</span>
</label>
<input type="number" name="rate" value="0" min="0" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('new_tag_modal').close()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Tag</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</DashboardLayout>

View File

@@ -1,93 +0,0 @@
---
import DashboardLayout from '../../../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../../../db';
import { categories, members } from '../../../../../../db/schema';
import { eq, and } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
// 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 isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team/settings');
const category = await db.select()
.from(categories)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userMembership.organizationId)
))
.get();
if (!category) return Astro.redirect('/dashboard/team/settings');
---
<DashboardLayout title="Edit Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Edit Category</h1>
</div>
<form method="POST" action={`/api/categories/${id}/update`} class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span>
</label>
<input
type="text"
id="name"
name="name"
value={category.name}
placeholder="Development"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span>
</label>
<input
type="color"
id="color"
name="color"
value={category.color || '#3b82f6'}
class="input input-bordered w-full h-12"
/>
</div>
<div class="card-actions justify-between mt-6">
<form method="POST" action={`/api/categories/${id}/delete`}>
<button type="submit" class="btn btn-error btn-outline">Delete Category</button>
</form>
<div class="flex gap-2">
<a href="/dashboard/team/settings" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -1,53 +0,0 @@
---
import DashboardLayout from '../../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
---
<DashboardLayout title="New Category - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
</a>
<h1 class="text-3xl font-bold">Add New Category</h1>
</div>
<form method="POST" action="/api/categories/create" class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Category Name</span>
</label>
<input
type="text"
id="name"
name="name"
placeholder="Development"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2" for="color">
<span class="label-text font-medium">Color (optional)</span>
</label>
<input
type="color"
id="color"
name="color"
class="input input-bordered w-full h-12"
/>
</div>
<div class="card-actions justify-end mt-6">
<a href="/dashboard/team/settings" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Category</button>
</div>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -4,7 +4,7 @@ import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue';
import { db } from '../../db';
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
import { timeEntries, clients, members, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime';
@@ -33,11 +33,6 @@ const allClients = await db.select()
.where(eq(clients.organizationId, organizationId))
.all();
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, organizationId))
.all();
const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, organizationId))
@@ -50,7 +45,7 @@ const pageSize = 20;
const offset = (page - 1) * pageSize;
const filterClient = url.searchParams.get('client') || '';
const filterCategory = url.searchParams.get('category') || '';
const filterStatus = url.searchParams.get('status') || '';
const filterType = url.searchParams.get('type') || '';
const sortBy = url.searchParams.get('sort') || 'start-desc';
@@ -62,10 +57,6 @@ if (filterClient) {
conditions.push(eq(timeEntries.clientId, filterClient));
}
if (filterCategory) {
conditions.push(eq(timeEntries.categoryId, filterCategory));
}
if (filterStatus === 'completed') {
conditions.push(sql`${timeEntries.endTime} IS NOT NULL`);
} else if (filterStatus === 'running') {
@@ -107,13 +98,13 @@ switch (sortBy) {
const entries = await db.select({
entry: timeEntries,
client: clients,
category: categories,
user: users,
tag: tags,
})
.from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions))
.orderBy(orderBy)
.limit(pageSize)
@@ -123,9 +114,11 @@ const entries = await db.select({
const runningEntry = await db.select({
entry: timeEntries,
client: clients,
tag: tags,
})
.from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(
eq(timeEntries.userId, user.id),
sql`${timeEntries.endTime} IS NULL`
@@ -164,14 +157,10 @@ const paginationPages = getPaginationPages(page, totalPages);
<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">
<span>You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning">
<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 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>
) : (
<Timer
@@ -180,10 +169,9 @@ const paginationPages = getPaginationPages(page, totalPages);
startTime: runningEntry.entry.startTime.getTime(),
description: runningEntry.entry.description,
clientId: runningEntry.entry.clientId,
categoryId: runningEntry.entry.categoryId,
tagId: runningEntry.tag?.id,
} : 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 }))}
/>
)}
@@ -192,20 +180,15 @@ const paginationPages = getPaginationPages(page, totalPages);
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a category before adding time entries.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
<div 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>
) : (
<ManualEntry
client:load
client:idle
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 }))}
/>
)}
@@ -221,23 +204,24 @@ const paginationPages = getPaginationPages(page, totalPages);
<div class="card-body">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Search</span>
<label class="label font-medium" for="tracker-search">
Search
</label>
<input
type="text"
id="tracker-search"
name="search"
placeholder="Search descriptions..."
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"
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}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Client</span>
<label class="label font-medium" for="tracker-client">
Client
</label>
<select name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<select id="tracker-client" 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>
{allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}>
@@ -247,25 +231,13 @@ const paginationPages = getPaginationPages(page, totalPages);
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Category</span>
</label>
<select name="category" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<option value="">All Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={filterCategory === category.id}>
{category.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Status</span>
<label class="label font-medium" for="tracker-status">
Status
</label>
<select name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<select id="tracker-status" 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="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
@@ -273,10 +245,10 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Entry Type</span>
<label class="label font-medium" for="tracker-type">
Entry Type
</label>
<select name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<select id="tracker-type" 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>
@@ -284,10 +256,10 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Sort By</span>
<label class="label font-medium" for="tracker-sort">
Sort By
</label>
<select name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
<select id="tracker-sort" 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-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
@@ -313,7 +285,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
Time Entries ({totalCount?.count || 0} total)
</h2>
{(filterClient || filterCategory || filterStatus || filterType || searchTerm) && (
{(filterClient || filterStatus || filterType || searchTerm) && (
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost hover:bg-base-300/50 transition-colors">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
Clear Filters
@@ -326,7 +298,6 @@ const paginationPages = getPaginationPages(page, totalPages);
<tr class="bg-base-300/30">
<th>Type</th>
<th>Client</th>
<th>Category</th>
<th>Description</th>
<th>Member</th>
<th>Start Time</th>
@@ -336,7 +307,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</tr>
</thead>
<tbody>
{entries.map(({ entry, client, category, user: entryUser }) => (
{entries.map(({ entry, client, user: entryUser }) => (
<tr class="hover:bg-base-300/20 transition-colors">
<td>
{entry.isManual ? (
@@ -352,14 +323,6 @@ const paginationPages = getPaginationPages(page, totalPages);
)}
</td>
<td class="font-medium">{client?.name || 'Unknown'}</td>
<td>
{category ? (
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full shadow-sm" style={`background-color: ${category.color}`}></span>
<span>{category.name}</span>
</div>
) : '-'}
</td>
<td class="text-base-content/80">{entry.description || '-'}</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap">
@@ -402,7 +365,7 @@ const paginationPages = getPaginationPages(page, totalPages);
{totalPages > 1 && (
<div class="flex justify-center items-center gap-2 mt-6">
<a
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === 1 ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
@@ -412,7 +375,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<div class="flex gap-1">
{paginationPages.map(pageNum => (
<a
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`}
>
{pageNum}
@@ -421,7 +384,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
<a
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}` : ''}`}
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`}
>
Next

View File

@@ -7,10 +7,10 @@ if (Astro.locals.user) {
---
<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="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">
Chronus
</h1>

View File

@@ -1,49 +1,72 @@
---
import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
if (Astro.locals.user) {
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">
<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-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>
<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">
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Email</span>
</div>
<div class="form-control">
<label class="label font-medium" for="email">
Email
</label>
<input
type="email"
id="email"
name="email"
placeholder="your@email.com"
class="input input-bordered w-full"
autocomplete="email"
required
/>
</label>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Password</span>
</div>
<div class="form-control">
<label class="label font-medium" for="password">
Password
</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
class="input input-bordered w-full"
autocomplete="current-password"
required
/>
</label>
</div>
<button class="btn btn-primary w-full mt-6">Sign In</button>
</form>
<PasskeyLogin client:idle />
<div class="divider">OR</div>
<div class="text-center">

View File

@@ -20,16 +20,33 @@ if (!isFirstUser) {
.get();
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">
<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-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>
<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 ? (
<>
<div class="alert alert-warning">
@@ -47,44 +64,50 @@ if (!isFirstUser) {
) : (
<>
<form action="/api/auth/signup" method="POST" class="space-y-4">
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Full Name</span>
</div>
<div class="form-control">
<label class="label font-medium" for="name">
Full Name
</label>
<input
type="text"
id="name"
name="name"
placeholder="John Doe"
class="input input-bordered w-full"
autocomplete="name"
required
/>
</label>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Email</span>
</div>
<div class="form-control">
<label class="label font-medium" for="email">
Email
</label>
<input
type="email"
id="email"
name="email"
placeholder="your@email.com"
class="input input-bordered w-full"
autocomplete="email"
required
/>
</label>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Password</span>
</div>
<div class="form-control">
<label class="label font-medium" for="password">
Password
</label>
<input
type="password"
id="password"
name="password"
placeholder="Create a strong password"
class="input input-bordered w-full"
autocomplete="new-password"
required
/>
</label>
</div>
<button class="btn btn-primary w-full mt-6">Create Account</button>
</form>

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

@@ -1,5 +1,7 @@
import { h } from "vue";
import { Document, Page, Text, View } from "@ceereals/vue-pdf";
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 {
@@ -13,6 +15,11 @@ interface InvoiceItem {
interface Client {
name: string;
email: string | null;
street: string | null;
city: string | null;
state: string | null;
zip: string | null;
country: string | null;
}
interface Organization {
@@ -22,6 +29,7 @@ interface Organization {
state: string | null;
zip: string | null;
country: string | null;
logoUrl?: string | null;
}
interface Invoice {
@@ -32,6 +40,9 @@ interface Invoice {
dueDate: Date;
currency: string;
subtotal: number;
discountValue: number | null;
discountType: string | null;
discountAmount: number | null;
taxRate: number | null;
taxAmount: number;
total: number;
@@ -67,6 +78,12 @@ const styles = {
flex: 1,
maxWidth: 280,
} as Style,
logo: {
height: 40,
marginBottom: 8,
objectFit: "contain",
objectPosition: "left",
} as Style,
headerRight: {
flex: 1,
alignItems: "flex-end",
@@ -84,40 +101,7 @@ const styles = {
lineHeight: 1.5,
marginBottom: 12,
} as Style,
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 6,
fontSize: 9,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
alignSelf: "flex-start",
} as Style,
statusDraft: {
backgroundColor: "#F3F4F6",
color: "#6B7280",
} as Style,
statusSent: {
backgroundColor: "#DBEAFE",
color: "#1E40AF",
} as Style,
statusPaid: {
backgroundColor: "#D1FAE5",
color: "#065F46",
} as Style,
statusAccepted: {
backgroundColor: "#D1FAE5",
color: "#065F46",
} as Style,
statusVoid: {
backgroundColor: "#FEE2E2",
color: "#991B1B",
} as Style,
statusDeclined: {
backgroundColor: "#FEE2E2",
color: "#991B1B",
} as Style,
invoiceTypeContainer: {
alignItems: "flex-end",
marginBottom: 16,
@@ -178,6 +162,11 @@ const styles = {
fontSize: 10,
color: "#6B7280",
} as Style,
clientAddress: {
fontSize: 10,
color: "#6B7280",
lineHeight: 1.5,
} as Style,
table: {
marginBottom: 40,
} as Style,
@@ -304,24 +293,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
});
};
const getStatusStyle = (status: string): Style => {
const baseStyle = styles.statusBadge;
switch (status) {
case "draft":
return { ...baseStyle, ...styles.statusDraft };
case "sent":
return { ...baseStyle, ...styles.statusSent };
case "paid":
case "accepted":
return { ...baseStyle, ...styles.statusPaid };
case "void":
case "declined":
return { ...baseStyle, ...styles.statusVoid };
default:
return { ...baseStyle, ...styles.statusDraft };
}
};
return h(Document, [
h(
Page,
@@ -330,6 +301,55 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
// 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(
@@ -353,9 +373,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
].filter(Boolean),
)
: null,
h(View, { style: getStatusStyle(invoice.status) }, [
h(Text, invoice.status),
]),
]),
h(View, { style: styles.headerRight }, [
h(View, { style: styles.invoiceTypeContainer }, [
@@ -374,14 +391,16 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
formatDate(invoice.issueDate),
),
]),
h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Due Date"),
h(
Text,
{ style: styles.metaValue },
formatDate(invoice.dueDate),
),
]),
invoice.type !== "quote"
? h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Due Date"),
h(
Text,
{ style: styles.metaValue },
formatDate(invoice.dueDate),
),
])
: null,
]),
]),
]),
@@ -393,6 +412,28 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
[
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,
@@ -465,6 +506,24 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
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(

View File

@@ -2,4 +2,5 @@
@plugin "daisyui" {
themes: false;
}
@plugin "./theme.ts";
@plugin "./theme-dark.ts";
@plugin "./theme-light.ts";

View File

@@ -0,0 +1,9 @@
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
export default createCatppuccinPlugin(
"latte",
{},
{
default: false,
},
);

View File

@@ -24,19 +24,36 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
.all();
// Calculate totals
// Note: amounts are in cents
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(subtotal * (taxRate / 100));
// Calculate discount
const discountType = invoice.discountType || "percentage";
const discountValue = invoice.discountValue || 0;
let discountAmount = 0;
const total = subtotal + taxAmount;
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,
})