32 Commits
2.2.1 ... main

Author SHA1 Message Date
76d3e0cd41 Deps + Bun
All checks were successful
Docker Deploy / build-and-push (push) Successful in 6m9s
2026-03-03 13:30:43 -07:00
42492be284 2.5.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m7s
2026-02-14 01:51:57 -07:00
cd6ececa27 Improved logo 2026-02-14 01:51:43 -07:00
be5dafe539 Updated the logo
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m50s
2026-02-14 01:47:14 -07:00
6233380682 Cleaned up the theme a bit 2026-02-14 01:11:01 -07:00
e99e042eea Fixed Origin mismatch for passkeys
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m9s
2026-02-13 11:35:06 -07:00
705358d44c Please...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m55s
2026-02-13 11:18:20 -07:00
44de064d68 Attempted fix for auth
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m56s
2026-02-13 10:55:35 -07:00
5f7b36582c Fixed
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m30s
2026-02-12 23:04:27 -07:00
25c9d77599 Oops CRF was too strong.
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m19s
2026-02-12 16:31:36 -07:00
3e17e58c9a Strengthened CRF, added more vue, and removed viewtransitions
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m42s
2026-02-12 16:19:59 -07:00
e5c5d68739 Fix logout bug with viewtransitions
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m24s
2026-02-12 15:31:28 -07:00
c7d880e09d Docker optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m0s
2026-02-12 15:11:54 -07:00
4666bc42cf Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m29s
2026-02-12 14:31:09 -07:00
1c70626f5a Icon refactor 2026-02-12 14:29:12 -07:00
caf763aa1e Moar
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
2026-02-09 02:28:54 -07:00
12d59bb42f Refactored a bunch of shit
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s
2026-02-09 01:49:19 -07:00
c39865031a Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-02-02 23:19:50 -07:00
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
115 changed files with 5730 additions and 14540 deletions

View File

@@ -2,3 +2,5 @@ DATA_DIR=./data
ROOT_DIR=./data
APP_PORT=4321
IMAGE=git.atri.dad/atash/chronus:latest
JWT_SECRET=some-secret
ORIGIN=https://chronus.example.com

View File

@@ -12,20 +12,20 @@ jobs:
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
@@ -33,3 +33,6 @@ jobs:
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
provenance: false
cache-from: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache
cache-to: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache,mode=max

View File

@@ -1,35 +1,28 @@
FROM node:lts-alpine AS base
FROM oven/bun:1.3.9-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
COPY package.json bun.lock ./
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
bun install --production --frozen-lockfile || bun install --production
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 base AS builder
COPY package.json bun.lock ./
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
bun install --frozen-lockfile || bun install
FROM build-deps AS builder
WORKDIR /app
COPY . .
RUN pnpm run build
RUN bun run build
FROM base AS runtime
WORKDIR /app
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 --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"]
CMD ["bun", "run", "./dist/server/entry.mjs"]

View File

@@ -2,19 +2,19 @@
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";
// https://astro.build/config
export default defineConfig({
output: "server",
integrations: [vue(), icon()],
integrations: [vue()],
security: {
checkOrigin: false,
csp: process.env.NODE_ENV === "production",
},
vite: {
plugins: [tailwindcss()],
},
adapter: node({
mode: "standalone",
}),

1715
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ services:
- HOST=0.0.0.0
- PORT=4321
- DATA_DIR=/app/data
- JWT_SECRET=${JWT_SECRET}
- ORIGIN=${ORIGIN}
volumes:
- ${ROOT_DIR}:/app/data
restart: unless-stopped

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,6 +73,8 @@ 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,
@@ -77,9 +84,33 @@ CREATE TABLE `organizations` (
`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,
@@ -87,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,
@@ -100,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,
@@ -129,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,6 +0,0 @@
ALTER TABLE `clients` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `street` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `city` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `state` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `zip` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `country` text;

View File

@@ -1,16 +0,0 @@
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `categories_organization_id_idx` ON `categories` (`organization_id`);--> statement-breakpoint
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
CREATE INDEX `invoices_organization_id_idx` ON `invoices` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_user_id_idx` ON `time_entries` (`user_id`);--> statement-breakpoint
CREATE INDEX `time_entries_organization_id_idx` ON `time_entries` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_client_id_idx` ON `time_entries` (`client_id`);--> statement-breakpoint
CREATE INDEX `time_entries_start_time_idx` ON `time_entries` (`start_time`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_time_entry_id_idx` ON `time_entry_tags` (`time_entry_id`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_tag_id_idx` ON `time_entry_tags` (`tag_id`);

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
"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",
@@ -555,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",
@@ -569,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": {
@@ -594,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",
@@ -691,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",
@@ -699,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",
@@ -750,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",
@@ -778,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",
@@ -794,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",
@@ -834,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": {},
@@ -871,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",

View File

@@ -1,8 +1,8 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5483c77e-e742-4fbd-8494-d6f9c6c9e28a",
"prevId": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
"id": "837a4e18-b319-465d-9e30-2614b4850fb5",
"prevId": "8343b003-264b-444a-9782-07d736dd3407",
"tables": {
"api_tokens": {
"name": "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": {
@@ -226,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",
@@ -295,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",
@@ -399,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",
@@ -431,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",
@@ -497,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",
@@ -597,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",
@@ -611,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": {
@@ -636,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",
@@ -733,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",
@@ -741,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",
@@ -792,11 +991,11 @@
"notNull": true,
"autoincrement": false
},
"category_id": {
"name": "category_id",
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"notNull": false,
"autoincrement": false
},
"start_time": {
@@ -820,6 +1019,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",
@@ -836,7 +1042,50 @@
"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_tag_id_idx": {
"name": "time_entries_tag_id_idx",
"columns": [
"tag_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",
@@ -877,60 +1126,9 @@
"onDelete": "no action",
"onUpdate": "no action"
},
"time_entries_category_id_categories_id_fk": {
"name": "time_entries_category_id_categories_id_fk",
"time_entries_tag_id_tags_id_fk": {
"name": "time_entries_tag_id_tags_id_fk",
"tableFrom": "time_entries",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"time_entry_tags": {
"name": "time_entry_tags",
"columns": {
"time_entry_id": {
"name": "time_entry_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"time_entry_tags_time_entry_id_time_entries_id_fk": {
"name": "time_entry_tags_time_entry_id_time_entries_id_fk",
"tableFrom": "time_entry_tags",
"tableTo": "time_entries",
"columnsFrom": [
"time_entry_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"time_entry_tags_tag_id_tags_id_fk": {
"name": "time_entry_tags_tag_id_tags_id_fk",
"tableFrom": "time_entry_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
@@ -942,15 +1140,7 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"time_entry_tags_time_entry_id_tag_id_pk": {
"columns": [
"time_entry_id",
"tag_id"
],
"name": "time_entry_tags_time_entry_id_tag_id_pk"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,36 +5,15 @@
{
"idx": 0,
"version": "6",
"when": 1768688193284,
"tag": "0000_motionless_king_cobra",
"when": 1768934194146,
"tag": "0000_lazy_rictor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768690333269,
"tag": "0001_lazy_roughhouse",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1768773436601,
"tag": "0002_chilly_cyclops",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1768842088321,
"tag": "0003_amusing_wendigo",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1768876902359,
"tag": "0004_happy_namorita",
"when": 1768935234392,
"tag": "0001_demonic_red_skull",
"breakpoints": true
}
]

View File

@@ -5,7 +5,8 @@
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs }:
outputs =
{ nixpkgs }:
let
allSystems = [
"x86_64-linux"
@@ -14,25 +15,33 @@
"aarch64-darwin"
];
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
pkgs = import nixpkgs { inherit system; };
});
forAllSystems =
f:
nixpkgs.lib.genAttrs allSystems (
system:
f {
pkgs = import nixpkgs { inherit system; };
}
);
in
{
devShells = forAllSystems ({ pkgs }: {
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_24
nodePackages.pnpm
sqlite
];
devShells = forAllSystems (
{ pkgs }:
{
default = pkgs.mkShell {
packages = with pkgs; [
nodejs_24
nodePackages.pnpm
sqlite
];
shellHook = ''
echo "Chronus dev shell"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
'';
};
});
shellHook = ''
echo "Chronus dev shell"
echo "Node version: $(node --version)"
echo "pnpm version: $(pnpm --version)"
'';
};
}
);
};
}

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "2.2.1",
"version": "2.5.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -12,32 +12,30 @@
"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": "9.5.4",
"@astrojs/vue": "5.1.4",
"@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-icon": "^1.1.5",
"@simplewebauthn/server": "^13.2.3",
"@tailwindcss/vite": "^4.2.1",
"astro": "5.18.0",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"daisyui": "^5.5.14",
"dotenv": "^17.2.3",
"daisyui": "^5.5.19",
"dotenv": "^17.3.1",
"drizzle-orm": "0.45.1",
"jsonwebtoken": "^9.0.3",
"nanoid": "^5.1.6",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vue": "^3.5.27",
"vue": "^3.5.29",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@catppuccin/daisyui": "^2.1.1",
"@iconify-json/heroicons": "^1.2.3",
"@react-pdf/types": "^2.9.2",
"drizzle-kit": "0.31.8"
"@types/jsonwebtoken": "^9.0.10",
"drizzle-kit": "0.31.9"
}
}

7720
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
function onChange(e: Event) {
const el = e.target as HTMLElement;
el.closest('form')?.submit();
}
</script>
<template>
<span @change="onChange">
<slot />
</span>
</template>

View File

@@ -9,7 +9,7 @@ const initial = name ? name.charAt(0).toUpperCase() : '?';
---
<div class:list={["avatar placeholder", className]}>
<div class="bg-primary text-primary-content w-10 rounded-full flex items-center justify-center">
<span class="text-lg font-semibold">{initial}</span>
<div class="bg-base-300 text-primary w-9 h-9 rounded-full flex items-center justify-center">
<span class="text-sm font-semibold">{initial}</span>
</div>
</div>

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

@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{
color: string;
as?: string;
class?: string;
borderColor?: boolean;
}>();
</script>
<template>
<component
:is="as || 'span'"
:class="$props.class"
:style="borderColor ? { borderColor: color } : { backgroundColor: color }"
><slot /></component>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
defineProps<{
message: string;
action: string;
method?: string;
class?: string;
}>();
function onSubmit(e: Event) {
if (!confirm((e.currentTarget as HTMLFormElement).dataset.message!)) {
e.preventDefault();
}
}
</script>
<template>
<form
:method="method || 'POST'"
:action="action"
:class="$props.class"
:data-message="message"
@submit="onSubmit"
>
<slot />
</form>
</template>

27
src/components/Icon.astro Normal file
View File

@@ -0,0 +1,27 @@
---
import { icons, type IconName } from "../config/icons";
interface Props {
name: IconName;
class?: string;
"class:list"?: any;
}
const { name, class: className, "class:list": classList } = Astro.props;
const svg = icons[name];
if (!svg) {
throw new Error(`Icon "${name}" not found in icon registry`);
}
---
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
class:list={[className, classList]}
aria-hidden="true"
set:html={svg}
/>

30
src/components/Icon.vue Normal file
View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from "vue";
import { icons, type IconName } from "../config/icons";
const props = defineProps<{
name: IconName;
class?: string;
}>();
const svg = computed(() => {
const icon = icons[props.name];
if (!icon) {
throw new Error(`Icon "${props.name}" not found in icon registry`);
}
return icon;
});
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
:class="props.class"
aria-hidden="true"
v-html="svg"
/>
</template>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ref } from "vue";
import Icon from "./Icon.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 +13,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("");
@@ -28,11 +27,10 @@ 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;
}
}
@@ -53,10 +51,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";
}
@@ -101,10 +95,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,
}),
});
@@ -119,8 +112,7 @@ async function submitManualEntry() {
description.value = "";
selectedClientId.value = "";
selectedCategoryId.value = "";
selectedTags.value = [];
selectedTagId.value = null;
startDate.value = today;
endDate.value = today;
startTime.value = "";
@@ -144,8 +136,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 = "";
@@ -157,7 +148,7 @@ function clearForm() {
<template>
<div
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200"
class="card bg-base-200 backdrop-blur-sm shadow-lg border border-base-content/20 hover:border-base-content/20 transition-all duration-200"
>
<div class="card-body gap-6">
<div class="flex justify-between items-center">
@@ -174,109 +165,58 @@ function clearForm() {
<!-- Success Message -->
<div v-if="success" class="alert alert-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="check-circle" class="stroke-current shrink-0 h-6 w-6" />
<span>Manual time entry created successfully!</span>
</div>
<!-- Error Message -->
<div v-if="error" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Icon name="x-circle" class="stroke-current shrink-0 h-6 w-6" />
<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 hover:bg-base-300 focus:bg-base-300 border-base-content/20 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"
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Start Time</span>
<span class="label-text-alt text-error">*</span>
<label 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"
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
@@ -285,27 +225,27 @@ 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"
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">End Time</span>
<span class="label-text-alt text-error">*</span>
<label 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"
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
@@ -313,23 +253,22 @@ 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?"
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"
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isSubmitting"
/>
</div>
<!-- Tags Section -->
<div v-if="tags.length > 0" class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Tags</span>
</label>
<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"
@@ -337,9 +276,9 @@ 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',
: 'badge-outline hover:bg-base-300',
]"
:disabled="isSubmitting"
type="button"

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
modalId: string;
action?: 'open' | 'close';
class?: string;
title?: string;
type?: string;
}>();
function onClick(e: MouseEvent) {
const btn = e.currentTarget as HTMLElement;
const id = btn.dataset.modalId!;
const act = btn.dataset.action || 'open';
const modal = document.getElementById(id) as HTMLDialogElement | null;
if (act === 'close') {
modal?.close();
} else {
modal?.showModal();
}
}
</script>
<template>
<button
:type="(type as any) || 'button'"
:class="$props.class"
:title="$props.title"
:data-modal-id="modalId"
:data-action="action || 'open'"
@click="onClick"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,29 @@
---
import Icon from './Icon.astro';
interface Props {
title: string;
value: string;
description?: string;
icon?: string;
color?: string;
valueClass?: string;
}
const { title, value, description, icon, color = 'text-primary', valueClass } = Astro.props;
---
<div class="card card-border bg-base-100">
<div class="card-body p-4 gap-1">
<div class="flex items-center justify-between">
<span class="text-xs font-medium uppercase tracking-wider text-base-content/60">{title}</span>
{icon && (
<div class:list={[color, "opacity-70"]}>
<Icon name={icon} class="w-5 h-5" />
</div>
)}
</div>
<div class:list={["text-2xl font-bold", color, valueClass]}>{value}</div>
{description && <div class="text-xs text-base-content/60">{description}</div>}
</div>
</div>

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",
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,31 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import Icon from "./Icon.vue";
const theme = ref("sunset");
onMounted(() => {
const stored = localStorage.getItem("theme");
if (stored) {
theme.value = stored;
document.documentElement.setAttribute("data-theme", stored);
}
});
function toggleTheme() {
const newTheme = theme.value === "sunset" ? "winter" : "sunset";
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 :name="theme === 'sunset' ? 'moon' : 'sun'" class="w-5 h-5" />
</button>
</template>

View File

@@ -1,16 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { Icon } from "@iconify/vue";
import Icon from "./Icon.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 }[];
}>();
@@ -19,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) {
@@ -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();
}
}
@@ -128,71 +118,45 @@ async function stopTimer() {
<template>
<div
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200"
class="card bg-base-200 backdrop-blur-sm shadow-lg border border-base-content/20 mb-6 hover:border-base-content/20 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 hover:bg-base-300 focus:bg-base-300 border-base-content/20 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?"
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"
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
:disabled="isRunning"
/>
</div>
<!-- Tags Section -->
<div v-if="tags.length > 0" class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Tags</span>
</label>
<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,9 +164,9 @@ 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',
: 'badge-outline hover:bg-base-300',
]"
:disabled="isRunning"
type="button"
@@ -224,7 +188,7 @@ 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"
>
<Icon icon="heroicons:play" class="w-5 h-5" />
<Icon name="play" class="w-5 h-5" />
Start Timer
</button>
<button
@@ -232,7 +196,7 @@ async function stopTimer() {
@click="stopTimer"
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
>
<Icon icon="heroicons:stop" class="w-5 h-5" />
<Icon name="stop" class="w-5 h-5" />
Stop Timer
</button>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
import Icon from "../Icon.vue";
import { startAuthentication } from "@simplewebauthn/browser";
const loading = ref(false);
@@ -60,12 +60,12 @@ async function 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" />
<Icon v-else name="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" />
<Icon name="exclamation-circle" class="w-6 h-6" />
<span>{{ error }}</span>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
import Icon from "../Icon.vue";
interface ApiToken {
id: string;
@@ -107,12 +107,12 @@ function closeShowTokenModal() {
<template>
<div>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card bg-base-100 shadow-xl border border-base-content/20 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"
name="code-bracket-square"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
API Tokens
@@ -121,7 +121,7 @@ function closeShowTokenModal() {
class="btn btn-primary btn-sm"
@click="createModalOpen = true"
>
<Icon icon="heroicons:plus" class="w-4 h-4" />
<Icon name="plus" class="w-4 h-4" />
Create Token
</button>
</div>
@@ -161,7 +161,7 @@ function closeShowTokenModal() {
class="btn btn-ghost btn-xs text-error"
@click="deleteToken(token.id)"
>
<Icon icon="heroicons:trash" class="w-4 h-4" />
<Icon name="trash" class="w-4 h-4" />
</button>
</td>
</tr>
@@ -182,11 +182,12 @@ function closeShowTokenModal() {
<form @submit.prevent="createToken" class="space-y-4">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium">Token Name</span>
<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"
@@ -221,7 +222,7 @@ function closeShowTokenModal() {
<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" />
<Icon name="check-circle" class="w-6 h-6" />
Token Created
</h3>
<p class="py-4">
@@ -238,7 +239,7 @@ function closeShowTokenModal() {
@click="copyToken"
title="Copy to clipboard"
>
<Icon icon="heroicons:clipboard" class="w-4 h-4" />
<Icon name="clipboard" class="w-4 h-4" />
</button>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
import Icon from "../Icon.vue";
import { startRegistration } from "@simplewebauthn/browser";
interface Passkey {
@@ -94,11 +94,11 @@ async function deletePasskey(id: string) {
</script>
<template>
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card bg-base-100 shadow-xl border border-base-content/20 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" />
<Icon name="finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
Passkeys
</h2>
<button
@@ -110,7 +110,7 @@ async function deletePasskey(id: string) {
v-if="loading"
class="loading loading-spinner loading-xs"
></span>
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
<Icon v-else name="plus" class="w-4 h-4" />
Add Passkey
</button>
</div>
@@ -157,7 +157,7 @@ async function deletePasskey(id: string) {
class="btn btn-ghost btn-xs text-error"
@click="deletePasskey(pk.id)"
>
<Icon icon="heroicons:trash" class="w-4 h-4" />
<Icon name="trash" class="w-4 h-4" />
</button>
</td>
</tr>

View File

@@ -1,21 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
import { ref } from "vue";
import Icon from "../Icon.vue";
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const currentPassword = ref("");
const newPassword = ref("");
const confirmPassword = ref("");
const loading = ref(false);
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
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' };
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' };
message.value = {
type: "error",
text: "Password must be at least 8 characters",
};
return;
}
@@ -23,10 +26,10 @@ async function changePassword() {
message.value = null;
try {
const response = await fetch('/api/user/change-password', {
method: 'POST',
const response = await fetch("/api/user/change-password", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
currentPassword: currentPassword.value,
@@ -36,20 +39,26 @@ async function changePassword() {
});
if (response.ok) {
message.value = { type: 'success', text: 'Password changed successfully!' };
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
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' };
message.value = {
type: "error",
text: data.error || "Failed to change password",
};
}
} catch (error) {
message.value = { type: 'error', text: 'An error occurred' };
message.value = { type: "error", text: "An error occurred" };
} finally {
loading.value = false;
}
@@ -59,25 +68,42 @@ async function changePassword() {
<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" />
<div
v-if="message"
:class="[
'alert mb-6',
message.type === 'success' ? 'alert-success' : 'alert-error',
]"
>
<Icon
:name="
message.type === 'success'
? 'check-circle'
: '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 bg-base-100 shadow-xl border border-base-content/20 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" />
<Icon name="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">
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
<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"
@@ -86,11 +112,15 @@ async function changePassword() {
</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
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"
@@ -98,16 +128,23 @@ async function changePassword() {
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>
<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
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"
@@ -117,9 +154,16 @@ async function changePassword() {
</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" />
<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 name="lock-closed" class="w-5 h-5" />
Update Password
</button>
</div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
import Icon from "../Icon.vue";
const props = defineProps<{
user: {
@@ -61,32 +61,34 @@ async function updateProfile() {
]"
>
<Icon
:icon="
:name="
message.type === 'success'
? 'heroicons:check-circle'
: 'heroicons:exclamation-circle'
? 'check-circle'
: '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 bg-base-100 shadow-xl border border-base-content/20 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" />
<Icon name="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">
<span class="label-text font-medium text-sm sm:text-base"
>Full Name</span
>
<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"
@@ -95,13 +97,15 @@ async function updateProfile() {
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base"
>Email</span
>
<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
@@ -124,7 +128,7 @@ async function updateProfile() {
v-if="loading"
class="loading loading-spinner loading-sm"
></span>
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
<Icon v-else name="check" class="w-5 h-5" />
Save Changes
</button>
</div>

62
src/config/icons.ts Normal file
View File

@@ -0,0 +1,62 @@
export const icons = {
"arrow-down-tray": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>`,
"arrow-left": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>`,
"arrow-right": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>`,
"arrow-right-on-rectangle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"/>`,
"banknotes": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 18.75a60 60 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0a3 3 0 0 1 6 0m3 0h.008v.008H18zm-12 0h.008v.008H6z"/>`,
"bars-3": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>`,
"bolt": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75L12 13.5z"/>`,
"building-office": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/>`,
"building-office-2": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008zm0 3h.008v.008h-.008zm0 3h.008v.008h-.008z"/>`,
"calendar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>`,
"chart-bar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875zm6.75-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125zm6.75-4.5c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125z"/>`,
"chart-pie": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5z"/><path d="M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3z"/></g>`,
"check": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m4.5 12.75l6 6l9-13.5"/>`,
"check-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12.75L11.25 15L15 9.75M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
"chevron-left": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 19.5L8.25 12l7.5-7.5"/>`,
"chevron-right": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m8.25 4.5l7.5 7.5l-7.5 7.5"/>`,
"clipboard": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0q.083.292.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0q.002-.32.084-.612m7.332 0q.969.073 1.927.184c1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48 48 0 0 1 1.927-.184"/>`,
"clipboard-document-list": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48 48 0 0 0-1.123-.08m-5.801 0q-.099.316-.1.664c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75a2.3 2.3 0 0 0-.1-.664m-5.8 0A2.25 2.25 0 0 1 13.5 2.25H15a2.25 2.25 0 0 1 2.15 1.586m-5.8 0q-.563.035-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125zM6.75 12h.008v.008H6.75zm0 3h.008v.008H6.75zm0 3h.008v.008H6.75z"/>`,
"clock": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
"code-bracket-square": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25"/>`,
"cog-6-tooth": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87q.11.06.22.127c.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a8 8 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a7 7 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a7 7 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a7 7 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124q.108-.066.22-.128c.332-.183.582-.495.644-.869z"/><path d="M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/></g>`,
"currency-dollar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0s1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659c-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
"document-currency-dollar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v7.5m2.25-6.466a9 9 0 0 0-3.461-.203c-.536.072-.974.478-1.021 1.017a5 5 0 0 0-.018.402c0 .464.336.844.775.994l2.95 1.012c.44.15.775.53.775.994q0 .204-.018.402c-.047.539-.485.945-1.021 1.017a9.1 9.1 0 0 1-3.461-.203M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9"/>`,
"document-duplicate": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9 9 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9 9 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"/>`,
"document-text": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9"/>`,
"ellipsis-horizontal": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 12a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0m6 0a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0m6 0a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0"/>`,
"ellipsis-vertical": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.75a.75.75 0 1 1 0-1.5a.75.75 0 0 1 0 1.5m0 6a.75.75 0 1 1 0-1.5a.75.75 0 0 1 0 1.5m0 6a.75.75 0 1 1 0-1.5a.75.75 0 0 1 0 1.5"/>`,
"envelope": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>`,
"exclamation-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0a9 9 0 0 1 18 0m-9 3.75h.008v.008H12z"/>`,
"exclamation-triangle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0zM12 15.75h.007v.008H12z"/>`,
"eye": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M2.036 12.322a1 1 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178c.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178"/><path d="M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/></g>`,
"finger-print": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.269M5.742 6.364A7.47 7.47 0 0 0 4.5 10.5a7.46 7.46 0 0 1-1.15 3.993m1.989 3.559A11.2 11.2 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0q0 .79-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.7 18.7 0 0 1-2.485 5.33"/>`,
"home": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m2.25 12l8.955-8.955a1.124 1.124 0 0 1 1.59 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/>`,
"inbox": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162q0-.338-.1-.661l-2.41-7.839a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.3 2.3 0 0 0-.1.661"/>`,
"information-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m11.25 11.25l.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0m-9-3.75h.008v.008H12z"/>`,
"key": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25"/>`,
"list-bullet": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0M3.75 12h.007v.008H3.75zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0m-.375 5.25h.007v.008H3.75zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0"/>`,
"lock-closed": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25"/>`,
"magnifying-glass": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607"/>`,
"map-pin": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M15 10.5a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/><path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0"/></g>`,
"moon": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.752 15.002A9.7 9.7 0 0 1 18 15.75A9.75 9.75 0 0 1 8.25 6c0-1.33.266-2.597.748-3.752A9.75 9.75 0 0 0 3 11.25A9.75 9.75 0 0 0 12.75 21a9.75 9.75 0 0 0 9.002-5.998"/>`,
"paper-airplane": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 12L3.269 3.125A59.8 59.8 0 0 1 21.486 12a59.8 59.8 0 0 1-18.217 8.875zm0 0h7.5"/>`,
"pencil": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8l.8-2.685a4.5 4.5 0 0 1 1.13-1.897zm0 0L19.5 7.125"/>`,
"pencil-square": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/>`,
"phone": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.04 12.04 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5z"/>`,
"photo": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m2.25 15.75l5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5m10.5-11.25h.008v.008h-.008zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0"/>`,
"play": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986z"/>`,
"play-circle": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/><path d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327z"/></g>`,
"plus": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.5v15m7.5-7.5h-15"/>`,
"stop": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.25 7.5A2.25 2.25 0 0 1 7.5 5.25h9a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25z"/>`,
"sun": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0a3.75 3.75 0 0 1 7.5 0"/>`,
"tag": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.1 18.1 0 0 0 5.224-5.223c.54-.827.368-1.908-.33-2.607l-9.583-9.58A2.25 2.25 0 0 0 9.568 3"/><path d="M6 6h.008v.008H6z"/></g>`,
"trash": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21q.512.078 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48 48 0 0 0-3.478-.397m-12 .562q.51-.088 1.022-.165m0 0a48 48 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a52 52 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a49 49 0 0 0-7.5 0"/>`,
"user-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.982 18.725A7.49 7.49 0 0 0 12 15.75a7.49 7.49 0 0 0-5.982 2.975m11.964 0a9 9 0 1 0-11.963 0m11.962 0A8.97 8.97 0 0 1 12 21a8.97 8.97 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/>`,
"user-group": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 18.72a9.1 9.1 0 0 0 3.741-.479q.01-.12.01-.241a3 3 0 0 0-4.692-2.478m.94 3.197l.001.031q0 .337-.037.666A11.94 11.94 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6 6 0 0 1 6 18.719m12 0a5.97 5.97 0 0 0-.941-3.197m0 0A6 6 0 0 0 12 12.75a6 6 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72a9 9 0 0 0 3.74.477m.94-3.197a5.97 5.97 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0a3 3 0 0 1 6 0m6 3a2.25 2.25 0 1 1-4.5 0a2.25 2.25 0 0 1 4.5 0m-13.5 0a2.25 2.25 0 1 1-4.5 0a2.25 2.25 0 0 1 4.5 0"/>`,
"users": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 19.128a9.4 9.4 0 0 0 2.625.372a9.3 9.3 0 0 0 4.121-.952q.004-.086.004-.173a4.125 4.125 0 0 0-7.536-2.32M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.3 12.3 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0a3.375 3.375 0 0 1 6.75 0m8.25 2.25a2.625 2.625 0 1 1-5.25 0a2.625 2.625 0 0 1 5.25 0"/>`,
"x-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
"x-mark": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"/>`,
} as const;
export type IconName = keyof typeof icons;

View File

@@ -33,6 +33,8 @@ export const organizations = sqliteTable("organizations", {
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(),
),
@@ -95,8 +97,8 @@ export const clients = sqliteTable(
}),
);
export const categories = sqliteTable(
"categories",
export const tags = sqliteTable(
"tags",
{
id: text("id")
.primaryKey()
@@ -104,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(),
),
@@ -113,7 +116,7 @@ export const categories = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("categories_organization_id_idx").on(
organizationIdIdx: index("tags_organization_id_idx").on(
table.organizationId,
),
}),
@@ -128,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(),
@@ -150,63 +154,18 @@ export const timeEntries = sqliteTable(
columns: [table.clientId],
foreignColumns: [clients.id],
}),
categoryFk: foreignKey({
columns: [table.categoryId],
foreignColumns: [categories.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),
}),
);
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],
}),
organizationIdIdx: index("tags_organization_id_idx").on(
table.organizationId,
),
}),
);
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],
}),
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
table.timeEntryId,
),
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
}),
);

1
src/env.d.ts vendored
View File

@@ -14,7 +14,6 @@ interface ImportMeta {
declare namespace App {
interface Locals {
user: import("./db/schema").User | null;
session: import("./db/schema").Session | null;
scopes: string[] | null;
}
}

View File

@@ -1,11 +1,11 @@
---
import '../styles/global.css';
import { Icon } from 'astro-icon/components';
import Icon from '../components/Icon.astro';
import { db } from '../db';
import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm';
import Avatar from '../components/Avatar.astro';
import { ClientRouter } from "astro:transitions";
import ThemeToggle from '../components/ThemeToggle.vue';
interface Props {
title: string;
@@ -28,11 +28,24 @@ const userMemberships = await db.select({
.all();
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: 'home', exact: true },
{ href: '/dashboard/tracker', label: 'Time Tracker', icon: 'clock' },
{ href: '/dashboard/invoices', label: 'Invoices & Quotes', icon: 'document-currency-dollar' },
{ href: '/dashboard/reports', label: 'Reports', icon: 'chart-bar' },
{ href: '/dashboard/clients', label: 'Clients', icon: 'building-office' },
{ href: '/dashboard/team', label: 'Team', icon: 'user-group' },
];
function isActive(item: { href: string; exact?: boolean }) {
if (item.exact) return Astro.url.pathname === item.href;
return Astro.url.pathname.startsWith(item.href);
}
---
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Chronus Dashboard" />
@@ -40,154 +53,157 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
<script is:inline>
const theme = localStorage.getItem('theme') || 'sunset';
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">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-auto">
<!-- Navbar -->
<div class="navbar bg-base-200/50 backdrop-blur-sm sticky top-0 z-50 lg:hidden border-b border-base-300/50">
<div class="flex-none lg:hidden">
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
<Icon name="heroicons:bars-3" class="w-6 h-6" />
<!-- Mobile Navbar -->
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-content/20">
<div class="flex-none">
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm">
<Icon name="bars-3" class="w-5 h-5" />
</label>
</div>
<div class="flex-1 px-2 flex items-center gap-2">
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
<span class="text-xl font-bold text-primary">Chronus</span>
<img src="/logo.webp" alt="Chronus" class="h-7 w-7" />
<span class="text-lg font-bold text-primary">Chronus</span>
</div>
<div class="flex-none">
<ThemeToggle client:load />
</div>
</div>
<!-- Page content here -->
<main class="p-6 md:p-8">
<!-- Page content -->
<main class="flex-1 p-4 sm:p-6 lg:p-8">
<slot />
</main>
</div>
<div class="drawer-side z-50">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200/95 backdrop-blur-sm min-h-full w-80 p-4 border-r border-base-300/30">
<!-- Sidebar content here -->
<li class="mb-6">
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
<img src="/logo.webp" alt="Chronus" class="h-10 w-10" />
Chronus
<aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-content/20">
<!-- Logo -->
<div class="px-5 pt-5 pb-3">
<a href="/dashboard" class="flex items-center gap-2.5 group">
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
<span class="text-xl font-bold text-primary">Chronus</span>
</a>
</li>
</div>
{/* Team Switcher */}
<!-- Team Switcher -->
{userMemberships.length > 0 && (
<li class="mb-4">
<div class="form-control">
<select
class="select select-bordered w-full font-semibold bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary focus:outline-none focus:outline-offset-0 transition-all duration-200 hover:border-primary/40 focus:ring-3 focus:ring-primary/15 [&>option]:bg-base-300 [&>option]:text-base-content [&>option]:p-2"
id="team-switcher"
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
>
{userMemberships.map(({ membership, organization }) => (
<option
value={organization.id}
selected={organization.id === currentTeamId}
>
{organization.name}
</option>
))}
</select>
</div>
</li>
<div class="px-4 pb-2">
<select
class="select select-sm w-full bg-base-300 border-base-content/20 focus:border-primary focus:outline-none text-sm font-medium"
id="team-switcher"
aria-label="Switch team"
>
{userMemberships.map(({ membership, organization }) => (
<option
value={organization.id}
selected={organization.id === currentTeamId}
>
{organization.name}
</option>
))}
</select>
</div>
)}
{userMemberships.length === 0 && (
<li class="mb-4">
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
<Icon name="heroicons:plus" class="w-4 h-4" />
<div class="px-4 pb-2">
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm btn-block">
<Icon name="plus" class="w-4 h-4" />
Create Team
</a>
</li>
</div>
)}
<div class="divider my-2"></div>
<div class="divider my-1 mx-4"></div>
<li><a href="/dashboard" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname === "/dashboard" }
]}>
<Icon name="heroicons:home" class="w-5 h-5" />
Dashboard
</a></li>
<li><a href="/dashboard/tracker" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/tracker") }
]}>
<Icon name="heroicons:clock" class="w-5 h-5" />
Time Tracker
</a></li>
<li><a href="/dashboard/invoices" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/invoices") }
]}>
<Icon name="heroicons:document-currency-dollar" class="w-5 h-5" />
Invoices & Quotes
</a></li>
<li><a href="/dashboard/reports" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/reports") }
]}>
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
Reports
</a></li>
<li><a href="/dashboard/clients" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/clients") }
]}>
<Icon name="heroicons:building-office" class="w-5 h-5" />
Clients
</a></li>
<li><a href="/dashboard/team" class:list={[
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/team") }
]}>
<Icon name="heroicons:user-group" class="w-5 h-5" />
Team
</a></li>
<!-- Navigation -->
<nav class="flex-1 px-3">
<ul class="menu menu-sm gap-0.5 p-0">
{navItems.map(item => (
<li>
<a href={item.href} class:list={[
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
isActive(item)
? "bg-primary text-primary-content"
: "text-base-content/70 hover:text-base-content hover:bg-base-300"
]}>
<Icon name={item.icon} class="w-[18px] h-[18px]" />
{item.label}
</a>
</li>
))}
</ul>
{user.isSiteAdmin && (
<>
<div class="divider my-2"></div>
<li><a href="/admin" class:list={[
"font-semibold hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/admin") }
]}>
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
Site Admin
</a></li>
</>
)}
{user.isSiteAdmin && (
<>
<div class="divider my-1"></div>
<ul class="menu menu-sm p-0">
<li>
<a href="/admin" class:list={[
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
Astro.url.pathname.startsWith("/admin")
? "bg-primary text-primary-content"
: "text-base-content/70 hover:text-base-content hover:bg-base-300"
]}>
<Icon name="cog-6-tooth" class="w-[18px] h-[18px]" />
Site Admin
</a>
</li>
</ul>
</>
)}
</nav>
<div class="divider my-2"></div>
<!-- Bottom Section -->
<div class="mt-auto border-t border-base-content/20">
<div class="p-3">
<a href="/dashboard/settings" class="flex items-center gap-3 rounded-lg p-2.5 hover:bg-base-300 group">
<Avatar name={user.name} />
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{user.name}</div>
<div class="text-xs text-base-content/60 truncate">{user.email}</div>
</div>
<Icon name="chevron-right" class="w-4 h-4 text-base-content/50 group-hover:text-base-content/70" />
</a>
</div>
<li>
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-300/30 hover:bg-base-300/60 rounded-lg p-3 transition-colors">
<Avatar name={user.name} />
<div class="flex-1 min-w-0">
<div class="font-semibold text-sm truncate">{user.name}</div>
<div class="text-xs text-base-content/50 truncate">{user.email}</div>
</div>
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-40" />
</a>
</li>
<div class="flex items-center justify-between px-5 pb-2">
<span class="text-xs text-base-content/60 font-medium">Theme</span>
<ThemeToggle client:load />
</div>
<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" />
<div class="px-3 pb-3">
<button id="logout-btn" type="button" class="btn btn-ghost btn-sm btn-block justify-start gap-2 text-base-content/60 hover:text-error hover:bg-base-300 font-medium">
<Icon name="arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
Logout
</button>
</form>
</li>
</ul>
</div>
</div>
</aside>
</div>
</div>
<script>
const teamSwitcher = document.getElementById('team-switcher') as HTMLSelectElement | null;
teamSwitcher?.addEventListener('change', () => {
document.cookie = 'currentTeamId=' + teamSwitcher.value + '; path=/';
window.location.reload();
});
const logoutBtn = document.getElementById('logout-btn');
logoutBtn?.addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.reload();
});
</script>
</body>
</html>

View File

@@ -1,6 +1,5 @@
---
import '../styles/global.css';
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
@@ -10,7 +9,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" />
@@ -18,7 +17,10 @@ const { title } = Astro.props;
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<ClientRouter />
<script is:inline>
const theme = localStorage.getItem('theme') || 'sunset';
document.documentElement.setAttribute('data-theme', theme);
</script>
</head>
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
<div class="flex-1 flex flex-col">

View File

@@ -1,50 +1,74 @@
import { db } from '../db';
import { users, sessions } from '../db/schema';
import { eq } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import { nanoid } from 'nanoid';
import { db } from "../db";
import { users } from "../db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import type { AstroCookies } from "astro";
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
const JWT_SECRET =
process.env.JWT_SECRET || "chronus-dev-secret-change-in-production";
const JWT_EXPIRES_IN = "30d";
export async function createSession(userId: string) {
const sessionId = nanoid();
const expiresAt = new Date(Date.now() + SESSION_DURATION);
interface JwtPayload {
userId: string;
}
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt,
export function createToken(userId: string): string {
return jwt.sign({ userId } satisfies JwtPayload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
return { sessionId, expiresAt };
}
export async function validateSession(sessionId: string) {
const result = await db.select({
user: users,
session: sessions
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId))
.get();
if (!result) {
export function verifyToken(token: string): JwtPayload | null {
try {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
} catch {
return null;
}
const { session, user } = result;
if (Date.now() >= session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return null;
}
return { session, user };
}
export async function invalidateSession(sessionId: string) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
export function setAuthCookie(cookies: AstroCookies, userId: string) {
const token = createToken(userId);
cookies.set("auth_token", token, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30,
});
}
export function clearAuthCookie(cookies: AstroCookies) {
cookies.delete("auth_token", { path: "/" });
}
export async function getUserFromToken(token: string) {
const payload = verifyToken(token);
if (!payload) return null;
const user = await db
.select()
.from(users)
.where(eq(users.id, payload.userId))
.get();
return user ?? null;
}
/**
* Get the public origin and hostname from the ORIGIN environment variable.
* This is required for WebAuthn/passkey rpID to match the browser's origin.
*/
export function getOrigin(): { hostname: string; origin: string } {
const origin = process.env.ORIGIN;
if (!origin) {
throw new Error("ORIGIN environment variable is not set");
}
const url = new URL(origin);
return {
hostname: url.hostname,
origin: url.origin,
};
}
export async function hashPassword(password: string) {

View File

@@ -25,3 +25,16 @@ export function formatTimeRange(start: Date, end: Date | null): string {
const ms = end.getTime() - start.getTime();
return formatDuration(ms);
}
/**
* Formats a cent-based amount as a currency string.
* @param amount - Amount in cents (e.g. 1500 = $15.00)
* @param currency - ISO 4217 currency code (default: 'USD')
* @returns Formatted currency string like "$15.00"
*/
export function formatCurrency(amount: number, currency: string = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
}).format(amount / 100);
}

24
src/lib/getCurrentTeam.ts Normal file
View File

@@ -0,0 +1,24 @@
import { db } from '../db';
import { members } from '../db/schema';
import { eq } from 'drizzle-orm';
type User = { id: string; [key: string]: any };
/**
* Get the current team membership for a user based on the currentTeamId cookie.
* Returns the membership row, or null if the user has no memberships.
*/
export async function getCurrentTeam(user: User, currentTeamId?: string | null) {
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return null;
const membership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
return membership;
}

View File

@@ -1,63 +1,66 @@
import { db } from "../db";
import { clients, categories, tags as tagsTable } from "../db/schema";
import { eq, and, inArray } from "drizzle-orm";
import { clients, tags as tagsTable } from "../db/schema";
import { eq, and } from "drizzle-orm";
export const MAX_LENGTHS = {
name: 255,
email: 320,
password: 128,
phone: 50,
address: 255, // street, city, state, zip, country
currency: 10,
invoiceNumber: 50,
invoiceNotes: 5000,
itemDescription: 2000,
description: 2000, // time entry description
} as const;
export function exceedsLength(
field: string,
value: string | null | undefined,
maxLength: number,
): string | null {
if (value && value.length > maxLength) {
return `${field} must be ${maxLength} characters or fewer`;
}
return null;
}
export async function validateTimeEntryResources({
organizationId,
clientId,
categoryId,
tagIds,
tagId,
}: {
organizationId: string;
clientId: string;
categoryId: string;
tagIds?: string[];
tagId?: string | null;
}) {
const [client, category] = await Promise.all([
db
.select()
.from(clients)
.where(
and(
eq(clients.id, clientId),
eq(clients.organizationId, organizationId),
),
)
.get(),
db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, organizationId),
),
)
.get(),
]);
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 (!category) {
return { valid: false, error: "Invalid category" };
}
if (tagIds && tagIds.length > 0) {
const validTags = await db
if (tagId) {
const validTag = await db
.select()
.from(tagsTable)
.where(
and(
inArray(tagsTable.id, tagIds),
eq(tagsTable.id, tagId),
eq(tagsTable.organizationId, organizationId),
),
)
.all();
.get();
if (validTags.length !== tagIds.length) {
return { valid: false, error: "Invalid tags" };
if (!validTag) {
return { valid: false, error: "Invalid tag" };
}
}
@@ -81,3 +84,9 @@ export function validateTimeRange(
return { valid: true, startDate, endDate };
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return EMAIL_REGEX.test(email) && email.length <= 320;
}

View File

@@ -1,8 +1,12 @@
import { defineMiddleware } from "astro/middleware";
import { validateSession } from "./lib/auth";
import { getUserFromToken } from "./lib/auth";
import { validateApiToken } from "./lib/api-auth";
const PUBLIC_ROUTES = ["/", "/login", "/signup"];
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = context.url;
const authHeader = context.request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.substring(7);
@@ -10,32 +14,26 @@ export const onRequest = defineMiddleware(async (context, next) => {
if (result) {
context.locals.user = result.user;
context.locals.session = null;
context.locals.scopes = result.scopes;
return next();
}
}
const sessionId = context.cookies.get("session_id")?.value;
const token = context.cookies.get("auth_token")?.value;
if (!sessionId) {
context.locals.user = null;
context.locals.session = null;
context.locals.scopes = null;
return next();
}
const result = await validateSession(sessionId);
if (result) {
context.locals.user = result.user;
context.locals.session = result.session;
context.locals.scopes = null;
if (token) {
const user = await getUserFromToken(token);
context.locals.user = user;
} else {
context.locals.user = null;
context.locals.session = null;
context.locals.scopes = null;
context.cookies.delete("session_id");
}
context.locals.scopes = null;
const isPublic =
PUBLIC_ROUTES.includes(pathname) || pathname.startsWith("/api/");
if (!isPublic && !context.locals.user) {
return context.redirect("/login");
}
return next();

View File

@@ -1,6 +1,7 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Avatar from '../../components/Avatar.astro';
import StatCard from '../../components/StatCard.astro';
import { db } from '../../db';
import { siteSettings, users } from '../../db/schema';
import { eq } from 'drizzle-orm';
@@ -21,52 +22,52 @@ const allUsers = await db.select().from(users).all();
---
<DashboardLayout title="Site Admin - Chronus">
<h1 class="text-3xl font-bold mb-6">Site Administration</h1>
<div class="mb-6">
<h1 class="text-2xl font-extrabold tracking-tight">Site Administration</h1>
<p class="text-base-content/60 text-sm mt-1">Manage users and site settings</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Statistics -->
<div class="stats shadow border border-base-200">
<div class="stat">
<div class="stat-title">Total Users</div>
<div class="stat-value">{allUsers.length}</div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
<StatCard
title="Total Users"
value={String(allUsers.length)}
description="Registered accounts"
icon="users"
color="text-primary"
/>
</div>
<!-- Settings -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">Site Settings</h2>
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">Site Settings</h2>
<form method="POST" action="/api/admin/settings">
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text flex-1 min-w-0 pr-4">
<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>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Allow New Registrations</legend>
<input
type="checkbox"
name="registration_enabled"
class="toggle toggle-primary shrink-0"
checked={registrationEnabled}
/>
</fieldset>
<div class="card-actions justify-end mt-6">
<button type="submit" class="btn btn-primary">Save Settings</button>
<div class="flex justify-end mt-4">
<button type="submit" class="btn btn-primary btn-sm">Save Settings</button>
</div>
</form>
</div>
</div>
<!-- Users List -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">All Users</h2>
<div class="card card-border bg-base-100">
<div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-content/20">
<h2 class="text-sm font-semibold">All Users</h2>
</div>
<div class="overflow-x-auto">
<table class="table">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
@@ -77,22 +78,22 @@ const allUsers = await db.select().from(users).all();
</thead>
<tbody>
{allUsers.map(u => (
<tr>
<tr class="hover">
<td>
<div class="flex items-center gap-3">
<Avatar name={u.name} />
<div class="font-bold">{u.name}</div>
<div class="font-medium">{u.name}</div>
</div>
</td>
<td>{u.email}</td>
<td class="text-base-content/60">{u.email}</td>
<td>
{u.isSiteAdmin ? (
<span class="badge badge-primary">Yes</span>
<span class="badge badge-xs badge-primary">Yes</span>
) : (
<span class="badge badge-ghost">No</span>
<span class="badge badge-xs badge-ghost">No</span>
)}
</td>
<td>{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
<td class="text-base-content/60">{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
</tr>
))}
</tbody>

View File

@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { verifyPassword, createSession } from "../../../lib/auth";
import { verifyPassword, setAuthCookie } from "../../../lib/auth";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
@@ -23,15 +23,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
return redirect("/login?error=invalid_credentials");
}
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
setAuthCookie(cookies, user.id);
return redirect("/dashboard");
};

View File

@@ -1,11 +1,7 @@
import type { APIRoute } from 'astro';
import { invalidateSession } from '../../../lib/auth';
import { clearAuthCookie } from '../../../lib/auth';
export const POST: APIRoute = async ({ cookies, redirect }) => {
const sessionId = cookies.get('session_id')?.value;
if (sessionId) {
await invalidateSession(sessionId);
cookies.delete('session_id', { path: '/' });
}
return redirect('/login');
export const POST: APIRoute = async ({ cookies }) => {
clearAuthCookie(cookies);
return new Response(null, { status: 200 });
};

View File

@@ -3,7 +3,7 @@ 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";
import { setAuthCookie, getOrigin } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, cookies }) => {
const body = await request.json();
@@ -50,11 +50,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
let verification;
try {
const { origin, hostname } = getOrigin();
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: challenge as string,
expectedOrigin: new URL(request.url).origin,
expectedRPID: new URL(request.url).hostname,
expectedOrigin: origin,
expectedRPID: hostname,
credential: {
id: passkey.id,
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
@@ -65,7 +66,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
},
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
console.error("Passkey authentication verification failed:", error);
return new Response(JSON.stringify({ error: "Verification failed" }), {
status: 400,
});
}
@@ -81,15 +83,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
})
.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,
});
setAuthCookie(cookies, user.id);
await db
.delete(passkeyChallenges)

View File

@@ -2,10 +2,18 @@ import type { APIRoute } from "astro";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeyChallenges } from "../../../../../db/schema";
import { lte } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const GET: APIRoute = async ({ request }) => {
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const { hostname } = getOrigin();
const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname,
rpID: hostname,
userVerification: "preferred",
});

View File

@@ -3,6 +3,7 @@ import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq, and, gt } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
@@ -41,14 +42,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
let verification;
try {
const { origin, hostname } = getOrigin();
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: challenge,
expectedOrigin: new URL(request.url).origin,
expectedRPID: new URL(request.url).hostname,
expectedOrigin: origin,
expectedRPID: hostname,
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
console.error("Passkey registration verification failed:", error);
return new Response(JSON.stringify({ error: "Verification failed" }), {
status: 400,
});
}

View File

@@ -2,7 +2,8 @@ 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";
import { eq, lte } from "drizzle-orm";
import { getOrigin } from "../../../../../lib/auth";
export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user;
@@ -13,13 +14,19 @@ export const GET: APIRoute = async ({ request, locals }) => {
});
}
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id),
});
const { hostname } = getOrigin();
const options = await generateRegistrationOptions({
rpName: "Chronus",
rpID: new URL(request.url).hostname,
rpID: hostname,
userName: user.email,
attestationType: "none",
excludeCredentials: userPasskeys.map((passkey) => ({

View File

@@ -6,7 +6,8 @@ import {
members,
siteSettings,
} from "../../../db/schema";
import { hashPassword, createSession } from "../../../lib/auth";
import { hashPassword, setAuthCookie } from "../../../lib/auth";
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation";
import { eq, count, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -37,6 +38,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
return redirect("/signup?error=missing_fields");
}
if (!isValidEmail(email)) {
return redirect("/signup?error=invalid_email");
}
if (name.length > MAX_LENGTHS.name) {
return redirect("/signup?error=name_too_long");
}
if (password.length > MAX_LENGTHS.password) {
return redirect("/signup?error=password_too_long");
}
if (password.length < 8) {
return redirect("/signup?error=password_too_short");
}
@@ -47,7 +60,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
.where(eq(users.email, email))
.get();
if (existingUser) {
return redirect("/signup?error=user_exists");
return redirect("/login?registered=true");
}
const passwordHash = await hashPassword(password);
@@ -73,15 +86,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
role: "owner",
});
const { sessionId, expiresAt } = await createSession(userId);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
setAuthCookie(cookies, userId);
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,23 +52,19 @@ 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();
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can delete clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can delete clients", { status: 403 });
}
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
await db.delete(clients).where(eq(clients.id, id)).run();
if (locals.scopes) {

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { clients, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user;
@@ -49,6 +50,25 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Client name is required", { status: 400 });
}
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address);
if (lengthError) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: lengthError }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(lengthError, { status: 400 });
}
try {
const client = await db
.select()
@@ -87,6 +107,17 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can update clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can update clients", { status: 403 });
}
await db
.update(clients)
.set({

View File

@@ -3,6 +3,7 @@ import { db } from "../../../db";
import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
@@ -45,6 +46,25 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("Name is required", { status: 400 });
}
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address);
if (lengthError) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: lengthError }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(lengthError, { status: 400 });
}
const userOrg = await db
.select()
.from(members)
@@ -55,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("No organization found", { status: 400 });
}
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can create clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can create clients", { status: 403 });
}
const id = nanoid();
await db.insert(clients).values({

View File

@@ -45,6 +45,11 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
return new Response("Unauthorized", { status: 401 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
return new Response("Only owners and admins can convert quotes", { status: 403 });
}
try {
const lastInvoice = await db
.select()

View File

@@ -1,4 +1,5 @@
import type { APIRoute } from "astro";
import { renderToStream } from "@ceereals/vue-pdf";
import { db } from "../../../../db";
import {
invoices,
@@ -8,95 +9,108 @@ 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,
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);
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;
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 });
}
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
if (!client) {
return new Response("Client not found", { status: 404 });
}
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const pdfDocument = createInvoiceDocument({
invoice,
items,
client,
organization,
});
const stream = await renderToStream(pdfDocument);
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
return new Response(stream as any, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (pdfError) {
// Restore console.log on error
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
throw pdfError;
}
return new Response(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.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,274 @@
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);
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);
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);
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`);
}
try {
await db.transaction(async (tx) => {
await tx.insert(invoiceItems).values(newItems);
if (entryIdsToUpdate.length > 0) {
await tx
.update(timeEntries)
.set({ invoiceId: invoice.id })
.where(inArray(timeEntries.id, entryIdsToUpdate));
}
const allItems = await tx
.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 tx
.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

@@ -3,6 +3,7 @@ import { db } from "../../../../../db";
import { invoiceItems, invoices, members } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
import { MAX_LENGTHS, exceedsLength } from "../../../../../lib/validation";
export const POST: APIRoute = async ({
request,
@@ -61,6 +62,11 @@ export const POST: APIRoute = async ({
return new Response("Missing required fields", { status: 400 });
}
const lengthError = exceedsLength("Description", description, MAX_LENGTHS.itemDescription);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr);

View File

@@ -60,6 +60,13 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 });
}
// Destructive status changes require owner/admin
const destructiveStatuses = ["void"];
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (destructiveStatuses.includes(status) && !isAdminOrOwner) {
return new Response("Only owners and admins can void invoices", { status: 403 });
}
try {
await db
.update(invoices)

View File

@@ -3,6 +3,7 @@ import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
const user = locals.user;
@@ -56,6 +57,14 @@ export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
return new Response("Missing required fields", { status: 400 });
}
const lengthError =
exceedsLength("Invoice number", number, MAX_LENGTHS.invoiceNumber) ||
exceedsLength("Currency", currency, MAX_LENGTHS.currency) ||
exceedsLength("Notes", notes, MAX_LENGTHS.invoiceNotes);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
try {
const issueDate = new Date(issueDateStr);
const dueDate = new Date(dueDateStr);

View File

@@ -43,6 +43,11 @@ export const POST: APIRoute = async ({ request, redirect, locals }) => {
return new Response("Unauthorized", { status: 401 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
return new Response("Only owners and admins can delete invoices", { status: 403 });
}
try {
// Delete invoice items first (manual cascade)
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));

View File

@@ -4,6 +4,7 @@ import path from "path";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
@@ -19,6 +20,8 @@ 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) {
@@ -27,6 +30,18 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
});
}
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address) ||
exceedsLength("Currency", defaultCurrency, MAX_LENGTHS.currency);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
try {
// Verify user is admin/owner of this organization
const membership = await db
@@ -65,7 +80,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
);
}
const ext = logo.name.split(".").pop() || "png";
const rawExt = (logo.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z]/g, "");
const allowedExtensions = ["png", "jpg", "jpeg"];
const ext = allowedExtensions.includes(rawExt) ? rawExt : "png";
const filename = `${organizationId}-${Date.now()}.${ext}`;
const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR
@@ -96,6 +113,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
defaultTaxRate: defaultTaxRate ? parseFloat(defaultTaxRate) : 0,
defaultCurrency: defaultCurrency || "USD",
};
if (logoUrl) {

View File

@@ -1,72 +1,89 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, users, clients, categories } from '../../../db/schema';
import { eq, and, gte, lte, desc } from 'drizzle-orm';
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 });
return new Response("Unauthorized", { status: 401 });
}
// Get current team from cookie
const currentTeamId = cookies.get('currentTeamId')?.value;
const currentTeamId = cookies.get("currentTeamId")?.value;
const userMemberships = await db.select()
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 });
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.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
const url = new URL(request.url);
const selectedMemberId = url.searchParams.get('member') || '';
const selectedCategoryId = url.searchParams.get('category') || '';
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 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':
case "today":
startDate.setHours(0, 0, 0, 0);
endDate.setHours(23, 59, 59, 999);
break;
case 'week':
case "week":
startDate.setDate(now.getDate() - 7);
break;
case 'month':
case "month":
startDate.setMonth(now.getMonth() - 1);
break;
case 'mtd':
case "mtd":
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
case 'ytd':
case "ytd":
startDate = new Date(now.getFullYear(), 0, 1);
break;
case 'last-month':
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':
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);
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);
const parts = customTo.split("-");
endDate = new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
23,
59,
59,
999,
);
}
break;
}
@@ -81,31 +98,44 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
conditions.push(eq(timeEntries.userId, selectedMemberId));
}
if (selectedCategoryId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
}
if (selectedClientId) {
conditions.push(eq(timeEntries.clientId, selectedClientId));
}
const entries = await db.select({
entry: timeEntries,
user: users,
client: clients,
category: categories,
})
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))
.innerJoin(categories, eq(timeEntries.categoryId, categories.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', 'Category', 'Description'];
const rows = entries.map(e => {
const headers = [
"Date",
"Start Time",
"End Time",
"Duration (h)",
"Member",
"Client",
"Tag",
"Description",
];
const sanitizeCell = (value: string): string => {
if (/^[=+\-@\t\r]/.test(value)) {
return `\t${value}`;
}
return value;
};
const rows = entries.map((e) => {
const start = e.entry.startTime;
const end = e.entry.endTime;
@@ -114,24 +144,26 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
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, '""')}"`,
`"${(e.category.name || '').replace(/"/g, '""')}"`,
`"${(e.entry.description || '').replace(/"/g, '""')}"`
].join(',');
end ? end.toLocaleTimeString() : "",
end ? duration.toFixed(2) : "Running",
`"${sanitizeCell((e.user.name || "").replace(/"/g, '""'))}"`,
`"${sanitizeCell((e.client.name || "").replace(/"/g, '""'))}"`,
`"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
`"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
].join(",");
});
const csvContent = [headers.join(','), ...rows].join('\n');
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"`,
"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

@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { users, members } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { isValidEmail } from '../../../lib/validation';
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
@@ -26,6 +27,10 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response('Email is required', { status: 400 });
}
if (!isValidEmail(email)) {
return new Response('Invalid email format', { status: 400 });
}
if (!['member', 'admin'].includes(role)) {
return new Response('Invalid role', { status: 400 });
}

View File

@@ -1,11 +1,12 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
import { timeEntries, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import {
validateTimeEntryResources,
validateTimeRange,
MAX_LENGTHS,
} from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
@@ -17,7 +18,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
const body = await request.json();
const { description, clientId, categoryId, startTime, endTime, tags } = body;
const { description, clientId, startTime, endTime, tagId } = body;
// Validation
if (!clientId) {
@@ -27,11 +28,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
if (!categoryId) {
return new Response(JSON.stringify({ error: "Category is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
if (description && description.length > MAX_LENGTHS.description) {
return new Response(
JSON.stringify({ error: `Description must be ${MAX_LENGTHS.description} characters or fewer` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (!startTime) {
@@ -81,8 +82,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
const resourceValidation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
categoryId,
tagIds: Array.isArray(tags) ? tags : undefined,
tagId: tagId || null,
});
if (!resourceValidation.valid) {
@@ -101,23 +101,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,

View File

@@ -1,9 +1,9 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members, timeEntryTags } 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";
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response("Unauthorized", { status: 401 });
@@ -11,15 +11,14 @@ 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 });
if (description && description.length > MAX_LENGTHS.description) {
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
}
const runningEntry = await db
@@ -47,8 +46,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
const validation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
categoryId,
tagIds: tags,
tagId,
});
if (!validation.valid) {
@@ -63,20 +61,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

@@ -3,8 +3,10 @@ import { db } from "../../../db";
import { users } from "../../../db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { MAX_LENGTHS } from "../../../lib/validation";
import { setAuthCookie } from "../../../lib/auth";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
@@ -53,6 +55,13 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response(msg, { status: 400 });
}
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
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
@@ -90,6 +99,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
.where(eq(users.id, user.id))
.run();
setAuthCookie(cookies, user.id);
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}

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

@@ -1,26 +1,15 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { clients, members } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
import { clients } from '../../db/schema';
import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const organizationId = userMembership.organizationId;
@@ -32,20 +21,20 @@ const allClients = await db.select()
<DashboardLayout title="Clients - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Clients</h1>
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
<h1 class="text-2xl font-extrabold tracking-tight">Clients</h1>
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Client</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{allClients.map(client => (
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title">{client.name}</h2>
{client.email && <p class="text-sm text-gray-500">{client.email}</p>}
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-4">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-sm btn-ghost">View</a>
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
<div class="card card-border bg-base-100">
<div class="card-body p-4 gap-1">
<h2 class="font-semibold">{client.name}</h2>
{client.email && <p class="text-sm text-base-content/60">{client.email}</p>}
<p class="text-xs text-base-content/60">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-3">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-xs btn-ghost">View</a>
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-xs btn-primary">Edit</a>
</div>
</div>
</div>
@@ -53,9 +42,9 @@ const allClients = await db.select()
</div>
{allClients.length === 0 && (
<div class="text-center py-12">
<p class="text-gray-500 mb-4">No clients yet</p>
<a href="/dashboard/clients/new" class="btn btn-primary">Add Your First Client</a>
<div class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-base-content/60 text-sm mb-4">No clients yet</p>
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Your First Client</a>
</div>
)}
</DashboardLayout>

View File

@@ -1,9 +1,11 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../../components/Icon.astro';
import ModalButton from '../../../../components/ModalButton.vue';
import { db } from '../../../../db';
import { clients, members } from '../../../../db/schema';
import { clients } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -11,20 +13,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients');
// 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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const client = await db.select()
.from(clients)
@@ -40,145 +30,129 @@ if (!client) return Astro.redirect('/dashboard/clients');
<DashboardLayout title={`Edit ${client.name} - Chronus`}>
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-xs">
<Icon name="arrow-left" class="w-4 h-4" />
</a>
<h1 class="text-3xl font-bold">Edit Client</h1>
<h1 class="text-2xl font-extrabold tracking-tight">Edit Client</h1>
</div>
<form method="POST" action={`/api/clients/${client.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Client Name</span>
</label>
<form method="POST" action={`/api/clients/${client.id}/update`} class="card card-border bg-base-100">
<div class="card-body p-4">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client Name</legend>
<input
type="text"
id="name"
name="name"
value={client.name}
placeholder="Acme Corp"
class="input input-bordered w-full"
class="input w-full"
required
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Email (optional)</legend>
<input
type="email"
id="email"
name="email"
value={client.email || ''}
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Phone (optional)</legend>
<input
type="tel"
id="phone"
name="phone"
value={client.phone || ''}
placeholder="+1 (780) 420-1337"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="divider">Address Details</div>
<div class="divider text-xs text-base-content/60">Address Details</div>
<div class="form-control">
<label class="label" for="street">
<span class="label-text">Street Address (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Street Address (optional)</legend>
<input
type="text"
id="street"
name="street"
value={client.street || ''}
placeholder="123 Business Rd"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="city">
<span class="label-text">City (optional)</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">City (optional)</legend>
<input
type="text"
id="city"
name="city"
value={client.city || ''}
placeholder="Edmonton"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State / Province (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">State / Province (optional)</legend>
<input
type="text"
id="state"
name="state"
value={client.state || ''}
placeholder="AB"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="zip">
<span class="label-text">Zip / Postal Code (optional)</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
<input
type="text"
id="zip"
name="zip"
value={client.zip || ''}
placeholder="10001"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="country">
<span class="label-text">Country (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Country (optional)</legend>
<input
type="text"
id="country"
name="country"
value={client.country || ''}
placeholder="Canada"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
</div>
<div class="card-actions justify-between mt-6">
<button
type="button"
class="btn btn-error btn-outline"
onclick={`document.getElementById('delete_modal').showModal()`}
<div class="flex justify-between items-center mt-4">
<ModalButton
client:load
modalId="delete_modal"
class="btn btn-error btn-outline btn-sm"
>
Delete Client
</button>
</ModalButton>
<div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button>
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
</div>
</div>
</div>
@@ -188,17 +162,17 @@ if (!client) return Astro.redirect('/dashboard/clients');
<!-- Delete Confirmation Modal -->
<dialog id="delete_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg text-error">Delete Client?</h3>
<p class="py-4">
<h3 class="font-semibold text-base text-error">Delete Client?</h3>
<p class="py-4 text-sm">
Are you sure you want to delete <strong>{client.name}</strong>?
This action cannot be undone and will delete all associated time entries.
</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
<button class="btn btn-sm">Cancel</button>
</form>
<form method="POST" action={`/api/clients/${client.id}/delete`}>
<button type="submit" class="btn btn-error">Delete</button>
<button type="submit" class="btn btn-error btn-sm">Delete</button>
</form>
</div>
</div>

View File

@@ -1,10 +1,14 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../../components/Icon.astro';
import ConfirmForm from '../../../../components/ConfirmForm.vue';
import ColorDot from '../../../../components/ColorDot.vue';
import { db } from '../../../../db';
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
import { clients, timeEntries, tags, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
import StatCard from '../../../../components/StatCard.astro';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -12,20 +16,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients');
// 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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const client = await db.select()
.from(clients)
@@ -40,12 +32,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)
@@ -73,35 +65,35 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<DashboardLayout title={`${client.name} - Clients - Chronus`}>
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
<a href="/dashboard/clients" class="btn btn-ghost btn-xs">
<Icon name="arrow-left" class="w-4 h-4" />
</a>
<h1 class="text-3xl font-bold">{client.name}</h1>
<h1 class="text-2xl font-extrabold tracking-tight">{client.name}</h1>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
<!-- Client Details Card -->
<div class="card bg-base-100 shadow-xl border border-base-200 lg:col-span-2">
<div class="card-body">
<div class="card card-border bg-base-100 lg:col-span-2">
<div class="card-body p-4">
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
<h2 class="text-sm font-semibold mb-3">{client.name}</h2>
<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" />
<div class="flex items-center gap-2 text-base-content/60 text-sm">
<Icon name="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" />
<div class="flex items-center gap-2 text-base-content/60 text-sm">
<Icon name="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="flex items-start gap-2 text-base-content/60">
<Icon name="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) && (
@@ -116,91 +108,90 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
</div>
</div>
<div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
<Icon name="heroicons:pencil" class="w-4 h-4" />
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-xs">
<Icon name="pencil" class="w-3 h-3" />
Edit
</a>
<form method="POST" action={`/api/clients/${client.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this client? This will also delete all associated time entries.');">
<button type="submit" class="btn btn-error btn-outline btn-sm">
<Icon name="heroicons:trash" class="w-4 h-4" />
<ConfirmForm client:load message="Are you sure you want to delete this client? This will also delete all associated time entries." action={`/api/clients/${client.id}/delete`}>
<button type="submit" class="btn btn-error btn-outline btn-xs">
<Icon name="trash" class="w-3 h-3" />
Delete
</button>
</form>
</ConfirmForm>
</div>
</div>
<div class="divider"></div>
<div class="divider my-2"></div>
<div class="stats shadow w-full">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">Total Time Tracked</div>
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
<div class="stat-desc">Across all projects</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
</div>
<div class="stat-title">Total Entries</div>
<div class="stat-value text-secondary">{totalEntriesCount}</div>
<div class="stat-desc">Recorded entries</div>
</div>
<div class="grid grid-cols-2 gap-3">
<StatCard
title="Total Time Tracked"
value={`${totalHours}h ${totalMinutes}m`}
description="Across all projects"
icon="clock"
color="text-primary"
/>
<StatCard
title="Total Entries"
value={String(totalEntriesCount)}
description="Recorded entries"
icon="list-bullet"
color="text-secondary"
/>
</div>
</div>
</div>
<!-- Meta Info Card -->
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Information</h3>
<div class="card card-border bg-base-100 h-fit">
<div class="card-body p-4">
<h3 class="text-sm font-semibold mb-3">Information</h3>
<div class="space-y-4">
<div class="space-y-3">
<div>
<div class="text-sm font-medium text-base-content/60">Created</div>
<div>{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
<div class="text-xs text-base-content/60">Created</div>
<div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">Recent Activity</h2>
<div class="card card-border bg-base-100">
<div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-content/20">
<h2 class="text-sm font-semibold">Recent Activity</h2>
</div>
{recentEntries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table">
<table class="table table-sm">
<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 }) => (
<tr>
{recentEntries.map(({ entry, tag, user: entryUser }) => (
<tr class="hover">
<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-xs badge-outline flex items-center gap-1">
{tag.color && (
<ColorDot client:load color={tag.color} class="w-2 h-2 rounded-full" />
)}
<span>{tag.name}</span>
</div>
) : '-'}
</td>
<td>{entryUser?.name || 'Unknown'}</td>
<td>{entry.startTime.toLocaleDateString()}</td>
<td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
<td class="text-base-content/60">{entry.startTime.toLocaleDateString()}</td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
</tr>
))}
@@ -208,14 +199,14 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
</table>
</div>
) : (
<div class="text-center py-8 text-base-content/60">
<div class="text-center py-8 text-base-content/60 text-sm">
No time entries recorded for this client yet.
</div>
)}
{recentEntries.length > 0 && (
<div class="card-actions justify-center mt-4">
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-sm">
<div class="flex justify-center py-3 border-t border-base-content/20">
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs">
View All Entries
</a>
</div>

View File

@@ -7,124 +7,108 @@ if (!user) return Astro.redirect('/login');
<DashboardLayout title="New Client - Chronus">
<div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Add New Client</h1>
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="form-control">
<label class="label" for="name">
<span class="label-text">Client Name</span>
</label>
<form method="POST" action="/api/clients/create" class="card card-border bg-base-100">
<div class="card-body p-4">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client Name</legend>
<input
type="text"
id="name"
name="name"
placeholder="Acme Corp"
class="input input-bordered w-full"
class="input w-full"
required
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="email">
<span class="label-text">Email (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Email (optional)</legend>
<input
type="email"
id="email"
name="email"
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Phone (optional)</legend>
<input
type="tel"
id="phone"
name="phone"
placeholder="+1 (780) 420-1337"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="divider">Address Details</div>
<div class="divider text-xs text-base-content/60">Address Details</div>
<div class="form-control">
<label class="label" for="street">
<span class="label-text">Street Address (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Street Address (optional)</legend>
<input
type="text"
id="street"
name="street"
placeholder="123 Business Rd"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="city">
<span class="label-text">City (optional)</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">City (optional)</legend>
<input
type="text"
id="city"
name="city"
placeholder="Edmonton"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State / Province (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">State / Province (optional)</legend>
<input
type="text"
id="state"
name="state"
placeholder="AB"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="zip">
<span class="label-text">Zip / Postal Code (optional)</span>
</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
<input
type="text"
id="zip"
name="zip"
placeholder="10001"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
<div class="form-control">
<label class="label" for="country">
<span class="label-text">Country (optional)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Country (optional)</legend>
<input
type="text"
id="country"
name="country"
placeholder="Canada"
class="input input-bordered w-full"
class="input w-full"
/>
</div>
</fieldset>
</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>
<div class="flex justify-end gap-2 mt-4">
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary btn-sm">Create Client</button>
</div>
</div>
</form>

View File

@@ -1,8 +1,10 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../components/Icon.astro';
import StatCard from '../../components/StatCard.astro';
import ColorDot from '../../components/ColorDot.vue';
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';
@@ -87,11 +89,11 @@ if (currentOrg) {
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)
@@ -103,28 +105,28 @@ const hasMembership = userOrgs.length > 0;
---
<DashboardLayout title="Dashboard - Chronus">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-6">
<div>
<h1 class="text-4xl font-bold text-primary mb-2">
<h1 class="text-2xl font-extrabold tracking-tight">
Dashboard
</h1>
<p class="text-base-content/60">Welcome back, {user.name}!</p>
<p class="text-base-content/60 text-sm mt-1">Welcome back, {user.name}!</p>
</div>
<a href="/dashboard/organizations/new" class="btn btn-outline">
<Icon name="heroicons:plus" class="w-5 h-5" />
<a href="/dashboard/organizations/new" class="btn btn-ghost btn-sm">
<Icon name="plus" class="w-4 h-4" />
New Team
</a>
</div>
{!hasMembership && (
<div class="alert alert-info mb-8">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<div class="alert alert-info mb-6 text-sm">
<Icon name="information-circle" class="w-5 h-5" />
<div>
<h3 class="font-bold">Welcome to Chronus!</h3>
<div class="text-sm">You're not part of any team yet. Create one or wait for an invitation.</div>
<div class="text-xs">You're not part of any team yet. Create one or wait for an invitation.</div>
</div>
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
<Icon name="heroicons:plus" class="w-4 h-4" />
<Icon name="plus" class="w-4 h-4" />
New Team
</a>
</div>
@@ -133,63 +135,56 @@ const hasMembership = userOrgs.length > 0;
{hasMembership && (
<>
<!-- Stats Overview -->
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">This Week</div>
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:calendar" class="w-8 h-8" />
</div>
<div class="stat-title">This Month</div>
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:play-circle" class="w-8 h-8" />
</div>
<div class="stat-title">Active Timers</div>
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
<div class="stat-desc">Currently running</div>
</div>
<div class="stat">
<div class="stat-figure text-info">
<Icon name="heroicons:building-office" class="w-8 h-8" />
</div>
<div class="stat-title">Clients</div>
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
<div class="stat-desc">Total active</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<StatCard
title="This Week"
value={formatDuration(stats.totalTimeThisWeek)}
description="Total tracked time"
icon="clock"
color="text-primary"
/>
<StatCard
title="This Month"
value={formatDuration(stats.totalTimeThisMonth)}
description="Total tracked time"
icon="calendar"
color="text-secondary"
/>
<StatCard
title="Active Timers"
value={String(stats.activeTimers)}
description="Currently running"
icon="play-circle"
color="text-accent"
/>
<StatCard
title="Clients"
value={String(stats.totalClients)}
description="Total active"
icon="building-office"
color="text-info"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Quick Actions -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:bolt" class="w-6 h-6 text-warning" />
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="bolt" class="w-4 h-4 text-warning" />
Quick Actions
</h2>
<div class="flex flex-col gap-3 mt-4">
<a href="/dashboard/tracker" class="btn btn-primary">
<Icon name="heroicons:play" class="w-5 h-5" />
<div class="flex flex-col gap-2 mt-3">
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
<Icon name="play" class="w-4 h-4" />
Start Timer
</a>
<a href="/dashboard/clients/new" class="btn btn-outline">
<Icon name="heroicons:plus" class="w-5 h-5" />
<a href="/dashboard/clients/new" class="btn btn-ghost btn-sm">
<Icon name="plus" class="w-4 h-4" />
Add Client
</a>
<a href="/dashboard/reports" class="btn btn-outline">
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
<a href="/dashboard/reports" class="btn btn-ghost btn-sm">
<Icon name="chart-bar" class="w-4 h-4" />
View Reports
</a>
</div>
@@ -197,26 +192,31 @@ const hasMembership = userOrgs.length > 0;
</div>
<!-- Recent Activity -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<Icon name="heroicons:clock" class="w-6 h-6 text-success" />
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="clock" class="w-4 h-4 text-success" />
Recent Activity
</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'}`}>
<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...'}
<ul class="space-y-2 mt-3">
{stats.recentEntries.map(({ entry, client, tag }) => (
<ColorDot client:load as="li" color={tag?.color || 'oklch(var(--p))'} borderColor class="p-2.5 rounded-lg bg-base-200 border-l-3 hover:bg-base-300 transition-colors">
<div class="font-medium text-sm">{client.name}</div>
<div class="text-xs text-base-content/60 mt-0.5 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>
</ColorDot>
))}
</ul>
) : (
<div class="flex flex-col items-center justify-center py-8 text-center mt-4">
<Icon name="heroicons:clock" class="w-12 h-12 text-base-content/20 mb-3" />
<div class="flex flex-col items-center justify-center py-6 text-center mt-3">
<Icon name="clock" class="w-10 h-10 text-base-content/30 mb-2" />
<p class="text-base-content/60 text-sm">No recent time entries</p>
</div>
)}

View File

@@ -1,9 +1,12 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../components/Icon.astro';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import ModalButton from '../../../components/ModalButton.vue';
import { db } from '../../../db';
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { formatCurrency } from '../../../lib/formatTime';
const { id } = Astro.params;
const user = Astro.locals.user;
@@ -49,13 +52,6 @@ const items = await db.select()
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: invoice.currency,
}).format(amount / 100);
};
const isDraft = invoice.status === 'draft';
---
@@ -66,9 +62,9 @@ const isDraft = invoice.status === 'draft';
<div>
<div class="flex items-center gap-2 mb-1">
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs btn-square">
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
<Icon name="arrow-left" class="w-4 h-4" />
</a>
<div class={`badge ${
<div class={`badge badge-xs ${
invoice.status === 'paid' || invoice.status === 'accepted' ? 'badge-success' :
invoice.status === 'sent' ? 'badge-info' :
invoice.status === 'void' || invoice.status === 'declined' ? 'badge-error' :
@@ -77,15 +73,15 @@ const isDraft = invoice.status === 'draft';
{invoice.status}
</div>
</div>
<h1 class="text-3xl font-bold">{invoice.number}</h1>
<h1 class="text-2xl font-extrabold tracking-tight">{invoice.number}</h1>
</div>
<div class="flex gap-2">
{isDraft && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="sent" />
<button type="submit" class="btn btn-primary">
<Icon name="heroicons:paper-airplane" class="w-5 h-5" />
<button type="submit" class="btn btn-primary btn-sm">
<Icon name="paper-airplane" class="w-4 h-4" />
Mark Sent
</button>
</form>
@@ -93,8 +89,8 @@ const isDraft = invoice.status === 'draft';
{(invoice.status !== 'paid' && invoice.status !== 'void' && invoice.type === 'invoice') && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="paid" />
<button type="submit" class="btn btn-success">
<Icon name="heroicons:check" class="w-5 h-5" />
<button type="submit" class="btn btn-success btn-sm">
<Icon name="check" class="w-4 h-4" />
Mark Paid
</button>
</form>
@@ -102,34 +98,34 @@ const isDraft = invoice.status === 'draft';
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && invoice.type === 'quote') && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="accepted" />
<button type="submit" class="btn btn-success">
<Icon name="heroicons:check" class="w-5 h-5" />
<button type="submit" class="btn btn-success btn-sm">
<Icon name="check" class="w-4 h-4" />
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" />
<button type="submit" class="btn btn-primary btn-sm">
<Icon name="document-duplicate" class="w-4 h-4" />
Convert to Invoice
</button>
</form>
)}
<div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-square btn-ghost border border-base-300">
<Icon name="heroicons:ellipsis-horizontal" class="w-6 h-6" />
<div role="button" tabindex="0" class="btn btn-square btn-ghost btn-sm border border-base-content/20">
<Icon name="ellipsis-horizontal" class="w-4 h-4" />
</div>
<ul tabindex="0" class="dropdown-content z-1 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
<ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-content/20">
<li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
<Icon name="pencil-square" class="w-4 h-4" />
Edit Settings
</a>
</li>
<li>
<a href={`/api/invoices/${invoice.id}/generate`} download>
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
<Icon name="arrow-down-tray" class="w-4 h-4" />
Download PDF
</a>
</li>
@@ -138,20 +134,20 @@ const isDraft = invoice.status === 'draft';
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="void" />
<button type="submit" class="text-error">
<Icon name="heroicons:x-circle" class="w-4 h-4" />
<Icon name="x-circle" class="w-4 h-4" />
Void
</button>
</form>
</li>
)}
<li>
<form method="POST" action="/api/invoices/delete" onsubmit="return confirm('Are you sure?');">
<ConfirmForm client:load message="Are you sure?" action="/api/invoices/delete">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="text-error">
<Icon name="heroicons:trash" class="w-4 h-4" />
<Icon name="trash" class="w-4 h-4" />
Delete
</button>
</form>
</ConfirmForm>
</li>
</ul>
</div>
@@ -159,7 +155,7 @@ const isDraft = invoice.status === 'draft';
</div>
<!-- Invoice Paper -->
<div class="card bg-base-100 shadow-xl border border-base-200 print:shadow-none print:border-none">
<div class="card card-border bg-base-100 print:shadow-none print:border-none">
<div class="card-body p-8 sm:p-12">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between gap-8 mb-12">
@@ -178,7 +174,7 @@ const isDraft = invoice.status === 'draft';
)}
</div>
<div class="text-right">
<div class="text-4xl font-light text-base-content/30 uppercase tracking-widest mb-4">
<div class="text-4xl font-light text-base-content/50 uppercase tracking-widest mb-4">
{invoice.type}
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
@@ -194,7 +190,7 @@ const isDraft = invoice.status === 'draft';
<!-- Bill To -->
<div class="mb-12">
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Bill To</div>
<div class="text-xs font-bold uppercase tracking-wider text-base-content/60 mb-2">Bill To</div>
{client ? (
<div>
<div class="font-bold text-lg">{client.name}</div>
@@ -213,7 +209,7 @@ const isDraft = invoice.status === 'draft';
)}
</div>
) : (
<div class="italic text-base-content/40">Client deleted</div>
<div class="italic text-base-content/60">Client deleted</div>
)}
</div>
@@ -222,7 +218,7 @@ const isDraft = invoice.status === 'draft';
<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">
<tr class="border-b-2 border-base-content/20 text-left text-xs font-bold uppercase tracking-wider text-base-content/60">
<th class="py-3">Description</th>
<th class="py-3 text-right w-24">Qty</th>
<th class="py-3 text-right w-32">Price</th>
@@ -230,19 +226,19 @@ const isDraft = invoice.status === 'draft';
{isDraft && <th class="py-3 w-10"></th>}
</tr>
</thead>
<tbody class="divide-y divide-base-200">
<tbody class="divide-y divide-base-content/20">
{items.map(item => (
<tr>
<td class="py-4">{item.description}</td>
<td class="py-4 text-right">{item.quantity}</td>
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
<td class="py-4 text-right">{formatCurrency(item.unitPrice, invoice.currency)}</td>
<td class="py-4 text-right font-medium">{formatCurrency(item.amount, invoice.currency)}</td>
{isDraft && (
<td class="py-4 text-right">
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
<input type="hidden" name="itemId" value={item.id} />
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error opacity-50 hover:opacity-100">
<Icon name="heroicons:trash" class="w-4 h-4" />
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error opacity-70 hover:opacity-100">
<Icon name="trash" class="w-4 h-4" />
</button>
</form>
</td>
@@ -251,7 +247,7 @@ const isDraft = invoice.status === 'draft';
))}
{items.length === 0 && (
<tr>
<td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/40 italic">
<td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/60 italic">
No items added yet.
</td>
</tr>
@@ -263,24 +259,31 @@ const isDraft = invoice.status === 'draft';
<!-- Add Item Form (Only if Draft) -->
{isDraft && (
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-300/50">
<h4 class="text-sm font-bold mb-3">Add Item</h4>
<div class="grid grid-cols-1 sm:grid-cols-12 gap-4 items-end">
<div class="flex justify-end mb-4">
<ModalButton client:load modalId="import_time_modal" class="btn btn-sm btn-outline gap-2">
<Icon name="clock" class="w-4 h-4" />
Import Time
</ModalButton>
</div>
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200 p-4 rounded-lg mb-8 border border-base-content/20">
<h4 class="text-xs font-semibold mb-3">Add Item</h4>
<div class="grid grid-cols-1 sm:grid-cols-12 gap-3 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="text-xs text-base-content/60" for="item-description">Description</label>
<input type="text" id="item-description" name="description" class="input input-sm 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="text-xs text-base-content/60" for="item-quantity">Qty</label>
<input type="number" id="item-quantity" name="quantity" step="0.01" class="input input-sm 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="text-xs text-base-content/60" 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 w-full" required placeholder="0.00" />
</div>
<div class="sm:col-span-1">
<button type="submit" class="btn btn-sm btn-primary w-full">
<Icon name="heroicons:plus" class="w-4 h-4" />
<Icon name="plus" class="w-4 h-4" />
</button>
</div>
</div>
@@ -292,7 +295,7 @@ const isDraft = invoice.status === 'draft';
<div class="w-64 space-y-3">
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
<span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm">
@@ -300,7 +303,7 @@ const isDraft = invoice.status === 'draft';
Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
</div>
)}
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
@@ -308,26 +311,26 @@ const isDraft = invoice.status === 'draft';
<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>
<ModalButton client:load modalId="tax_modal" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<Icon name="pencil" class="w-3 h-3" />
</ModalButton>
)}
</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
</div>
)}
<div class="divider my-2"></div>
<div class="flex justify-between text-lg font-bold">
<span>Total</span>
<span class="text-primary">{formatCurrency(invoice.total)}</span>
<span class="text-primary">{formatCurrency(invoice.total, invoice.currency)}</span>
</div>
</div>
</div>
<!-- Notes -->
{invoice.notes && (
<div class="mt-12 pt-8 border-t border-base-200">
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Notes</div>
<div class="mt-12 pt-8 border-t border-base-content/20">
<div class="text-xs font-bold uppercase tracking-wider text-base-content/60 mb-2">Notes</div>
<div class="text-sm whitespace-pre-wrap opacity-80">{invoice.notes}</div>
</div>
)}
@@ -345,27 +348,59 @@ const isDraft = invoice.status === 'draft';
<!-- 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>
<h3 class="font-semibold text-base">Update Tax Rate</h3>
<p class="py-3 text-sm text-base-content/60">Enter the tax percentage to apply to the subtotal.</p>
<form method="POST" action={`/api/invoices/${invoice.id}/update-tax`}>
<div class="form-control mb-6">
<label class="label">
<span class="label-text">Tax Rate (%)</span>
</label>
<fieldset class="fieldset mb-4">
<legend class="fieldset-legend text-xs">Tax Rate (%)</legend>
<input
type="number"
id="tax-rate"
name="taxRate"
step="0.01"
min="0"
max="100"
class="input input-bordered w-full"
class="input w-full"
value={invoice.taxRate ?? 0}
required
/>
</div>
</fieldset>
<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>
<ModalButton client:load modalId="tax_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">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-semibold text-base">Import Time Entries</h3>
<p class="py-3 text-sm text-base-content/60">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-3 mb-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Start Date</legend>
<input type="date" id="start-date" name="startDate" class="input" required />
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">End Date</legend>
<input type="date" id="end-date" name="endDate" class="input" required />
</fieldset>
</div>
<label class="label cursor-pointer justify-start gap-3 mb-4">
<input type="checkbox" name="groupByDay" class="checkbox checkbox-sm" />
<span class="text-sm">Group entries by day</span>
</label>
<div class="modal-action">
<ModalButton client:load modalId="import_time_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary btn-sm">Import</button>
</div>
</form>
</div>

View File

@@ -1,6 +1,6 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../../components/Icon.astro';
import { db } from '../../../../db';
import { invoices, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
@@ -47,130 +47,120 @@ const discountValueDisplay = invoice.discountType === 'fixed'
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60">
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-xs gap-2 pl-0 hover:bg-transparent text-base-content/60">
<Icon name="arrow-left" class="w-4 h-4" />
Back to Invoice
</a>
<h1 class="text-3xl font-bold mt-2">Edit Details</h1>
<h1 class="text-2xl font-extrabold tracking-tight mt-2">Edit Details</h1>
</div>
<form method="POST" action={`/api/invoices/${invoice.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body gap-6">
<form method="POST" action={`/api/invoices/${invoice.id}/update`} class="card card-border bg-base-100">
<div class="card-body p-4 gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Number</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Number</legend>
<input
type="text"
id="invoice-number"
name="number"
class="input input-bordered font-mono"
class="input font-mono"
value={invoice.number}
required
/>
</div>
</fieldset>
<!-- Currency -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Currency</span>
</label>
<select name="currency" class="select select-bordered w-full">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Currency</legend>
<select id="invoice-currency" name="currency" class="select w-full">
<option value="USD" selected={invoice.currency === 'USD'}>USD ($)</option>
<option value="EUR" selected={invoice.currency === 'EUR'}>EUR (€)</option>
<option value="GBP" selected={invoice.currency === 'GBP'}>GBP (£)</option>
<option value="CAD" selected={invoice.currency === 'CAD'}>CAD ($)</option>
<option value="AUD" selected={invoice.currency === 'AUD'}>AUD ($)</option>
</select>
</div>
</fieldset>
<!-- Issue Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Issue Date</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Issue Date</legend>
<input
type="date"
id="invoice-issue-date"
name="issueDate"
class="input input-bordered"
class="input"
value={issueDateStr}
required
/>
</div>
</fieldset>
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">
{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}
</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}</legend>
<input
type="date"
id="invoice-due-date"
name="dueDate"
class="input input-bordered"
class="input"
value={dueDateStr}
required
/>
</div>
</fieldset>
<!-- Discount -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Discount</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Discount</legend>
<div class="join w-full">
<select name="discountType" class="select select-bordered join-item">
<select id="invoice-discount-type" name="discountType" class="select 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"
class="input join-item w-full"
value={discountValueDisplay}
/>
</div>
</div>
</fieldset>
<!-- Tax Rate -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Tax Rate (%)</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Tax Rate (%)</legend>
<input
type="number"
id="invoice-tax-rate"
name="taxRate"
step="0.01"
min="0"
max="100"
class="input input-bordered"
class="input"
value={invoice.taxRate}
/>
</div>
</fieldset>
</div>
<!-- Notes -->
<div class="form-control flex flex-col">
<label class="label">
<span class="label-text font-semibold">Notes / Terms</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Notes / Terms</legend>
<textarea
id="invoice-notes"
name="notes"
class="textarea textarea-bordered h-32 font-mono text-sm"
class="textarea h-32 font-mono text-sm"
placeholder="Payment terms, bank details, or thank you notes..."
>{invoice.notes}</textarea>
</div>
</fieldset>
<div class="divider"></div>
<div class="divider my-0"></div>
<div class="card-actions justify-end">
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">
<div class="flex justify-end gap-2">
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary btn-sm">
Save Changes
</button>
</div>

View File

@@ -1,47 +1,94 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../components/Icon.astro';
import StatCard from '../../../components/StatCard.astro';
import AutoSubmit from '../../../components/AutoSubmit.vue';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import { db } from '../../../db';
import { invoices, clients, members } from '../../../db/schema';
import { eq, desc, and } from 'drizzle-orm';
import { invoices, clients } from '../../../db/schema';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
import { formatCurrency } from '../../../lib/formatTime';
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
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: string | number = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam);
const yearNum = typeof selectedYear === 'number' ? selectedYear : currentYear;
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();
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
};
// 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 = new Date(yearNum, 0, 1);
const yearEnd = selectedYear === 'current' ? new Date() : new Date(yearNum, 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 getStatusColor = (status: string) => {
switch (status) {
@@ -59,60 +106,122 @@ const getStatusColor = (status: string) => {
<DashboardLayout title="Invoices & Quotes - Chronus">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 class="text-3xl font-bold">Invoices & Quotes</h1>
<p class="text-base-content/60 mt-1">Manage your billing and estimates</p>
<h1 class="text-2xl font-extrabold tracking-tight">Invoices & Quotes</h1>
<p class="text-base-content/60 text-sm mt-1">Manage your billing and estimates</p>
</div>
<a href="/dashboard/invoices/new" class="btn btn-primary">
<Icon name="heroicons:plus" class="w-5 h-5" />
<a href="/dashboard/invoices/new" class="btn btn-primary btn-sm">
<Icon name="plus" class="w-4 h-4" />
Create New
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:document-text" class="w-8 h-8" />
</div>
<div class="stat-title">Total Invoices</div>
<div class="stat-value text-primary">{allInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
<div class="stat-desc">All time</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
<StatCard
title="Total Invoices"
value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
description={selectedYear === 'current' ? `${currentYear} (YTD)` : String(selectedYear)}
icon="document-text"
color="text-primary"
/>
<StatCard
title="Open Quotes"
value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
description="Waiting for approval"
icon="clipboard-document-list"
color="text-secondary"
/>
<StatCard
title="Total Revenue"
value={formatCurrency(yearInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
icon="currency-dollar"
color="text-success"
/>
</div>
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
</div>
<div class="stat-title">Open Quotes</div>
<div class="stat-value text-secondary">{allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
<div class="stat-desc">Waiting for approval</div>
</div>
</div>
<!-- Filters -->
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Year</legend>
<AutoSubmit client:load>
<select name="year" class="select w-full">
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
{availableYears.map(year => (
<option value={year} selected={year === selectedYear}>{year}</option>
))}
</select>
</AutoSubmit>
</fieldset>
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Type</legend>
<AutoSubmit client:load>
<select name="type" class="select w-full">
<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>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend>
<AutoSubmit client:load>
<select name="status" class="select w-full">
<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>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend>
<AutoSubmit client:load>
<select name="sort" class="select w-full">
<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>
</AutoSubmit>
</fieldset>
</form>
{(selectedYear !== 'current' || selectedType !== 'all' || selectedStatus !== 'all' || sortBy !== 'date-desc') && (
<div class="mt-3">
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs">
<Icon name="x-mark" class="w-3 h-3" />
Clear Filters
</a>
</div>
<div class="stat-title">Total Revenue</div>
<div class="stat-value text-success">
{formatCurrency(allInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
</div>
<div class="stat-desc">Paid invoices</div>
</div>
)}
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card card-border bg-base-100">
<div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-content/20">
<p class="text-xs text-base-content/60">
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">
<table class="table table-sm">
<thead>
<tr class="bg-base-200/50">
<tr>
<th>Number</th>
<th>Client</th>
<th>Date</th>
@@ -126,14 +235,14 @@ const getStatusColor = (status: string) => {
<tbody>
{allInvoices.length === 0 ? (
<tr>
<td colspan="8" class="text-center py-8 text-base-content/60">
<td colspan="8" class="text-center py-8 text-base-content/60 text-sm">
No invoices or quotes found. Create one to get started.
</td>
</tr>
) : (
allInvoices.map(({ invoice, client }) => (
<tr class="hover:bg-base-200/50 transition-colors">
<td class="font-mono font-medium">
<tr class="hover">
<td class="font-mono font-medium text-sm">
<a href={`/dashboard/invoices/${invoice.id}`} class="link link-hover text-primary">
{invoice.number}
</a>
@@ -142,7 +251,7 @@ const getStatusColor = (status: string) => {
{client ? (
<div class="font-medium">{client.name}</div>
) : (
<span class="text-base-content/40 italic">Deleted Client</span>
<span class="text-base-content/60 italic">Deleted Client</span>
)}
</td>
<td>{invoice.issueDate.toLocaleDateString()}</td>
@@ -151,7 +260,7 @@ const getStatusColor = (status: string) => {
{formatCurrency(invoice.total, invoice.currency)}
</td>
<td>
<div class={`badge ${getStatusColor(invoice.status)} badge-sm uppercase font-bold tracking-wider`}>
<div class={`badge ${getStatusColor(invoice.status)} badge-xs uppercase font-bold tracking-wider`}>
{invoice.status}
</div>
</td>
@@ -160,25 +269,25 @@ const getStatusColor = (status: string) => {
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
<div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
<Icon name="ellipsis-vertical" class="w-4 h-4" />
</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 bg-base-100 rounded-box w-52 border border-base-content/20 z-100">
<li>
<a href={`/dashboard/invoices/${invoice.id}`}>
<Icon name="heroicons:eye" class="w-4 h-4" />
<Icon name="eye" class="w-4 h-4" />
View Details
</a>
</li>
<li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
<Icon name="pencil-square" class="w-4 h-4" />
Edit
</a>
</li>
<li>
<a href={`/api/invoices/${invoice.id}/generate`} download>
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
<Icon name="arrow-down-tray" class="w-4 h-4" />
Download PDF
</a>
</li>
@@ -187,7 +296,7 @@ const getStatusColor = (status: string) => {
<form method="POST" action={`/api/invoices/${invoice.id}/status`} class="w-full">
<input type="hidden" name="status" value="sent" />
<button type="submit" class="w-full justify-start">
<Icon name="heroicons:paper-airplane" class="w-4 h-4" />
<Icon name="paper-airplane" class="w-4 h-4" />
Mark as Sent
</button>
</form>
@@ -195,13 +304,13 @@ const getStatusColor = (status: string) => {
)}
<div class="divider my-1"></div>
<li>
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full">
<ConfirmForm client:load message="Are you sure? This action cannot be undone." action="/api/invoices/delete" class="w-full">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="w-full justify-start text-error hover:bg-error/10">
<Icon name="heroicons:trash" class="w-4 h-4" />
<button type="submit" class="w-full justify-start text-error hover:bg-base-300">
<Icon name="trash" class="w-4 h-4" />
Delete
</button>
</form>
</ConfirmForm>
</li>
</ul>
</div>

View File

@@ -1,30 +1,24 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../components/Icon.astro';
import { db } from '../../../db';
import { clients, members, invoices } from '../../../db/schema';
import { clients, invoices, organizations } from '../../../db/schema';
import { eq, desc, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
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)
@@ -86,124 +80,114 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<DashboardLayout title="New Document - Chronus">
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm gap-2 pl-0 hover:bg-transparent text-base-content/60">
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs gap-2 pl-0 hover:bg-transparent text-base-content/60">
<Icon name="arrow-left" class="w-4 h-4" />
Back to Invoices
</a>
<h1 class="text-3xl font-bold mt-2">Create New Document</h1>
<h1 class="text-2xl font-extrabold tracking-tight mt-2">Create New Document</h1>
</div>
{teamClients.length === 0 ? (
<div role="alert" class="alert alert-warning shadow-lg">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
<div role="alert" class="alert alert-warning">
<Icon name="exclamation-triangle" class="w-5 h-5" />
<div>
<h3 class="font-bold">No Clients Found</h3>
<h3 class="font-semibold text-sm">No Clients Found</h3>
<div class="text-xs">You need to add a client before you can create an invoice or quote.</div>
</div>
<a href="/dashboard/clients" class="btn btn-sm">Manage Clients</a>
</div>
) : (
<form method="POST" action="/api/invoices/create" class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body gap-6">
<form method="POST" action="/api/invoices/create" class="card card-border bg-base-100">
<div class="card-body p-4 gap-4">
<!-- Document Type -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Document Type</span>
</label>
<div class="flex gap-4">
<label class="label cursor-pointer justify-start gap-2 border border-base-300 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 transition-all">
<input type="radio" name="type" value="invoice" class="radio radio-primary" checked />
<span class="label-text font-medium">Invoice</span>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Document Type</legend>
<div class="flex gap-3">
<label class="label cursor-pointer justify-start gap-2 border border-base-content/20 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-base-200 transition-all font-medium text-sm" for="document-type-invoice">
<input type="radio" id="document-type-invoice" name="type" value="invoice" class="radio radio-primary radio-sm" 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-content/20 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-base-200 transition-all font-medium text-sm" for="document-type-quote">
<input type="radio" id="document-type-quote" name="type" value="quote" class="radio radio-primary radio-sm" />
Quote / Estimate
</label>
</div>
</div>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Client -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Client</span>
</label>
<select name="clientId" class="select select-bordered w-full" required>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend>
<select id="invoice-client" name="clientId" class="select w-full" required>
<option value="" disabled selected>Select a client...</option>
{teamClients.map(client => (
<option value={client.id}>{client.name}</option>
))}
</select>
</div>
</fieldset>
<!-- Number -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Number</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Number</legend>
<input
type="text"
name="number"
id="documentNumber"
class="input input-bordered font-mono"
class="input font-mono"
value={nextInvoiceNumber}
data-invoice-number={nextInvoiceNumber}
data-quote-number={nextQuoteNumber}
required
/>
</div>
</fieldset>
<!-- Issue Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Issue Date</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Issue Date</legend>
<input
type="date"
id="invoice-issue-date"
name="issueDate"
class="input input-bordered"
class="input"
value={today}
required
/>
</div>
</fieldset>
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold" id="dueDateLabel">Due Date</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs" id="dueDateLabel">Due Date</legend>
<input
type="date"
id="invoice-due-date"
name="dueDate"
class="input input-bordered"
class="input"
value={defaultDueDate}
required
/>
</div>
</fieldset>
<!-- Currency -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Currency</span>
</label>
<select name="currency" class="select select-bordered w-full">
<option value="USD">USD ($)</option>
<option value="EUR">EUR ()</option>
<option value="GBP">GBP (£)</option>
<option value="CAD">CAD ($)</option>
<option value="AUD">AUD ($)</option>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Currency</legend>
<select id="invoice-currency" name="currency" class="select 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>
</fieldset>
</div>
<div class="divider"></div>
<div class="divider my-0"></div>
<div class="card-actions justify-end">
<a href="/dashboard/invoices" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">
<div class="flex justify-end gap-2">
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary btn-sm">
Create Draft
<Icon name="heroicons:arrow-right" class="w-4 h-4" />
<Icon name="arrow-right" class="w-4 h-4" />
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import Icon from '../../../components/Icon.astro';
import { db } from '../../../db';
import { organizations, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
@@ -12,36 +12,34 @@ if (!user) return Astro.redirect('/login');
<DashboardLayout title="Create Team - Chronus">
<div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6">
<a href="/dashboard" class="btn btn-ghost btn-sm">
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
<a href="/dashboard" class="btn btn-ghost btn-xs">
<Icon name="arrow-left" class="w-4 h-4" />
</a>
<h1 class="text-3xl font-bold">Create New Team</h1>
<h1 class="text-2xl font-extrabold tracking-tight">Create New Team</h1>
</div>
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<form method="POST" action="/api/organizations/create" class="card card-border bg-base-100">
<div class="card-body p-4">
<div class="alert alert-info mb-4">
<Icon name="heroicons:information-circle" class="w-6 h-6" />
<span>Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
<Icon name="information-circle" class="w-4 h-4" />
<span class="text-sm">Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
</div>
<div class="form-control">
<label class="label pb-2" for="name">
<span class="label-text font-medium">Team Name</span>
</label>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Team Name</legend>
<input
type="text"
id="name"
name="name"
placeholder="Acme Corp"
class="input input-bordered w-full"
class="input w-full"
required
/>
</div>
</fieldset>
<div class="card-actions justify-end mt-6">
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Team</button>
<div class="flex justify-end gap-2 mt-4">
<a href="/dashboard" class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary btn-sm">Create Team</button>
</div>
</div>
</form>

View File

@@ -1,31 +1,23 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import CategoryChart from '../../components/CategoryChart.vue';
import Icon from '../../components/Icon.astro';
import StatCard from '../../components/StatCard.astro';
import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import AutoSubmit from '../../components/AutoSubmit.vue';
import ColorDot from '../../components/ColorDot.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';
import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
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 userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const teamMembers = await db.select({
id: users.id,
@@ -37,9 +29,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,7 +41,7 @@ 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');
@@ -102,8 +94,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) {
@@ -114,12 +106,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();
@@ -140,9 +132,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());
}
@@ -150,9 +142,9 @@ const statsByCategory = allCategories.map(category => {
}, 0);
return {
category,
tag,
totalTime,
entryCount: categoryEntries.length,
entryCount: tagEntries.length,
};
}).sort((a, b) => b.totalTime - a.totalTime);
@@ -247,13 +239,6 @@ const revenueByClient = allClients.map(client => {
};
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
function formatCurrency(amount: number, currency: string = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
}
function getTimeRangeLabel(range: string) {
switch (range) {
case 'today': return 'Today';
@@ -269,168 +254,139 @@ function getTimeRangeLabel(range: string) {
---
<DashboardLayout title="Reports - Chronus">
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Team Reports</h1>
<!-- Filters -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
<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>
<select 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>
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Time Range</legend>
<AutoSubmit client:load>
<select id="reports-range" name="range" class="select w-full">
<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>
</AutoSubmit>
</fieldset>
{timeRange === 'custom' && (
<>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">From Date</span>
</label>
<input
type="date"
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">
<span class="label-text font-medium">To Date</span>
</label>
<input
type="date"
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>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">From Date</legend>
<AutoSubmit client:load>
<input
type="date"
id="reports-from"
name="from"
class="input w-full"
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
/>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">To Date</legend>
<AutoSubmit client:load>
<input
type="date"
id="reports-to"
name="to"
class="input w-full"
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
/>
</AutoSubmit>
</fieldset>
</>
)}
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Team Member</span>
</label>
<select 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}>
{member.name}
</option>
))}
</select>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Team Member</legend>
<AutoSubmit client:load>
<select id="reports-member" name="member" class="select w-full">
<option value="">All Members</option>
{teamMembers.map(member => (
<option value={member.id} selected={selectedMemberId === member.id}>
{member.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Category</span>
</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}
</option>
))}
</select>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Tag</legend>
<AutoSubmit client:load>
<select id="reports-tag" name="tag" class="select w-full">
<option value="">All Tags</option>
{allTags.map(tag => (
<option value={tag.id} selected={selectedTagId === tag.id}>
{tag.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Client</span>
</label>
<select 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}>
{client.name}
</option>
))}
</select>
</div>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend>
<AutoSubmit client:load>
<select id="reports-client" name="client" class="select w-full">
<option value="">All Clients</option>
{allClients.map(client => (
<option value={client.id} selected={selectedClientId === client.id}>
{client.name}
</option>
))}
</select>
</AutoSubmit>
</fieldset>
</form>
<style>
@media (max-width: 767px) {
form {
align-items: stretch !important;
}
.form-control {
width: 100%;
}
}
select, input {
width: 100%;
}
</style>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">Total Time</div>
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
</div>
<div class="stat-title">Total Entries</div>
<div class="stat-value text-secondary">{entries.length}</div>
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
</div>
<div class="stat-title">Revenue</div>
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:user-group" class="w-8 h-8" />
</div>
<div class="stat-title">Active Members</div>
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
<div class="stat-desc">of {teamMembers.length} total</div>
</div>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<StatCard
title="Total Time"
value={formatDuration(totalTime)}
description={getTimeRangeLabel(timeRange)}
icon="clock"
color="text-primary"
/>
<StatCard
title="Total Entries"
value={String(entries.length)}
description={getTimeRangeLabel(timeRange)}
icon="list-bullet"
color="text-secondary"
/>
<StatCard
title="Revenue"
value={formatCurrency(revenueStats.total)}
description={`${invoiceStats.paid} paid invoices`}
icon="currency-dollar"
color="text-success"
/>
<StatCard
title="Active Members"
value={String(statsByMember.filter(s => s.entryCount > 0).length)}
description={`of ${teamMembers.length} total`}
icon="user-group"
color="text-accent"
/>
</div>
<!-- Invoice & Quote Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:document-text" class="w-6 h-6" />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="document-text" class="w-4 h-4" />
Invoices Overview
</h2>
<div class="grid grid-cols-2 gap-4">
@@ -438,11 +394,11 @@ function getTimeRangeLabel(range: string) {
<div class="stat-title text-xs">Total Invoices</div>
<div class="stat-value text-2xl">{invoiceStats.total}</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Paid</div>
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
</div>
<div class="stat bg-info/10 rounded-lg">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Sent</div>
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
</div>
@@ -463,10 +419,10 @@ function getTimeRangeLabel(range: string) {
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:clipboard-document-list" class="w-6 h-6" />
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="clipboard-document-list" class="w-4 h-4" />
Quotes Overview
</h2>
<div class="grid grid-cols-2 gap-4">
@@ -474,15 +430,15 @@ function getTimeRangeLabel(range: string) {
<div class="stat-title text-xs">Total Quotes</div>
<div class="stat-value text-2xl">{quoteStats.total}</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Accepted</div>
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
</div>
<div class="stat bg-info/10 rounded-lg">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Pending</div>
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
</div>
<div class="stat bg-error/10 rounded-lg">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Declined</div>
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
</div>
@@ -504,14 +460,14 @@ function getTimeRangeLabel(range: string) {
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
{!selectedClientId && revenueByClient.length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
<div class="card-body">
<h2 class="card-title mb-4">
<Icon name="heroicons:banknotes" class="w-6 h-6" />
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="banknotes" class="w-4 h-4" />
Revenue by Client
</h2>
<div class="overflow-x-auto">
<table class="table">
<table class="table table-sm">
<thead>
<tr>
<th>Client</th>
@@ -524,11 +480,11 @@ function getTimeRangeLabel(range: string) {
{revenueByClient.slice(0, 10).map(stat => (
<tr>
<td>
<div class="font-bold">{stat.client.name}</div>
<div class="font-medium">{stat.client.name}</div>
</td>
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td>
<td class="font-mono font-semibold text-success text-sm">{formatCurrency(stat.revenue)}</td>
<td>{stat.invoiceCount}</td>
<td class="font-mono">
<td class="font-mono text-sm">
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
</td>
</tr>
@@ -543,22 +499,22 @@ function getTimeRangeLabel(range: string) {
{/* Charts Section - Only show if there's data */}
{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 && (
<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
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{/* Tag Distribution Chart - Only show when no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="chart-pie" class="w-4 h-4" />
Tag Distribution
</h2>
<div class="h-64 w-full">
<CategoryChart
<TagChart
client:visible
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
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>
@@ -568,10 +524,10 @@ function getTimeRangeLabel(range: string) {
{/* Client Distribution Chart - Only show when no client filter */}
{!selectedClientId && statsByClient.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-bar" class="w-6 h-6" />
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="chart-bar" class="w-4 h-4" />
Time by Client
</h2>
<div class="h-64 w-full">
@@ -590,10 +546,10 @@ function getTimeRangeLabel(range: string) {
{/* Team Member Chart - Only show when no member filter */}
{!selectedMemberId && statsByMember.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:users" class="w-6 h-6" />
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="users" class="w-4 h-4" />
Time by Team Member
</h2>
<div class="h-64 w-full">
@@ -613,14 +569,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Member - Only show if there's data and no member filter */}
{!selectedMemberId && statsByMember.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:users" class="w-6 h-6" />
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="users" class="w-4 h-4" />
By Team Member
</h2>
<div class="overflow-x-auto">
<table class="table">
<table class="table table-sm">
<thead>
<tr>
<th>Member</th>
@@ -634,13 +590,13 @@ function getTimeRangeLabel(range: string) {
<tr>
<td>
<div>
<div class="font-bold">{stat.member.name}</div>
<div class="text-sm opacity-50">{stat.member.email}</div>
<div class="font-medium">{stat.member.name}</div>
<div class="text-xs text-base-content/60">{stat.member.email}</div>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td class="font-mono">
<td class="font-mono text-sm">
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
</td>
</tr>
@@ -652,45 +608,45 @@ 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 && (
<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
{/* 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 card-border bg-base-100 mb-6">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="tag" class="w-4 h-4" />
By Tag
</h2>
<div class="overflow-x-auto">
<table class="table">
<table class="table table-sm">
<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 && (
<ColorDot client:load color={stat.tag.color} class="w-3 h-3 rounded-full" />
)}
<span>{stat.category.name}</span>
<span>{stat.tag.name}</span>
</div>
</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-primary w-20"
class="progress progress-primary w-16"
value={stat.totalTime}
max={totalTime}
></progress>
<span class="text-sm">
<span class="text-xs">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span>
</div>
@@ -706,14 +662,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Client - Only show if there's data and no client filter */}
{!selectedClientId && statsByClient.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:building-office" class="w-6 h-6" />
<div class="card card-border bg-base-100 mb-6">
<div class="card-body p-4">
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="building-office" class="w-4 h-4" />
By Client
</h2>
<div class="overflow-x-auto">
<table class="table">
<table class="table table-sm">
<thead>
<tr>
<th>Client</th>
@@ -726,16 +682,16 @@ function getTimeRangeLabel(range: string) {
{statsByClient.filter(s => s.totalTime > 0).map(stat => (
<tr>
<td>{stat.client.name}</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-secondary w-20"
class="progress progress-secondary w-16"
value={stat.totalTime}
max={totalTime}
></progress>
<span class="text-sm">
<span class="text-xs">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span>
</div>
@@ -750,29 +706,29 @@ function getTimeRangeLabel(range: string) {
)}
{/* Detailed Entries */}
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">
<Icon name="heroicons:document-text" class="w-6 h-6" />
<div class="card card-border bg-base-100">
<div class="card-body p-4">
<div class="flex justify-between items-center mb-3">
<h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="document-text" class="w-4 h-4" />
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" />
<a href={`/api/reports/export${url.search}`} class="btn btn-xs btn-ghost" target="_blank">
<Icon name="arrow-down-tray" class="w-3.5 h-3.5" />
Export CSV
</a>
)}
</div>
{entries.length > 0 ? (
<div class="overflow-x-auto">
<table class="table table-zebra">
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Member</th>
<th>Client</th>
<th>Category</th>
<th>Tag</th>
<th>Description</th>
<th>Duration</th>
</tr>
@@ -782,22 +738,26 @@ function getTimeRangeLabel(range: string) {
<tr>
<td class="whitespace-nowrap">
{e.entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50">
<span class="text-xs text-base-content/60">
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</td>
<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-xs badge-outline flex items-center gap-1">
{e.tag.color && (
<ColorDot client:load color={e.tag.color} class="w-2 h-2 rounded-full" />
)}
<span>{e.tag.name}</span>
</div>
) : (
<span class="text-base-content/60">-</span>
)}
</td>
<td>{e.entry.description || '-'}</td>
<td class="font-mono">
<td class="text-base-content/60">{e.entry.description || '-'}</td>
<td class="font-mono text-sm">
{e.entry.endTime
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
: 'Running...'
@@ -809,12 +769,12 @@ function getTimeRangeLabel(range: string) {
</table>
</div>
) : (
<div class="flex flex-col items-center justify-center py-12 text-center">
<Icon name="heroicons:inbox" class="w-16 h-16 text-base-content/20 mb-4" />
<h3 class="text-lg font-semibold mb-2">No time entries found</h3>
<p class="text-base-content/60 mb-4">Try adjusting your filters or select a different time range.</p>
<a href="/dashboard/tracker" class="btn btn-primary">
<Icon name="heroicons:play" class="w-5 h-5" />
<div class="flex flex-col items-center justify-center py-10 text-center">
<Icon name="inbox" class="w-12 h-12 text-base-content/30 mb-3" />
<h3 class="text-base font-semibold mb-1">No time entries found</h3>
<p class="text-base-content/60 text-sm mb-4">Try adjusting your filters or select a different time range.</p>
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
<Icon name="play" class="w-4 h-4" />
Start Tracking Time
</a>
</div>

Some files were not shown because too many files have changed in this diff Show More