45 Commits

Author SHA1 Message Date
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
df82a02f41 2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-19 21:08:46 -07:00
8a3932a013 Optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2026-01-19 20:55:47 -07:00
d4a2c5853b 2.2.0 Migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-01-19 19:41:56 -07:00
ee9807e8e0 Passkeys!
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-19 15:53:05 -07:00
bf2a1816db Added custom ranges for report filtering + CSV exports
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m59s
2026-01-19 15:18:34 -07:00
1063bf99f1 2.1.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m24s
2026-01-19 10:06:23 -07:00
ea0a83f44d Added discounts to invoices
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-19 10:06:04 -07:00
fa2c92644a Forgot these...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m4s
2026-01-18 14:57:32 -07:00
3d4b8762e5 Oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m8s
2026-01-18 14:47:45 -07:00
5e70dd6bb8 2.0.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m49s
2026-01-18 14:27:47 -07:00
ce47de9e56 Fixed icons for Vue... I guess we need to be consistent.
All checks were successful
Docker Deploy / build-and-push (push) Successful in 8m22s
2026-01-18 13:46:03 -07:00
db1d180afc Removed footer
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m52s
2026-01-18 01:43:21 -07:00
82e1b8a626 Style updates
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-18 01:40:22 -07:00
253c24c89b Last try!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-01-17 22:41:56 -07:00
39c51b1115 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m50s
2026-01-17 22:30:54 -07:00
091766d6e4 Fixed a few things lol
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m11s
2026-01-17 22:19:10 -07:00
0cd77677f2 FINISHED
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
2026-01-17 15:56:25 -07:00
3734b2693a Moved to lbSQL fully
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m33s
2026-01-17 10:58:10 -07:00
996092d14e 1.3.0 - Invoices, Manual entries, and Auto Migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m55s
2026-01-17 01:39:12 -07:00
aae8693dd3 Trying this again...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-01-17 01:32:07 -07:00
bebc4b2743 Responsive updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m36s
2026-01-17 01:01:53 -07:00
7026435cd3 Changed DB driver
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m42s
2026-01-16 18:45:28 -07:00
85750a5c79 Fixed docker
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-01-16 18:20:47 -07:00
6aa4548a38 ????
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m40s
2026-01-16 18:00:55 -07:00
42fbea6ae7 pls
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m19s
2026-01-16 17:55:36 -07:00
c4ecc0b899 :|
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m40s
2026-01-16 17:39:57 -07:00
5aa9388678 Trying this...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-16 17:24:50 -07:00
15b903f1af Adding manual entries + UI cleanup 2026-01-16 16:28:06 -07:00
104 changed files with 12543 additions and 3001 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -1,2 +1,10 @@
# Chronus # Chronus
A modern time tracking application built with Astro, Vue, and DaisyUI. A modern time tracking application built with Astro, Vue, and DaisyUI.
## Stack
- Framework: Astro
- Runtime: Node
- UI Library: Vue 3
- CSS and Styles: DaisyUI + Tailwind CSS
- Database: libSQL
- ORM: Drizzle ORM

View File

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

View File

@@ -7,7 +7,7 @@ services:
- NODE_ENV=production - NODE_ENV=production
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=4321 - PORT=4321
- DATABASE_URL=/app/data/chronus.db - DATA_DIR=/app/data
volumes: volumes:
- ${ROOT_DIR}:/app/data - ${ROOT_DIR}:/app/data
restart: unless-stopped restart: unless-stopped

View File

@@ -1,10 +1,27 @@
import { defineConfig } from 'drizzle-kit'; import { defineConfig } from "drizzle-kit";
import fs from "fs";
import path from "path";
import * as dotenv from "dotenv";
dotenv.config();
const dataDir = process.env.DATA_DIR;
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbUrl = `file:${path.join(dataDir, "chronus.db")}`;
export default defineConfig({ export default defineConfig({
schema: './src/db/schema.ts', schema: "./src/db/schema.ts",
out: './drizzle', out: "./drizzle",
dialect: 'sqlite', dialect: "turso",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL || 'chronus.db', url: dbUrl,
}, },
}); });

View File

@@ -0,0 +1,181 @@
CREATE TABLE `api_tokens` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`name` text NOT NULL,
`token` text NOT NULL,
`scopes` text DEFAULT '*' NOT NULL,
`last_used_at` integer,
`created_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_tokens_token_unique` ON `api_tokens` (`token`);--> statement-breakpoint
CREATE 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,
`description` text NOT NULL,
`quantity` real DEFAULT 1 NOT NULL,
`unit_price` integer DEFAULT 0 NOT NULL,
`amount` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE 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,
`client_id` text NOT NULL,
`number` text NOT NULL,
`type` text DEFAULT 'invoice' NOT NULL,
`status` text DEFAULT 'draft' NOT NULL,
`issue_date` integer NOT NULL,
`due_date` integer NOT NULL,
`notes` text,
`currency` text DEFAULT 'USD' NOT NULL,
`subtotal` integer DEFAULT 0 NOT NULL,
`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,
`created_at` integer,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE 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,
`role` text DEFAULT 'member' NOT NULL,
`joined_at` integer,
PRIMARY KEY(`user_id`, `organization_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
CREATE TABLE `organizations` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`logo_url` text,
`street` text,
`city` text,
`state` text,
`zip` text,
`country` text,
`default_tax_rate` real DEFAULT 0,
`default_currency` text DEFAULT 'USD',
`created_at` integer
);
--> statement-breakpoint
CREATE TABLE `passkey_challenges` (
`id` text PRIMARY KEY NOT NULL,
`challenge` text NOT NULL,
`user_id` text,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `passkey_challenges_challenge_unique` ON `passkey_challenges` (`challenge`);--> statement-breakpoint
CREATE TABLE `passkeys` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`public_key` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`last_used_at` integer,
`created_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `passkeys_user_id_idx` ON `passkeys` (`user_id`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
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,
`value` text NOT NULL,
`updated_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_settings_key_unique` ON `site_settings` (`key`);--> statement-breakpoint
CREATE TABLE `tags` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`name` text NOT NULL,
`color` text,
`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,
`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
);
--> 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,
PRIMARY KEY(`time_entry_id`, `tag_id`),
FOREIGN KEY (`time_entry_id`) REFERENCES `time_entries`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE 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,
`password_hash` text NOT NULL,
`name` text NOT NULL,
`is_site_admin` integer DEFAULT false,
`created_at` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1768934194146,
"tag": "0000_lazy_rictor",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768935234392,
"tag": "0001_demonic_red_skull",
"breakpoints": true
}
]
}

View File

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

2839
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

40
scripts/migrate.js Normal file
View File

@@ -0,0 +1,40 @@
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { createClient } from "@libsql/client";
import path from "path";
import fs from "fs";
async function runMigrate() {
console.log("Running migrations...");
const dataDir = process.env.DATA_DIR;
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const url = `file:${path.join(dataDir, "chronus.db")}`;
console.log(`Using database: ${url}`);
const client = createClient({
url,
});
const db = drizzle(client);
try {
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations completed successfully");
} catch (error) {
console.error("Migration failed:", error);
process.exit(1);
} finally {
client.close();
}
}
runMigrate();

View File

@@ -0,0 +1,15 @@
---
interface Props {
name: string;
class?: string;
}
const { name, class: className } = Astro.props;
const initial = name ? name.charAt(0).toUpperCase() : '?';
---
<div class:list={["avatar placeholder", className]}>
<div class="bg-primary/15 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

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

View File

@@ -1,7 +0,0 @@
<footer class="footer footer-center p-4 bg-base-200 text-base-content border-t border-base-300">
<aside>
<p class="text-sm">
Made with <span class="text-red-500">❤️</span> by <a href="https://github.com/atridad" target="_blank" rel="noopener noreferrer" class="link link-hover font-semibold">Atridad Lahiji</a>
</p>
</aside>
</footer>

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
---
import { Icon } from 'astro-icon/components';
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-40"]}>
<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/50">{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", // Matches typical dark mode bg
borderWidth: 2,
},
],
}));
const chartOptions: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "right",
labels: {
color: "#e2e8f0",
usePointStyle: true,
padding: 20,
},
},
tooltip: {
callbacks: {
label: function (context) {
const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return ` ${hours}h ${mins}m`;
},
},
},
},
cutout: "70%",
};
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,41 @@
import Database from 'better-sqlite3'; import { drizzle } from "drizzle-orm/libsql";
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { createClient } from "@libsql/client";
import * as schema from './schema'; import * as schema from "./schema";
import path from 'path'; import path from "path";
import fs from "fs";
let _db: ReturnType<typeof drizzle> | null = null; type Database = ReturnType<typeof drizzle<typeof schema>>;
function initDb() { let _db: Database | null = null;
function initDb(): Database {
if (!_db) { if (!_db) {
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'chronus.db'); const dataDir = process.env.DATA_DIR
const sqlite = new Database(dbUrl, { readonly: false }); ? process.env.DATA_DIR
_db = drizzle(sqlite, { schema }); : import.meta.env.DATA_DIR;
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const url = `file:${path.join(dataDir, "chronus.db")}`;
const client = createClient({
url,
});
_db = drizzle(client, { schema });
} }
return _db; return _db;
} }
export const db = new Proxy({} as ReturnType<typeof drizzle>, { export const db = new Proxy({} as Database, {
get(_target, prop) { get(_target, prop) {
const database = initDb(); const database = initDb();
return database[prop as keyof typeof database]; return database[prop as keyof Database];
} },
}); });

View File

@@ -2,8 +2,10 @@ import {
sqliteTable, sqliteTable,
text, text,
integer, integer,
real,
primaryKey, primaryKey,
foreignKey, foreignKey,
index,
} from "drizzle-orm/sqlite-core"; } from "drizzle-orm/sqlite-core";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
@@ -25,6 +27,14 @@ export const organizations = sqliteTable("organizations", {
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
name: text("name").notNull(), name: text("name").notNull(),
logoUrl: text("logo_url"),
street: text("street"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
defaultTaxRate: real("default_tax_rate").default(0),
defaultCurrency: text("default_currency").default("USD"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),
@@ -50,6 +60,10 @@ export const members = sqliteTable(
columns: [table.organizationId], columns: [table.organizationId],
foreignColumns: [organizations.id], foreignColumns: [organizations.id],
}), }),
userIdIdx: index("members_user_id_idx").on(table.userId),
organizationIdIdx: index("members_organization_id_idx").on(
table.organizationId,
),
}), }),
); );
@@ -62,6 +76,12 @@ export const clients = sqliteTable(
organizationId: text("organization_id").notNull(), organizationId: text("organization_id").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
email: text("email"), email: text("email"),
phone: text("phone"),
street: text("street"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),
@@ -71,11 +91,14 @@ export const clients = sqliteTable(
columns: [table.organizationId], columns: [table.organizationId],
foreignColumns: [organizations.id], foreignColumns: [organizations.id],
}), }),
organizationIdIdx: index("clients_organization_id_idx").on(
table.organizationId,
),
}), }),
); );
export const categories = sqliteTable( export const tags = sqliteTable(
"categories", "tags",
{ {
id: text("id") id: text("id")
.primaryKey() .primaryKey()
@@ -83,6 +106,7 @@ export const categories = sqliteTable(
organizationId: text("organization_id").notNull(), organizationId: text("organization_id").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
color: text("color"), color: text("color"),
rate: integer("rate").default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),
@@ -92,6 +116,9 @@ export const categories = sqliteTable(
columns: [table.organizationId], columns: [table.organizationId],
foreignColumns: [organizations.id], foreignColumns: [organizations.id],
}), }),
organizationIdIdx: index("tags_organization_id_idx").on(
table.organizationId,
),
}), }),
); );
@@ -104,10 +131,12 @@ export const timeEntries = sqliteTable(
userId: text("user_id").notNull(), userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(), organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
categoryId: text("category_id").notNull(), tagId: text("tag_id"),
startTime: integer("start_time", { mode: "timestamp" }).notNull(), startTime: integer("start_time", { mode: "timestamp" }).notNull(),
endTime: integer("end_time", { mode: "timestamp" }), endTime: integer("end_time", { mode: "timestamp" }),
description: text("description"), description: text("description"),
invoiceId: text("invoice_id"),
isManual: integer("is_manual", { mode: "boolean" }).default(false),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),
@@ -125,50 +154,18 @@ export const timeEntries = sqliteTable(
columns: [table.clientId], columns: [table.clientId],
foreignColumns: [clients.id], foreignColumns: [clients.id],
}), }),
categoryFk: foreignKey({
columns: [table.categoryId],
foreignColumns: [categories.id],
}),
}),
);
export const tags = sqliteTable(
"tags",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
color: text("color"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
}),
);
export const timeEntryTags = sqliteTable(
"time_entry_tags",
{
timeEntryId: text("time_entry_id").notNull(),
tagId: text("tag_id").notNull(),
},
(table: any) => ({
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
timeEntryFk: foreignKey({
columns: [table.timeEntryId],
foreignColumns: [timeEntries.id],
}),
tagFk: foreignKey({ tagFk: foreignKey({
columns: [table.tagId], columns: [table.tagId],
foreignColumns: [tags.id], foreignColumns: [tags.id],
}), }),
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
organizationIdIdx: index("time_entries_organization_id_idx").on(
table.organizationId,
),
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
tagIdIdx: index("time_entries_tag_id_idx").on(table.tagId),
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
}), }),
); );
@@ -184,6 +181,7 @@ export const sessions = sqliteTable(
columns: [table.userId], columns: [table.userId],
foreignColumns: [users.id], foreignColumns: [users.id],
}), }),
userIdIdx: index("sessions_user_id_idx").on(table.userId),
}), }),
); );
@@ -218,5 +216,102 @@ export const apiTokens = sqliteTable(
columns: [table.userId], columns: [table.userId],
foreignColumns: [users.id], foreignColumns: [users.id],
}), }),
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
}), }),
); );
export const invoices = sqliteTable(
"invoices",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(),
number: text("number").notNull(),
type: text("type").notNull().default("invoice"),
status: text("status").notNull().default("draft"),
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
notes: text("notes"),
currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().default(0),
discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"),
discountAmount: integer("discount_amount").default(0),
taxRate: real("tax_rate").default(0),
taxAmount: integer("tax_amount").notNull().default(0),
total: integer("total").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
orgFk: foreignKey({
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
clientFk: foreignKey({
columns: [table.clientId],
foreignColumns: [clients.id],
}),
organizationIdIdx: index("invoices_organization_id_idx").on(
table.organizationId,
),
clientIdIdx: index("invoices_client_id_idx").on(table.clientId),
}),
);
export const invoiceItems = sqliteTable(
"invoice_items",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
invoiceId: text("invoice_id").notNull(),
description: text("description").notNull(),
quantity: real("quantity").notNull().default(1),
unitPrice: integer("unit_price").notNull().default(0),
amount: integer("amount").notNull().default(0),
},
(table: any) => ({
invoiceFk: foreignKey({
columns: [table.invoiceId],
foreignColumns: [invoices.id],
}),
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
}),
);
export const passkeys = sqliteTable(
"passkeys",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
publicKey: text("public_key").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
},
(table: any) => ({
userFk: foreignKey({
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("passkeys_user_id_idx").on(table.userId),
}),
);
export const passkeyChallenges = sqliteTable("passkey_challenges", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
challenge: text("challenge").notNull().unique(),
userId: text("user_id"),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
});

View File

@@ -4,7 +4,9 @@ import { Icon } from 'astro-icon/components';
import { db } from '../db'; import { db } from '../db';
import { members, organizations } from '../db/schema'; import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import Footer from '../components/Footer.astro'; import Avatar from '../components/Avatar.astro';
import ThemeToggle from '../components/ThemeToggle.vue';
import { ClientRouter } from "astro:transitions";
interface Props { interface Props {
title: string; title: string;
@@ -17,7 +19,6 @@ if (!user) {
return Astro.redirect('/login'); return Astro.redirect('/login');
} }
// Get user's team memberships
const userMemberships = await db.select({ const userMemberships = await db.select({
membership: members, membership: members,
organization: organizations, organization: organizations,
@@ -27,13 +28,26 @@ const userMemberships = await db.select({
.where(eq(members.userId, user.id)) .where(eq(members.userId, user.id))
.all(); .all();
// Get current team from cookie or use first membership
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id; const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId); const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
const navItems = [
{ href: '/dashboard', label: 'Dashboard', icon: 'heroicons:home', exact: true },
{ href: '/dashboard/tracker', label: 'Time Tracker', icon: 'heroicons:clock' },
{ href: '/dashboard/invoices', label: 'Invoices & Quotes', icon: 'heroicons:document-currency-dollar' },
{ href: '/dashboard/reports', label: 'Reports', icon: 'heroicons:chart-bar' },
{ href: '/dashboard/clients', label: 'Clients', icon: 'heroicons:building-office' },
{ href: '/dashboard/team', label: 'Team', icon: 'heroicons: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> <!doctype html>
<html lang="en" data-theme="dark"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="description" content="Chronus Dashboard" /> <meta name="description" content="Chronus Dashboard" />
@@ -41,47 +55,56 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title}</title> <title>{title}</title>
<ClientRouter />
<script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato';
document.documentElement.setAttribute('data-theme', theme);
</script>
</head> </head>
<body class="bg-linear-to-br from-base-100 via-base-200 to-base-100 h-screen flex flex-col overflow-hidden"> <body class="bg-base-100 h-screen flex flex-col overflow-hidden">
<div class="drawer lg:drawer-open flex-1 overflow-auto"> <div class="drawer lg:drawer-open flex-1 overflow-auto">
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" /> <input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col h-full overflow-auto"> <div class="drawer-content flex flex-col h-full overflow-auto">
<!-- Navbar --> <!-- Mobile Navbar -->
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-300"> <div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-200">
<div class="flex-none lg:hidden"> <div class="flex-none">
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost"> <label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm">
<Icon name="heroicons:bars-3" class="w-6 h-6" /> <Icon name="heroicons:bars-3" class="w-5 h-5" />
</label> </label>
</div> </div>
<div class="flex-1 px-2 flex items-center gap-2"> <div class="flex-1 px-2 flex items-center gap-2">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" /> <img src="/logo.webp" alt="Chronus" class="h-7 w-7" />
<span class="text-xl font-bold text-primary">Chronus</span> <span class="text-lg font-bold text-primary">Chronus</span>
</div>
<div class="flex-none">
<ThemeToggle client:load />
</div> </div>
</div> </div>
<!-- Page content here --> <!-- Page content -->
<main class="p-6 md:p-8"> <main class="flex-1 p-4 sm:p-6 lg:p-8">
<slot /> <slot />
</main> </main>
</div> </div>
<div class="drawer-side z-50"> <div class="drawer-side z-50">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label> <label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 min-h-full w-80 p-4"> <aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-300/40">
<!-- Sidebar content here --> <!-- Logo -->
<li class="mb-6"> <div class="px-5 pt-5 pb-3">
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary"> <a href="/dashboard" class="flex items-center gap-2.5 group">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" /> <img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
Chronus <span class="text-xl font-bold text-primary">Chronus</span>
</a> </a>
</li> </div>
{/* Team Switcher */} <!-- Team Switcher -->
{userMemberships.length > 0 && ( {userMemberships.length > 0 && (
<li class="mb-4"> <div class="px-4 pb-2">
<div class="form-control">
<select <select
class="select select-bordered w-full font-semibold" class="select select-sm w-full bg-base-300/40 border-base-300/60 focus:border-primary/50 focus:outline-none text-sm font-medium"
id="team-switcher" id="team-switcher"
aria-label="Switch team"
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();" onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
> >
{userMemberships.map(({ membership, organization }) => ( {userMemberships.map(({ membership, organization }) => (
@@ -94,80 +117,86 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
))} ))}
</select> </select>
</div> </div>
</li>
)} )}
{userMemberships.length === 0 && ( {userMemberships.length === 0 && (
<li class="mb-4"> <div class="px-4 pb-2">
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm"> <a href="/dashboard/organizations/new" class="btn btn-primary btn-sm btn-block">
<Icon name="heroicons:plus" class="w-4 h-4" /> <Icon name="heroicons:plus" class="w-4 h-4" />
Create Team Create Team
</a> </a>
</li> </div>
)} )}
<div class="divider my-2"></div> <div class="divider my-1 mx-4"></div>
<li><a href="/dashboard"> <!-- Navigation -->
<Icon name="heroicons:home" class="w-5 h-5" /> <nav class="flex-1 px-3">
Dashboard <ul class="menu menu-sm gap-0.5 p-0">
</a></li> {navItems.map(item => (
<li><a href="/dashboard/tracker"> <li>
<Icon name="heroicons:clock" class="w-5 h-5" /> <a href={item.href} class:list={[
Time Tracker "rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
</a></li> isActive(item)
<li><a href="/dashboard/reports"> ? "bg-primary/10 text-primary"
<Icon name="heroicons:chart-bar" class="w-5 h-5" /> : "text-base-content/70 hover:text-base-content hover:bg-base-300/50"
Reports ]}>
</a></li> <Icon name={item.icon} class="w-[18px] h-[18px]" />
<li><a href="/dashboard/clients"> {item.label}
<Icon name="heroicons:building-office" class="w-5 h-5" /> </a>
Clients </li>
</a></li> ))}
<li><a href="/dashboard/team"> </ul>
<Icon name="heroicons:user-group" class="w-5 h-5" />
Team
</a></li>
{user.isSiteAdmin && ( {user.isSiteAdmin && (
<> <>
<div class="divider"></div> <div class="divider my-1"></div>
<li><a href="/admin" class="font-semibold"> <ul class="menu menu-sm p-0">
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
Site Admin
</a></li>
</>
)}
<div class="divider"></div>
<li> <li>
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3"> <a href="/admin" class:list={[
<div class="avatar placeholder"> "rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
<div class="bg-linear-to-br from-primary via-secondary to-accent text-primary-content rounded-full w-10 ring ring-primary ring-offset-base-100 ring-offset-2"> Astro.url.pathname.startsWith("/admin")
<span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span> ? "bg-primary/10 text-primary"
</div> : "text-base-content/70 hover:text-base-content hover:bg-base-300/50"
</div> ]}>
<div class="flex-1 min-w-0"> <Icon name="heroicons:cog-6-tooth" class="w-[18px] h-[18px]" />
<div class="font-semibold text-sm truncate">{user.name}</div> Site Admin
<div class="text-xs text-base-content/60 truncate">{user.email}</div>
</div>
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-50" />
</a> </a>
</li> </li>
</ul>
</>
)}
</nav>
<li> <!-- Bottom Section -->
<div class="mt-auto border-t border-base-300/40">
<div class="p-3">
<a href="/dashboard/settings" class="flex items-center gap-3 rounded-lg p-2.5 hover:bg-base-300/40 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/50 truncate">{user.email}</div>
</div>
<Icon name="heroicons:chevron-right" class="w-4 h-4 text-base-content/30 group-hover:text-base-content/50" />
</a>
</div>
<div class="flex items-center justify-between px-5 pb-2">
<span class="text-xs text-base-content/40 font-medium">Theme</span>
<ThemeToggle client:load />
</div>
<div class="px-3 pb-3">
<form action="/api/auth/logout" method="POST"> <form action="/api/auth/logout" method="POST">
<button type="submit" class="w-full text-error hover:bg-error/10"> <button type="submit" class="btn btn-ghost btn-sm btn-block justify-start gap-2 text-base-content/60 hover:text-error hover:bg-error/10 font-medium">
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" /> <Icon name="heroicons:arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
Logout Logout
</button> </button>
</form> </form>
</li>
</ul>
</div> </div>
</div> </div>
<Footer /> </aside>
</div>
</div>
</body> </body>
</html> </html>

View File

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

View File

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

View File

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

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

@@ -0,0 +1,92 @@
import { db } from "../db";
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,
tagId,
}: {
organizationId: string;
clientId: string;
tagId?: string | null;
}) {
const client = await db
.select()
.from(clients)
.where(
and(eq(clients.id, clientId), eq(clients.organizationId, organizationId)),
)
.get();
if (!client) {
return { valid: false, error: "Invalid client" };
}
if (tagId) {
const validTag = await db
.select()
.from(tagsTable)
.where(
and(
eq(tagsTable.id, tagId),
eq(tagsTable.organizationId, organizationId),
),
)
.get();
if (!validTag) {
return { valid: false, error: "Invalid tag" };
}
}
return { valid: true };
}
export function validateTimeRange(
start: string | number | Date,
end: string | number | Date,
) {
const startDate = new Date(start);
const endDate = new Date(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return { valid: false, error: "Invalid date format" };
}
if (endDate <= startDate) {
return { valid: false, error: "End time must be after start time" };
}
return { valid: true, startDate, endDate };
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return EMAIL_REGEX.test(email) && email.length <= 320;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 type { APIRoute } from "astro";
import { db } from "../../../../db"; import { db } from "../../../../db";
import { import { clients, members, timeEntries } from "../../../../db/schema";
clients, import { eq, and } from "drizzle-orm";
members,
timeEntries,
timeEntryTags,
} from "../../../../db/schema";
import { eq, and, inArray } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => { export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -57,22 +52,18 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 }); return new Response("Not authorized", { status: 403 });
} }
const clientEntries = await db const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
.select({ id: timeEntries.id }) if (!isAdminOrOwner) {
.from(timeEntries) if (locals.scopes) {
.where(eq(timeEntries.clientId, id)) return new Response(
.all(); JSON.stringify({ error: "Only owners and admins can delete clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
const entryIds = clientEntries.map((e) => e.id); );
}
if (entryIds.length > 0) { return new Response("Only owners and admins can delete clients", { status: 403 });
await db }
.delete(timeEntryTags)
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.run();
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run(); await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
}
await db.delete(clients).where(eq(clients.id, id)).run(); await db.delete(clients).where(eq(clients.id, id)).run();

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
import { db } from "../../../../db"; import { db } from "../../../../db";
import { clients, members } from "../../../../db/schema"; import { clients, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => { export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -16,21 +17,58 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
let name: string | undefined; let name: string | undefined;
let email: string | undefined; let email: string | undefined;
let phone: string | undefined;
let street: string | undefined;
let city: string | undefined;
let state: string | undefined;
let zip: string | undefined;
let country: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) { if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json(); const body = await request.json();
name = body.name; name = body.name;
email = body.email; email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else { } else {
const formData = await request.formData(); const formData = await request.formData();
name = formData.get("name")?.toString(); name = formData.get("name")?.toString();
email = formData.get("email")?.toString(); email = formData.get("email")?.toString();
phone = formData.get("phone")?.toString();
street = formData.get("street")?.toString();
city = formData.get("city")?.toString();
state = formData.get("state")?.toString();
zip = formData.get("zip")?.toString();
country = formData.get("country")?.toString();
} }
if (!name || name.trim().length === 0) { if (!name || name.trim().length === 0) {
return new Response("Client name is required", { status: 400 }); 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 { try {
const client = await db const client = await db
.select() .select()
@@ -69,11 +107,28 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 }); 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 await db
.update(clients) .update(clients)
.set({ .set({
name: name.trim(), name: name.trim(),
email: email?.trim() || null, email: email?.trim() || null,
phone: phone?.trim() || null,
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
}) })
.where(eq(clients.id, id)) .where(eq(clients.id, id))
.run(); .run();
@@ -85,6 +140,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
id, id,
name: name.trim(), name: name.trim(),
email: email?.trim() || null, email: email?.trim() || null,
phone: phone?.trim() || null,
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
}), }),
{ {
status: 200, status: 200,

View File

@@ -3,6 +3,7 @@ import { db } from "../../../db";
import { clients, members } from "../../../db/schema"; import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -12,21 +13,58 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
let name: string | undefined; let name: string | undefined;
let email: string | undefined; let email: string | undefined;
let phone: string | undefined;
let street: string | undefined;
let city: string | undefined;
let state: string | undefined;
let zip: string | undefined;
let country: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) { if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json(); const body = await request.json();
name = body.name; name = body.name;
email = body.email; email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else { } else {
const formData = await request.formData(); const formData = await request.formData();
name = formData.get("name")?.toString(); name = formData.get("name")?.toString();
email = formData.get("email")?.toString(); email = formData.get("email")?.toString();
phone = formData.get("phone")?.toString();
street = formData.get("street")?.toString();
city = formData.get("city")?.toString();
state = formData.get("state")?.toString();
zip = formData.get("zip")?.toString();
country = formData.get("country")?.toString();
} }
if (!name) { if (!name) {
return new Response("Name is required", { status: 400 }); 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 const userOrg = await db
.select() .select()
.from(members) .from(members)
@@ -37,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("No organization found", { status: 400 }); 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(); const id = nanoid();
await db.insert(clients).values({ await db.insert(clients).values({
@@ -44,13 +93,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
organizationId: userOrg.organizationId, organizationId: userOrg.organizationId,
name, name,
email: email || null, email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}); });
if (locals.scopes) { if (locals.scopes) {
return new Response(JSON.stringify({ id, name, email: email || null }), { return new Response(
JSON.stringify({
id,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}),
{
status: 201, status: 201,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); },
);
} }
return redirect("/dashboard/clients"); return redirect("/dashboard/clients");

View File

@@ -0,0 +1,94 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and, desc } from "drizzle-orm";
export const POST: APIRoute = async ({ redirect, locals, params }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
if (invoice.type !== "quote") {
return new Response("Only quotes can be converted to invoices", {
status: 400,
});
}
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId),
),
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
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()
.from(invoices)
.where(
and(
eq(invoices.organizationId, invoice.organizationId),
eq(invoices.type, "invoice"),
),
)
.orderBy(desc(invoices.createdAt))
.limit(1)
.get();
let nextInvoiceNumber = "INV-001";
if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
let prefix = lastInvoice.number.replace(match[0], "");
if (prefix === "EST-") prefix = "INV-";
nextInvoiceNumber =
prefix + num.toString().padStart(match[0].length, "0");
}
}
await db
.update(invoices)
.set({
type: "invoice",
status: "draft",
number: nextInvoiceNumber,
issueDate: new Date(),
})
.where(eq(invoices.id, invoiceId));
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error converting quote to invoice:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -0,0 +1,117 @@
import type { APIRoute } from "astro";
import { renderToStream } from "@ceereals/vue-pdf";
import { db } from "../../../../db";
import {
invoices,
invoiceItems,
clients,
organizations,
members,
} from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
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 document = createInvoiceDocument({
invoice: {
...invoice,
notes: invoice.notes || null,
// Ensure null safety for optional fields that might be undefined in some runtimes depending on driver
discountValue: invoice.discountValue ?? null,
discountType: invoice.discountType ?? null,
discountAmount: invoice.discountAmount ?? null,
taxRate: invoice.taxRate ?? null,
},
items,
client: {
name: client?.name || "Deleted Client",
email: client?.email || null,
street: client?.street || null,
city: client?.city || null,
state: client?.state || null,
zip: client?.zip || null,
country: client?.country || null,
},
organization: {
name: organization.name,
street: organization.street || null,
city: organization.city || null,
state: organization.state || null,
zip: organization.zip || null,
country: organization.country || null,
logoUrl: organization.logoUrl || null,
},
});
const stream = await renderToStream(document);
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk as Uint8Array);
}
const buffer = Buffer.concat(chunks);
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("Failed to generate PDF", { status: 500 });
}
};

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,77 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
export const POST: APIRoute = async ({
request,
redirect,
locals,
params,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const taxRateStr = formData.get("taxRate") as string;
if (taxRateStr === null) {
return new Response("Tax rate is required", { status: 400 });
}
try {
const taxRate = parseFloat(taxRateStr);
if (isNaN(taxRate) || taxRate < 0) {
return new Response("Invalid tax rate", { status: 400 });
}
await db
.update(invoices)
.set({
taxRate,
})
.where(eq(invoices.id, invoiceId));
// Recalculate totals since tax rate changed
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error updating invoice tax rate:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

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

View File

@@ -0,0 +1,84 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { invoices, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({
request,
redirect,
locals,
cookies,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const formData = await request.formData();
const type = formData.get("type") as string;
const clientId = formData.get("clientId") as string;
const number = formData.get("number") as string;
const issueDateStr = formData.get("issueDate") as string;
const dueDateStr = formData.get("dueDate") as string;
const currency = formData.get("currency") as string;
if (!type || !clientId || !number || !issueDateStr || !dueDateStr) {
return new Response("Missing required fields", { status: 400 });
}
// Get current team context
const currentTeamId = cookies.get("currentTeamId")?.value;
// Verify membership
const userMemberships = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) {
return redirect("/dashboard");
}
const membership = currentTeamId
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
const organizationId = membership.organizationId;
try {
const issueDate = new Date(issueDateStr);
const dueDate = new Date(dueDateStr);
const [newInvoice] = await db
.insert(invoices)
.values({
organizationId,
clientId,
number,
type: type as "invoice" | "quote",
status: "draft",
issueDate,
dueDate,
currency: currency || "USD",
subtotal: 0,
taxAmount: 0,
total: 0,
})
.returning();
return redirect(`/dashboard/invoices/${newInvoice.id}`);
} catch (error) {
console.error("Error creating invoice:", error);
return new Response("Internal Server Error", { status: 500 });
}
};
export const GET: APIRoute = async ({ redirect }) => {
return redirect("/dashboard/invoices/new");
};

View File

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

View File

@@ -1,7 +1,10 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { promises as fs } from "fs";
import path from "path";
import { db } from "../../../db"; import { db } from "../../../db";
import { organizations, members } from "../../../db/schema"; import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -12,6 +15,14 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
const formData = await request.formData(); const formData = await request.formData();
const organizationId = formData.get("organizationId") as string; const organizationId = formData.get("organizationId") as string;
const name = formData.get("name") as string; const name = formData.get("name") as string;
const street = formData.get("street") as string | null;
const city = formData.get("city") as string | null;
const state = formData.get("state") as string | null;
const zip = formData.get("zip") as string | null;
const country = formData.get("country") as string | null;
const 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) { if (!organizationId || !name || name.trim().length === 0) {
return new Response("Organization ID and name are required", { return new Response("Organization ID and name are required", {
@@ -19,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 { try {
// Verify user is admin/owner of this organization // Verify user is admin/owner of this organization
const membership = await db const membership = await db
@@ -44,16 +67,69 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
); );
} }
// Update organization name let logoUrl: string | undefined;
if (logo && logo.size > 0) {
const allowedTypes = ["image/png", "image/jpeg"];
if (!allowedTypes.includes(logo.type)) {
return new Response(
"Invalid file type. Only PNG and JPG are allowed.",
{
status: 400,
},
);
}
const 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
: import.meta.env.DATA_DIR;
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
const uploadDir = path.join(dataDir, "uploads");
try {
await fs.access(uploadDir);
} catch {
await fs.mkdir(uploadDir, { recursive: true });
}
const buffer = Buffer.from(await logo.arrayBuffer());
await fs.writeFile(path.join(uploadDir, filename), buffer);
logoUrl = `/uploads/${filename}`;
}
// Update organization information
const updateData: any = {
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
defaultTaxRate: defaultTaxRate ? parseFloat(defaultTaxRate) : 0,
defaultCurrency: defaultCurrency || "USD",
};
if (logoUrl) {
updateData.logoUrl = logoUrl;
}
await db await db
.update(organizations) .update(organizations)
.set({ name: name.trim() }) .set(updateData)
.where(eq(organizations.id, organizationId)) .where(eq(organizations.id, organizationId))
.run(); .run();
return redirect("/dashboard/team/settings?success=org-name"); return redirect("/dashboard/team/settings?success=org-name");
} catch (error) { } catch (error) {
console.error("Error updating organization name:", error); console.error("Error updating organization:", error);
return new Response("Failed to update organization name", { status: 500 }); return new Response("Failed to update organization", { status: 500 });
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import {
validateTimeEntryResources,
validateTimeRange,
MAX_LENGTHS,
} from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const body = await request.json();
const { description, clientId, startTime, endTime, tagId } = body;
// Validation
if (!clientId) {
return new Response(JSON.stringify({ error: "Client 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) {
return new Response(JSON.stringify({ error: "Start time is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!endTime) {
return new Response(JSON.stringify({ error: "End time is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const timeValidation = validateTimeRange(startTime, endTime);
if (
!timeValidation.valid ||
!timeValidation.startDate ||
!timeValidation.endDate
) {
return new Response(JSON.stringify({ error: timeValidation.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const { startDate, endDate } = timeValidation;
// Get user's organization
const member = await db
.select()
.from(members)
.where(eq(members.userId, locals.user.id))
.limit(1)
.get();
if (!member) {
return new Response(JSON.stringify({ error: "No organization found" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const resourceValidation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
tagId: tagId || null,
});
if (!resourceValidation.valid) {
return new Response(JSON.stringify({ error: resourceValidation.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const id = nanoid();
try {
// Insert the manual time entry
await db.insert(timeEntries).values({
id,
userId: locals.user.id,
organizationId: member.organizationId,
clientId,
tagId: tagId || null,
startTime: startDate,
endTime: endDate,
description: description || null,
isManual: true,
});
return new Response(
JSON.stringify({
success: true,
id,
startTime: startDate.toISOString(),
endTime: endDate.toISOString(),
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error("Error creating manual time entry:", error);
return new Response(
JSON.stringify({ error: "Failed to create time entry" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
};

View File

@@ -1,51 +1,56 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from "astro";
import { db } from '../../../db'; import { db } from "../../../db";
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema'; import { timeEntries, members } from "../../../db/schema";
import { eq, and, isNull } from 'drizzle-orm'; import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from 'nanoid'; import { nanoid } from "nanoid";
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => { export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response("Unauthorized", { status: 401 });
const body = await request.json(); const body = await request.json();
const description = body.description || ''; const description = body.description || "";
const clientId = body.clientId; const clientId = body.clientId;
const categoryId = body.categoryId; const tagId = body.tagId || null;
const tags = body.tags || [];
if (!clientId) { if (!clientId) {
return new Response('Client is required', { status: 400 }); return new Response("Client is required", { status: 400 });
} }
if (!categoryId) { if (description && description.length > MAX_LENGTHS.description) {
return new Response('Category is required', { status: 400 }); return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
} }
const runningEntry = await db.select().from(timeEntries).where( const runningEntry = await db
and( .select()
eq(timeEntries.userId, locals.user.id), .from(timeEntries)
isNull(timeEntries.endTime) .where(
and(eq(timeEntries.userId, locals.user.id), isNull(timeEntries.endTime)),
) )
).get(); .get();
if (runningEntry) { if (runningEntry) {
return new Response('Timer already running', { status: 400 }); return new Response("Timer already running", { status: 400 });
} }
const member = await db.select().from(members).where(eq(members.userId, locals.user.id)).limit(1).get(); const member = await db
.select()
.from(members)
.where(eq(members.userId, locals.user.id))
.limit(1)
.get();
if (!member) { if (!member) {
return new Response('No organization found', { status: 400 }); return new Response("No organization found", { status: 400 });
} }
const category = await db.select().from(categories).where( const validation = await validateTimeEntryResources({
and( organizationId: member.organizationId,
eq(categories.id, categoryId), clientId,
eq(categories.organizationId, member.organizationId) tagId,
) });
).get();
if (!category) { if (!validation.valid) {
return new Response('Invalid category', { status: 400 }); return new Response(validation.error, { status: 400 });
} }
const startTime = new Date(); const startTime = new Date();
@@ -56,19 +61,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
userId: locals.user.id, userId: locals.user.id,
organizationId: member.organizationId, organizationId: member.organizationId,
clientId, clientId,
categoryId, tagId,
startTime, startTime,
description, 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 }); return new Response(JSON.stringify({ id, startTime }), { status: 200 });
}; };

View File

@@ -1,61 +1,138 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from "astro";
import { db } from '../../../db'; import { db } from "../../../db";
import { users } from '../../../db/schema'; import { users, sessions } from "../../../db/schema";
import { eq } from 'drizzle-orm'; import { eq } from "drizzle-orm";
import bcrypt from 'bcryptjs'; import bcrypt from "bcryptjs";
import { MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
const user = locals.user; const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!user) { if (!user) {
return redirect('/login'); if (isJson) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
});
}
return redirect("/login");
} }
let currentPassword, newPassword, confirmPassword;
if (isJson) {
const body = await request.json();
currentPassword = body.currentPassword;
newPassword = body.newPassword;
confirmPassword = body.confirmPassword;
} else {
const formData = await request.formData(); const formData = await request.formData();
const currentPassword = formData.get('currentPassword') as string; currentPassword = formData.get("currentPassword") as string;
const newPassword = formData.get('newPassword') as string; newPassword = formData.get("newPassword") as string;
const confirmPassword = formData.get('confirmPassword') as string; confirmPassword = formData.get("confirmPassword") as string;
}
if (!currentPassword || !newPassword || !confirmPassword) { if (!currentPassword || !newPassword || !confirmPassword) {
return new Response('All fields are required', { status: 400 }); const msg = "All fields are required";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
return new Response('New passwords do not match', { status: 400 }); const msg = "New passwords do not match";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
} }
if (newPassword.length < 8) { if (newPassword.length < 8) {
return new Response('Password must be at least 8 characters', { status: 400 }); const msg = "Password must be at least 8 characters";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
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 { try {
// Get current user from database // Get current user from database
const dbUser = await db.select() const dbUser = await db
.select()
.from(users) .from(users)
.where(eq(users.id, user.id)) .where(eq(users.id, user.id))
.get(); .get();
if (!dbUser) { if (!dbUser) {
return new Response('User not found', { status: 404 }); const msg = "User not found";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 404 });
return new Response(msg, { status: 404 });
} }
// Verify current password // Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash); const passwordMatch = await bcrypt.compare(
currentPassword,
dbUser.passwordHash,
);
if (!passwordMatch) { if (!passwordMatch) {
return new Response('Current password is incorrect', { status: 400 }); const msg = "Current password is incorrect";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
} }
// Hash new password // Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10); const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password // Update password
await db.update(users) await db
.update(users)
.set({ passwordHash: hashedPassword }) .set({ passwordHash: hashedPassword })
.where(eq(users.id, user.id)) .where(eq(users.id, user.id))
.run(); .run();
return redirect('/dashboard/settings?success=password'); // Invalidate all sessions, then re-create one for the current user
const currentSessionId = cookies.get("session_id")?.value;
if (currentSessionId) {
await db
.delete(sessions)
.where(
eq(sessions.userId, user.id),
)
.run();
const { createSession } = await import("../../../lib/auth");
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
} else {
await db
.delete(sessions)
.where(eq(sessions.userId, user.id))
.run();
}
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
return redirect("/dashboard/settings?success=password");
} catch (error) { } catch (error) {
console.error('Error changing password:', error); console.error("Error changing password:", error);
return new Response('Failed to change password', { status: 500 }); const msg = "Failed to change password";
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 500 });
return new Response(msg, { status: 500 });
} }
}; };

View File

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

View File

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

View File

@@ -1,62 +0,0 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { categories, members } from '../../db/schema';
import { eq } from 'drizzle-orm';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, userMembership.organizationId))
.all();
---
<DashboardLayout title="Categories - Chronus">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Categories</h1>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Category</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{allCategories.map(category => (
<div class="card bg-base-200 shadow-xl border border-base-300">
<div class="card-body">
<h2 class="card-title">
{category.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${category.color}`}></span>
)}
{category.name}
</h2>
<p class="text-xs text-base-content/60">Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
<div class="card-actions justify-end mt-4">
<a href={`/dashboard/categories/${category.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
</div>
</div>
</div>
))}
</div>
{allCategories.length === 0 && (
<div class="text-center py-12">
<p class="text-base-content/60 mb-4">No categories yet</p>
<a href="/dashboard/categories/new" class="btn btn-primary">Add Your First Category</a>
</div>
)}
</DashboardLayout>

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,9 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../../db'; import { db } from '../../../../db';
import { clients, members } from '../../../../db/schema'; import { clients } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params; const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients'); if (!id) return Astro.redirect('/dashboard/clients');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
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 client = await db.select() const client = await db.select()
.from(clients) .from(clients)
@@ -40,55 +29,129 @@ if (!client) return Astro.redirect('/dashboard/clients');
<DashboardLayout title={`Edit ${client.name} - Chronus`}> <DashboardLayout title={`Edit ${client.name} - Chronus`}>
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm"> <a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-xs">
<Icon name="heroicons:arrow-left" class="w-5 h-5" /> <Icon name="heroicons:arrow-left" class="w-4 h-4" />
</a> </a>
<h1 class="text-3xl font-bold">Edit Client</h1> <h1 class="text-2xl font-extrabold tracking-tight">Edit Client</h1>
</div> </div>
<form method="POST" action={`/api/clients/${client.id}/update`} class="card bg-base-100 shadow-xl border border-base-200"> <form method="POST" action={`/api/clients/${client.id}/update`} class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="name"> <legend class="fieldset-legend text-xs">Client Name</legend>
<span class="label-text">Client Name</span>
</label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
value={client.name} value={client.name}
placeholder="Acme Corp" placeholder="Acme Corp"
class="input input-bordered" class="input w-full"
required required
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="email"> <legend class="fieldset-legend text-xs">Email (optional)</legend>
<span class="label-text">Email (optional)</span>
</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
value={client.email || ''} value={client.email || ''}
placeholder="contact@acme.com" placeholder="jason.borne@cia.com"
class="input input-bordered" class="input w-full"
/> />
</fieldset>
<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 w-full"
/>
</fieldset>
<div class="divider text-xs text-base-content/40">Address Details</div>
<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 w-full"
/>
</fieldset>
<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 w-full"
/>
</fieldset>
<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 w-full"
/>
</fieldset>
</div> </div>
<div class="card-actions justify-between mt-6"> <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 w-full"
/>
</fieldset>
<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 w-full"
/>
</fieldset>
</div>
<div class="flex justify-between items-center mt-4">
<button <button
type="button" type="button"
class="btn btn-error btn-outline" class="btn btn-error btn-outline btn-sm"
onclick={`document.getElementById('delete_modal').showModal()`} onclick={`document.getElementById('delete_modal').showModal()`}
> >
Delete Client Delete Client
</button> </button>
<div class="flex gap-2"> <div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost">Cancel</a> <a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary">Save Changes</button> <button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
</div> </div>
</div> </div>
</div> </div>
@@ -98,17 +161,17 @@ if (!client) return Astro.redirect('/dashboard/clients');
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<dialog id="delete_modal" class="modal"> <dialog id="delete_modal" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg text-error">Delete Client?</h3> <h3 class="font-semibold text-base text-error">Delete Client?</h3>
<p class="py-4"> <p class="py-4 text-sm">
Are you sure you want to delete <strong>{client.name}</strong>? Are you sure you want to delete <strong>{client.name}</strong>?
This action cannot be undone and will delete all associated time entries. This action cannot be undone and will delete all associated time entries.
</p> </p>
<div class="modal-action"> <div class="modal-action">
<form method="dialog"> <form method="dialog">
<button class="btn">Cancel</button> <button class="btn btn-sm">Cancel</button>
</form> </form>
<form method="POST" action={`/api/clients/${client.id}/delete`}> <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> </form>
</div> </div>
</div> </div>

View File

@@ -2,9 +2,11 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../../db'; 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 { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime'; import { formatTimeRange } from '../../../../lib/formatTime';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
import StatCard from '../../../../components/StatCard.astro';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params; const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients'); if (!id) return Astro.redirect('/dashboard/clients');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
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 client = await db.select() const client = await db.select()
.from(clients) .from(clients)
@@ -40,12 +30,12 @@ if (!client) return Astro.redirect('/dashboard/clients');
// Get recent activity // Get recent activity
const recentEntries = await db.select({ const recentEntries = await db.select({
entry: timeEntries, entry: timeEntries,
category: categories,
user: users, user: users,
tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id)) .leftJoin(users, eq(timeEntries.userId, users.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(eq(timeEntries.clientId, client.id)) .where(eq(timeEntries.clientId, client.id))
.orderBy(desc(timeEntries.startTime)) .orderBy(desc(timeEntries.startTime))
.limit(10) .limit(10)
@@ -73,112 +63,133 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<DashboardLayout title={`${client.name} - Clients - Chronus`}> <DashboardLayout title={`${client.name} - Clients - Chronus`}>
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<a href="/dashboard/clients" class="btn btn-ghost btn-sm"> <a href="/dashboard/clients" class="btn btn-ghost btn-xs">
<Icon name="heroicons:arrow-left" class="w-5 h-5" /> <Icon name="heroicons:arrow-left" class="w-4 h-4" />
</a> </a>
<h1 class="text-3xl font-bold">{client.name}</h1> <h1 class="text-2xl font-extrabold tracking-tight">{client.name}</h1>
</div> </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 --> <!-- Client Details Card -->
<div class="card bg-base-100 shadow-xl border border-base-200 lg:col-span-2"> <div class="card card-border bg-base-100 lg:col-span-2">
<div class="card-body"> <div class="card-body p-4">
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div> <div>
<h2 class="card-title text-2xl mb-1">{client.name}</h2> <h2 class="text-sm font-semibold mb-3">{client.name}</h2>
<div class="space-y-2 mb-4">
{client.email && ( {client.email && (
<div class="flex items-center gap-2 text-base-content/70 mb-4"> <div class="flex items-center gap-2 text-base-content/60 text-sm">
<Icon name="heroicons:envelope" class="w-4 h-4" /> <Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a> <a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div> </div>
)} )}
{client.phone && (
<div class="flex items-center gap-2 text-base-content/60 text-sm">
<Icon name="heroicons:phone" class="w-4 h-4" />
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
</div>
)}
{(client.street || client.city || client.state || client.zip || client.country) && (
<div class="flex items-start gap-2 text-base-content/60">
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
<div class="text-sm space-y-0.5">
{client.street && <div>{client.street}</div>}
{(client.city || client.state || client.zip) && (
<div>
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
</div>
)}
{client.country && <div>{client.country}</div>}
</div>
</div>
)}
</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm"> <a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-xs">
<Icon name="heroicons:pencil" class="w-4 h-4" /> <Icon name="heroicons:pencil" class="w-3 h-3" />
Edit Edit
</a> </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.');"> <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"> <button type="submit" class="btn btn-error btn-outline btn-xs">
<Icon name="heroicons:trash" class="w-4 h-4" /> <Icon name="heroicons:trash" class="w-3 h-3" />
Delete Delete
</button> </button>
</form> </form>
</div> </div>
</div> </div>
<div class="divider"></div> <div class="divider my-2"></div>
<div class="stats shadow w-full"> <div class="grid grid-cols-2 gap-3">
<div class="stat"> <StatCard
<div class="stat-figure text-primary"> title="Total Time Tracked"
<Icon name="heroicons:clock" class="w-8 h-8" /> value={`${totalHours}h ${totalMinutes}m`}
</div> description="Across all projects"
<div class="stat-title">Total Time Tracked</div> icon="heroicons:clock"
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div> color="text-primary"
<div class="stat-desc">Across all projects</div> />
</div> <StatCard
title="Total Entries"
<div class="stat"> value={String(totalEntriesCount)}
<div class="stat-figure text-secondary"> description="Recorded entries"
<Icon name="heroicons:list-bullet" class="w-8 h-8" /> icon="heroicons:list-bullet"
</div> color="text-secondary"
<div class="stat-title">Total Entries</div> />
<div class="stat-value text-secondary">{totalEntriesCount}</div>
<div class="stat-desc">Recorded entries</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Meta Info Card --> <!-- Meta Info Card -->
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit"> <div class="card card-border bg-base-100 h-fit">
<div class="card-body"> <div class="card-body p-4">
<h3 class="card-title text-lg mb-4">Information</h3> <h3 class="text-sm font-semibold mb-3">Information</h3>
<div class="space-y-4"> <div class="space-y-3">
<div> <div>
<div class="text-sm font-medium text-base-content/60">Created</div> <div class="text-xs text-base-content/40">Created</div>
<div>{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div> <div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Recent Activity --> <!-- Recent Activity -->
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-0">
<h2 class="card-title mb-4">Recent Activity</h2> <div class="px-4 py-3 border-b border-base-200">
<h2 class="text-sm font-semibold">Recent Activity</h2>
</div>
{recentEntries.length > 0 ? ( {recentEntries.length > 0 ? (
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Description</th> <th>Description</th>
<th>Category</th> <th>Tag</th>
<th>User</th> <th>User</th>
<th>Date</th> <th>Date</th>
<th>Duration</th> <th>Duration</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{recentEntries.map(({ entry, category, user: entryUser }) => ( {recentEntries.map(({ entry, tag, user: entryUser }) => (
<tr> <tr class="hover">
<td>{entry.description || '-'}</td> <td>{entry.description || '-'}</td>
<td> <td>
{category ? ( {tag ? (
<div class="flex items-center gap-2"> <div class="badge badge-xs badge-outline flex items-center gap-1">
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span> {tag.color && (
<span>{category.name}</span> <span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
)}
<span>{tag.name}</span>
</div> </div>
) : '-'} ) : '-'}
</td> </td>
<td>{entryUser?.name || 'Unknown'}</td> <td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
<td>{entry.startTime.toLocaleDateString()}</td> <td class="text-base-content/40">{entry.startTime.toLocaleDateString()}</td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td> <td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
</tr> </tr>
))} ))}
@@ -186,14 +197,14 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
</table> </table>
</div> </div>
) : ( ) : (
<div class="text-center py-8 text-base-content/60"> <div class="text-center py-8 text-base-content/40 text-sm">
No time entries recorded for this client yet. No time entries recorded for this client yet.
</div> </div>
)} )}
{recentEntries.length > 0 && ( {recentEntries.length > 0 && (
<div class="card-actions justify-center mt-4"> <div class="flex justify-center py-3 border-t border-base-200">
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-sm"> <a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs">
View All Entries View All Entries
</a> </a>
</div> </div>

View File

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

View File

@@ -1,8 +1,9 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import StatCard from '../../components/StatCard.astro';
import { db } from '../../db'; 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 { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
import { formatDuration } from '../../lib/formatTime'; import { formatDuration } from '../../lib/formatTime';
@@ -41,61 +42,57 @@ if (currentOrg) {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const weekEntries = await db.select() const weekStats = await db.select({
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries) .from(timeEntries)
.where(and( .where(and(
eq(timeEntries.organizationId, currentOrg.organizationId), eq(timeEntries.organizationId, currentOrg.organizationId),
gte(timeEntries.startTime, weekAgo) gte(timeEntries.startTime, weekAgo),
sql`${timeEntries.endTime} IS NOT NULL`
)) ))
.all(); .get();
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => { stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
const monthEntries = await db.select() const monthStats = await db.select({
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries) .from(timeEntries)
.where(and( .where(and(
eq(timeEntries.organizationId, currentOrg.organizationId), eq(timeEntries.organizationId, currentOrg.organizationId),
gte(timeEntries.startTime, monthAgo) gte(timeEntries.startTime, monthAgo),
sql`${timeEntries.endTime} IS NOT NULL`
)) ))
.all(); .get();
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => { stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
const activeCount = await db.select() const activeCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries) .from(timeEntries)
.where(and( .where(and(
eq(timeEntries.organizationId, currentOrg.organizationId), eq(timeEntries.organizationId, currentOrg.organizationId),
isNull(timeEntries.endTime) isNull(timeEntries.endTime)
)) ))
.all(); .get();
stats.activeTimers = activeCount.length; stats.activeTimers = activeCount?.count || 0;
const clientCount = await db.select() const clientCount = await db.select({ count: sql<number>`count(*)` })
.from(clients) .from(clients)
.where(eq(clients.organizationId, currentOrg.organizationId)) .where(eq(clients.organizationId, currentOrg.organizationId))
.all(); .get();
stats.totalClients = clientCount.length; stats.totalClients = clientCount?.count || 0;
stats.recentEntries = await db.select({ stats.recentEntries = await db.select({
entry: timeEntries, entry: timeEntries,
client: clients, client: clients,
category: categories, tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.innerJoin(clients, eq(timeEntries.clientId, clients.id)) .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)) .where(eq(timeEntries.organizationId, currentOrg.organizationId))
.orderBy(desc(timeEntries.startTime)) .orderBy(desc(timeEntries.startTime))
.limit(5) .limit(5)
@@ -107,25 +104,25 @@ const hasMembership = userOrgs.length > 0;
--- ---
<DashboardLayout title="Dashboard - Chronus"> <DashboardLayout title="Dashboard - Chronus">
<div class="flex justify-between items-center mb-8"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-6">
<div> <div>
<h1 class="text-4xl font-bold text-primary mb-2"> <h1 class="text-2xl font-extrabold tracking-tight">
Dashboard Dashboard
</h1> </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> </div>
<a href="/dashboard/organizations/new" class="btn btn-outline"> <a href="/dashboard/organizations/new" class="btn btn-ghost btn-sm">
<Icon name="heroicons:plus" class="w-5 h-5" /> <Icon name="heroicons:plus" class="w-4 h-4" />
New Team New Team
</a> </a>
</div> </div>
{!hasMembership && ( {!hasMembership && (
<div class="alert alert-info mb-8"> <div class="alert alert-info mb-6 text-sm">
<Icon name="heroicons:information-circle" class="w-6 h-6" /> <Icon name="heroicons:information-circle" class="w-5 h-5" />
<div> <div>
<h3 class="font-bold">Welcome to Chronus!</h3> <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> </div>
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm"> <a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
<Icon name="heroicons:plus" class="w-4 h-4" /> <Icon name="heroicons:plus" class="w-4 h-4" />
@@ -137,63 +134,56 @@ const hasMembership = userOrgs.length > 0;
{hasMembership && ( {hasMembership && (
<> <>
<!-- Stats Overview --> <!-- Stats Overview -->
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<div class="stat"> <StatCard
<div class="stat-figure text-primary"> title="This Week"
<Icon name="heroicons:clock" class="w-8 h-8" /> value={formatDuration(stats.totalTimeThisWeek)}
</div> description="Total tracked time"
<div class="stat-title">This Week</div> icon="heroicons:clock"
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div> color="text-primary"
<div class="stat-desc">Total tracked time</div> />
<StatCard
title="This Month"
value={formatDuration(stats.totalTimeThisMonth)}
description="Total tracked time"
icon="heroicons:calendar"
color="text-secondary"
/>
<StatCard
title="Active Timers"
value={String(stats.activeTimers)}
description="Currently running"
icon="heroicons:play-circle"
color="text-accent"
/>
<StatCard
title="Clients"
value={String(stats.totalClients)}
description="Total active"
icon="heroicons:building-office"
color="text-info"
/>
</div> </div>
<div class="stat"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<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>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="card bg-base-100 shadow-xl"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:bolt" class="w-6 h-6 text-warning" /> <Icon name="heroicons:bolt" class="w-4 h-4 text-warning" />
Quick Actions Quick Actions
</h2> </h2>
<div class="flex flex-col gap-3 mt-4"> <div class="flex flex-col gap-2 mt-3">
<a href="/dashboard/tracker" class="btn btn-primary"> <a href="/dashboard/tracker" class="btn btn-primary btn-sm">
<Icon name="heroicons:play" class="w-5 h-5" /> <Icon name="heroicons:play" class="w-4 h-4" />
Start Timer Start Timer
</a> </a>
<a href="/dashboard/clients/new" class="btn btn-outline"> <a href="/dashboard/clients/new" class="btn btn-ghost btn-sm">
<Icon name="heroicons:plus" class="w-5 h-5" /> <Icon name="heroicons:plus" class="w-4 h-4" />
Add Client Add Client
</a> </a>
<a href="/dashboard/reports" class="btn btn-outline"> <a href="/dashboard/reports" class="btn btn-ghost btn-sm">
<Icon name="heroicons:chart-bar" class="w-5 h-5" /> <Icon name="heroicons:chart-bar" class="w-4 h-4" />
View Reports View Reports
</a> </a>
</div> </div>
@@ -201,27 +191,32 @@ const hasMembership = userOrgs.length > 0;
</div> </div>
<!-- Recent Activity --> <!-- Recent Activity -->
<div class="card bg-base-100 shadow-xl"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:clock" class="w-6 h-6 text-success" /> <Icon name="heroicons:clock" class="w-4 h-4 text-success" />
Recent Activity Recent Activity
</h2> </h2>
{stats.recentEntries.length > 0 ? ( {stats.recentEntries.length > 0 ? (
<ul class="space-y-3 mt-4"> <ul class="space-y-2 mt-3">
{stats.recentEntries.map(({ entry, client, category }) => ( {stats.recentEntries.map(({ entry, client, tag }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}> <li class="p-2.5 rounded-lg bg-base-200/50 border-l-3 hover:bg-base-200 transition-colors" style={`border-color: ${tag?.color || 'oklch(var(--p))'}`}>
<div class="font-semibold text-sm">{client.name}</div> <div class="font-medium text-sm">{client.name}</div>
<div class="text-xs text-base-content/60 mt-1"> <div class="text-xs text-base-content/50 mt-0.5 flex flex-wrap gap-2 items-center">
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'} <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> </div>
</li> </li>
))} ))}
</ul> </ul>
) : ( ) : (
<div class="flex flex-col items-center justify-center py-8 text-center mt-4"> <div class="flex flex-col items-center justify-center py-6 text-center mt-3">
<Icon name="heroicons:clock" class="w-12 h-12 text-base-content/20 mb-3" /> <Icon name="heroicons:clock" class="w-10 h-10 text-base-content/15 mb-2" />
<p class="text-base-content/60 text-sm">No recent time entries</p> <p class="text-base-content/40 text-sm">No recent time entries</p>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,409 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { formatCurrency } from '../../../lib/formatTime';
const { id } = Astro.params;
const user = Astro.locals.user;
if (!user || !id) {
return Astro.redirect('/dashboard/invoices');
}
// Fetch invoice with related data
const invoiceResult = await db.select({
invoice: invoices,
client: clients,
organization: organizations,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
.where(eq(invoices.id, id))
.get();
if (!invoiceResult) {
return Astro.redirect('/404');
}
const { invoice, client, organization } = invoiceResult;
// Verify access
const membership = await db.select()
.from(members)
.where(and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
))
.get();
if (!membership) {
return Astro.redirect('/dashboard');
}
// Fetch items
const items = await db.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
const isDraft = invoice.status === 'draft';
---
<DashboardLayout title={`${invoice.number} - Chronus`}>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<div class="flex items-center gap-2 mb-1">
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs btn-square">
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
</a>
<div class={`badge badge-xs ${
invoice.status === 'paid' || invoice.status === 'accepted' ? 'badge-success' :
invoice.status === 'sent' ? 'badge-info' :
invoice.status === 'void' || invoice.status === 'declined' ? 'badge-error' :
'badge-ghost'
} uppercase font-bold tracking-wider`}>
{invoice.status}
</div>
</div>
<h1 class="text-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 btn-sm">
<Icon name="heroicons:paper-airplane" class="w-4 h-4" />
Mark Sent
</button>
</form>
)}
{(invoice.status !== 'paid' && invoice.status !== 'void' && invoice.type === 'invoice') && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="paid" />
<button type="submit" class="btn btn-success btn-sm">
<Icon name="heroicons:check" class="w-4 h-4" />
Mark Paid
</button>
</form>
)}
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && invoice.type === 'quote') && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="accepted" />
<button type="submit" class="btn btn-success btn-sm">
<Icon name="heroicons: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 btn-sm">
<Icon name="heroicons: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 btn-sm border border-base-200">
<Icon name="heroicons:ellipsis-horizontal" class="w-4 h-4" />
</div>
<ul tabindex="0" class="dropdown-content z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-200">
<li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
Edit Settings
</a>
</li>
<li>
<a href={`/api/invoices/${invoice.id}/generate`} download>
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
Download PDF
</a>
</li>
{invoice.status !== 'void' && invoice.status !== 'draft' && (
<li>
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="void" />
<button type="submit" class="text-error">
<Icon name="heroicons:x-circle" class="w-4 h-4" />
Void
</button>
</form>
</li>
)}
<li>
<form method="POST" action="/api/invoices/delete" onsubmit="return confirm('Are you sure?');">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="text-error">
<Icon name="heroicons:trash" class="w-4 h-4" />
Delete
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
<!-- Invoice Paper -->
<div class="card 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">
<div>
<h2 class="text-2xl font-bold text-primary mb-1">{organization.name}</h2>
{(organization.street || organization.city || organization.state || organization.zip || organization.country) && (
<div class="text-sm opacity-70 space-y-0.5">
{organization.street && <div>{organization.street}</div>}
{(organization.city || organization.state || organization.zip) && (
<div>
{[organization.city, organization.state, organization.zip].filter(Boolean).join(', ')}
</div>
)}
{organization.country && <div>{organization.country}</div>}
</div>
)}
</div>
<div class="text-right">
<div class="text-4xl font-light text-base-content/30 uppercase tracking-widest mb-4">
{invoice.type}
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<div class="text-base-content/60">Number:</div>
<div class="font-mono font-bold">{invoice.number}</div>
<div class="text-base-content/60">Date:</div>
<div>{invoice.issueDate.toLocaleDateString()}</div>
<div class="text-base-content/60">Due Date:</div>
<div>{invoice.dueDate.toLocaleDateString()}</div>
</div>
</div>
</div>
<!-- Bill To -->
<div class="mb-12">
<div class="text-xs font-bold uppercase tracking-wider text-base-content/40 mb-2">Bill To</div>
{client ? (
<div>
<div class="font-bold text-lg">{client.name}</div>
{client.email && <div class="text-base-content/70">{client.email}</div>}
{client.phone && <div class="text-base-content/70">{client.phone}</div>}
{(client.street || client.city || client.state || client.zip || client.country) && (
<div class="text-sm text-base-content/70 mt-2 space-y-0.5">
{client.street && <div>{client.street}</div>}
{(client.city || client.state || client.zip) && (
<div>
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
</div>
)}
{client.country && <div>{client.country}</div>}
</div>
)}
</div>
) : (
<div class="italic text-base-content/40">Client deleted</div>
)}
</div>
<!-- Items Table -->
<div class="mb-8">
<div class="overflow-x-auto">
<table class="w-full min-w-150">
<thead>
<tr class="border-b-2 border-base-200 text-left text-xs font-bold uppercase tracking-wider text-base-content/40">
<th class="py-3">Description</th>
<th class="py-3 text-right w-24">Qty</th>
<th class="py-3 text-right w-32">Price</th>
<th class="py-3 text-right w-32">Amount</th>
{isDraft && <th class="py-3 w-10"></th>}
</tr>
</thead>
<tbody class="divide-y divide-base-200">
{items.map(item => (
<tr>
<td class="py-4">{item.description}</td>
<td class="py-4 text-right">{item.quantity}</td>
<td class="py-4 text-right">{formatCurrency(item.unitPrice, 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>
</form>
</td>
)}
</tr>
))}
{items.length === 0 && (
<tr>
<td colspan={isDraft ? 5 : 4} class="py-8 text-center text-base-content/40 italic">
No items added yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<!-- Add Item Form (Only if Draft) -->
{isDraft && (
<div class="flex justify-end mb-4">
<button onclick="document.getElementById('import_time_modal').showModal()" class="btn btn-sm btn-outline gap-2">
<Icon name="heroicons:clock" class="w-4 h-4" />
Import Time
</button>
</div>
<form method="POST" action={`/api/invoices/${invoice.id}/items/add`} class="bg-base-200/50 p-4 rounded-lg mb-8 border border-base-200">
<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="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="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="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" />
</button>
</div>
</div>
</form>
)}
<!-- Totals -->
<div class="flex justify-end">
<div class="w-64 space-y-3">
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm">
<span class="text-base-content/60">
Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
</div>
)}
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
<div class="flex justify-between text-sm items-center group">
<span class="text-base-content/60 flex items-center gap-2">
Tax ({invoice.taxRate ?? 0}%)
{isDraft && (
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<Icon name="heroicons:pencil" class="w-3 h-3" />
</button>
)}
</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount, 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, 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="text-sm whitespace-pre-wrap opacity-80">{invoice.notes}</div>
</div>
)}
{/* Edit Notes (Draft Only) - Simplistic approach */}
{isDraft && !invoice.notes && (
<div class="mt-8 text-center">
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-primary">Edit Details</a>
</div>
)}
</div>
</div>
</div>
<!-- Tax Modal -->
<dialog id="tax_modal" class="modal">
<div class="modal-box">
<h3 class="font-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`}>
<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 w-full"
value={invoice.taxRate ?? 0}
required
/>
</fieldset>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('tax_modal').close()">Cancel</button>
<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">
<button type="button" class="btn btn-sm" onclick="document.getElementById('import_time_modal').close()">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Import</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</DashboardLayout>

View File

@@ -0,0 +1,170 @@
---
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { invoices, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
const { id } = Astro.params;
const user = Astro.locals.user;
if (!user || !id) {
return Astro.redirect('/dashboard/invoices');
}
// Fetch invoice
const invoice = await db.select()
.from(invoices)
.where(eq(invoices.id, id))
.get();
if (!invoice) {
return Astro.redirect('/404');
}
// Verify membership
const membership = await db.select()
.from(members)
.where(and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
))
.get();
if (!membership) {
return Astro.redirect('/dashboard');
}
// Format dates for input[type="date"]
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
const discountValueDisplay = invoice.discountType === 'fixed'
? (invoice.discountValue || 0) / 100
: (invoice.discountValue || 0);
---
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<a href={`/dashboard/invoices/${invoice.id}`} class="btn btn-ghost btn-xs gap-2 pl-0 hover:bg-transparent text-base-content/60">
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
Back to Invoice
</a>
<h1 class="text-2xl font-extrabold tracking-tight mt-2">Edit Details</h1>
</div>
<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-3">
<!-- Number -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Number</legend>
<input
type="text"
id="invoice-number"
name="number"
class="input font-mono"
value={invoice.number}
required
/>
</fieldset>
<!-- Currency -->
<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>
</fieldset>
<!-- Issue Date -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Issue Date</legend>
<input
type="date"
id="invoice-issue-date"
name="issueDate"
class="input"
value={issueDateStr}
required
/>
</fieldset>
<!-- Due Date -->
<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"
value={dueDateStr}
required
/>
</fieldset>
<!-- Discount -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Discount</legend>
<div class="join w-full">
<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 join-item w-full"
value={discountValueDisplay}
/>
</div>
</fieldset>
<!-- Tax Rate -->
<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"
value={invoice.taxRate}
/>
</fieldset>
</div>
<!-- Notes -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Notes / Terms</legend>
<textarea
id="invoice-notes"
name="notes"
class="textarea h-32 font-mono text-sm"
placeholder="Payment terms, bank details, or thank you notes..."
>{invoice.notes}</textarea>
</fieldset>
<div class="divider my-0"></div>
<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>
</div>
</form>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,316 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import StatCard from '../../../components/StatCard.astro';
import { db } from '../../../db';
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');
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const currentTeamIdResolved = userMembership.organizationId;
// 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))
.all();
// Get unique years from invoices
const availableYears = [...new Set(allInvoicesRaw.map(i => i.invoice.issueDate.getFullYear()))].sort((a, b) => b - a);
// Ensure current year is in the list
if (!availableYears.includes(currentYear)) {
availableYears.unshift(currentYear);
}
// Filter by year
const yearStart = 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) {
case 'paid': return 'badge-success';
case 'accepted': return 'badge-success';
case 'sent': return 'badge-info';
case 'draft': return 'badge-ghost';
case 'void': return 'badge-error';
case 'declined': return 'badge-error';
default: return 'badge-ghost';
}
};
---
<DashboardLayout title="Invoices & Quotes - Chronus">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 class="text-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 btn-sm">
<Icon name="heroicons:plus" class="w-4 h-4" />
Create New
</a>
</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="heroicons: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="heroicons: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="heroicons:currency-dollar"
color="text-success"
/>
</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>
<select name="year" class="select w-full" onchange="this.form.submit()">
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
{availableYears.map(year => (
<option value={year} selected={year === selectedYear}>{year}</option>
))}
</select>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Type</legend>
<select name="type" class="select w-full" onchange="this.form.submit()">
<option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option>
</select>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend>
<select name="status" class="select w-full" onchange="this.form.submit()">
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option>
</select>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend>
<select name="sort" class="select w-full" onchange="this.form.submit()">
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
</select>
</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="heroicons:x-mark" class="w-3 h-3" />
Clear Filters
</a>
</div>
)}
</div>
</div>
<div class="card card-border bg-base-100">
<div class="card-body p-0">
<div class="px-4 py-3 border-b border-base-200">
<p class="text-xs text-base-content/50">
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-sm">
<thead>
<tr>
<th>Number</th>
<th>Client</th>
<th>Date</th>
<th>Due Date</th>
<th>Amount</th>
<th>Status</th>
<th>Type</th>
<th></th>
</tr>
</thead>
<tbody>
{allInvoices.length === 0 ? (
<tr>
<td colspan="8" class="text-center py-8 text-base-content/50 text-sm">
No invoices or quotes found. Create one to get started.
</td>
</tr>
) : (
allInvoices.map(({ invoice, client }) => (
<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>
</td>
<td>
{client ? (
<div class="font-medium">{client.name}</div>
) : (
<span class="text-base-content/40 italic">Deleted Client</span>
)}
</td>
<td>{invoice.issueDate.toLocaleDateString()}</td>
<td>{invoice.dueDate.toLocaleDateString()}</td>
<td class="font-mono font-medium">
{formatCurrency(invoice.total, invoice.currency)}
</td>
<td>
<div class={`badge ${getStatusColor(invoice.status)} badge-xs uppercase font-bold tracking-wider`}>
{invoice.status}
</div>
</td>
<td>
<span class="capitalize text-sm">{invoice.type}</span>
</td>
<td class="text-right">
<div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
<Icon name="heroicons:ellipsis-vertical" class="w-4 h-4" />
</div>
<ul tabindex="0" class="dropdown-content menu p-2 bg-base-100 rounded-box w-52 border border-base-200 z-100">
<li>
<a href={`/dashboard/invoices/${invoice.id}`}>
<Icon name="heroicons:eye" class="w-4 h-4" />
View Details
</a>
</li>
<li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
Edit
</a>
</li>
<li>
<a href={`/api/invoices/${invoice.id}/generate`} download>
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" />
Download PDF
</a>
</li>
{invoice.status === 'draft' && (
<li>
<form method="POST" action={`/api/invoices/${invoice.id}/status`} class="w-full">
<input type="hidden" name="status" value="sent" />
<button type="submit" class="w-full justify-start">
<Icon name="heroicons:paper-airplane" class="w-4 h-4" />
Mark as Sent
</button>
</form>
</li>
)}
<div class="divider my-1"></div>
<li>
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full">
<input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="w-full justify-start text-error hover:bg-error/10">
<Icon name="heroicons:trash" class="w-4 h-4" />
Delete
</button>
</form>
</li>
</ul>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -0,0 +1,225 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
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');
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)
.where(eq(clients.organizationId, currentTeamIdResolved))
.all();
// Generate next invoice number (INV-)
const lastInvoice = await db.select()
.from(invoices)
.where(and(
eq(invoices.organizationId, currentTeamIdResolved),
eq(invoices.type, 'invoice')
))
.orderBy(desc(invoices.createdAt))
.limit(1)
.get();
let nextInvoiceNumber = 'INV-001';
if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
let prefix = lastInvoice.number.replace(match[0], '');
// Ensure we don't carry over an EST- prefix to an invoice
if (prefix === 'EST-') prefix = 'INV-';
nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0');
}
}
// Generate next quote number (EST-)
const lastQuote = await db.select()
.from(invoices)
.where(and(
eq(invoices.organizationId, currentTeamIdResolved),
eq(invoices.type, 'quote')
))
.orderBy(desc(invoices.createdAt))
.limit(1)
.get();
let nextQuoteNumber = 'EST-001';
if (lastQuote) {
const match = lastQuote.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
let prefix = lastQuote.number.replace(match[0], '');
// Ensure we don't carry over an INV- prefix to a quote
if (prefix === 'INV-') prefix = 'EST-';
nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0');
}
}
const today = new Date().toISOString().split('T')[0];
const nextMonth = new Date();
nextMonth.setDate(nextMonth.getDate() + 30);
const defaultDueDate = nextMonth.toISOString().split('T')[0];
---
<DashboardLayout title="New Document - Chronus">
<div class="max-w-3xl mx-auto">
<div class="mb-6">
<a href="/dashboard/invoices" class="btn btn-ghost btn-xs gap-2 pl-0 hover:bg-transparent text-base-content/60">
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
Back to Invoices
</a>
<h1 class="text-2xl font-extrabold tracking-tight mt-2">Create New Document</h1>
</div>
{teamClients.length === 0 ? (
<div role="alert" class="alert alert-warning">
<Icon name="heroicons:exclamation-triangle" class="w-5 h-5" />
<div>
<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 card-border bg-base-100">
<div class="card-body p-4 gap-4">
<!-- Document Type -->
<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-200 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 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-200 rounded-lg p-3 flex-1 hover:border-primary has-checked:border-primary has-checked:bg-primary/5 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>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<!-- Client -->
<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>
</fieldset>
<!-- Number -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Number</legend>
<input
type="text"
name="number"
id="documentNumber"
class="input font-mono"
value={nextInvoiceNumber}
data-invoice-number={nextInvoiceNumber}
data-quote-number={nextQuoteNumber}
required
/>
</fieldset>
<!-- Issue Date -->
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Issue Date</legend>
<input
type="date"
id="invoice-issue-date"
name="issueDate"
class="input"
value={today}
required
/>
</fieldset>
<!-- Due Date -->
<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"
value={defaultDueDate}
required
/>
</fieldset>
<!-- Currency -->
<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>
</fieldset>
</div>
<div class="divider my-0"></div>
<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" />
</button>
</div>
</div>
</form>
)}
</div>
</DashboardLayout>
<script>
// Update number based on document type
const typeRadios = document.querySelectorAll('input[name="type"]');
const numberInput = document.getElementById('documentNumber') as HTMLInputElement | null;
const dueDateLabel = document.getElementById('dueDateLabel');
const invoiceNumber = numberInput?.dataset.invoiceNumber || 'INV-001';
const quoteNumber = numberInput?.dataset.quoteNumber || 'EST-001';
typeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (numberInput) {
if (target.value === 'quote') {
numberInput.value = quoteNumber;
} else if (target.value === 'invoice') {
numberInput.value = invoiceNumber;
}
}
if (dueDateLabel) {
dueDateLabel.textContent = target.value === 'quote' ? 'Valid Until' : 'Due Date';
}
});
});
</script>

View File

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

View File

@@ -1,31 +1,21 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import CategoryChart from '../../components/CategoryChart.vue'; import StatCard from '../../components/StatCard.astro';
import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue'; import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue'; import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, members, users, clients, categories } from '../../db/schema'; import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm'; import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime'; import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
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 teamMembers = await db.select({ const teamMembers = await db.select({
id: users.id, id: users.id,
@@ -37,9 +27,9 @@ const teamMembers = await db.select({
.where(eq(members.organizationId, userMembership.organizationId)) .where(eq(members.organizationId, userMembership.organizationId))
.all(); .all();
const allCategories = await db.select() const allTags = await db.select()
.from(categories) .from(tags)
.where(eq(categories.organizationId, userMembership.organizationId)) .where(eq(tags.organizationId, userMembership.organizationId))
.all(); .all();
const allClients = await db.select() const allClients = await db.select()
@@ -49,9 +39,11 @@ const allClients = await db.select()
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
const selectedMemberId = url.searchParams.get('member') || ''; 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 selectedClientId = url.searchParams.get('client') || '';
const timeRange = url.searchParams.get('range') || 'week'; const timeRange = url.searchParams.get('range') || 'week';
const customFrom = url.searchParams.get('from');
const customTo = url.searchParams.get('to');
const now = new Date(); const now = new Date();
let startDate = new Date(); let startDate = new Date();
@@ -78,6 +70,16 @@ switch (timeRange) {
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1); startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
break; break;
case 'custom':
if (customFrom) {
const parts = customFrom.split('-');
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
}
if (customTo) {
const parts = customTo.split('-');
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
}
break;
} }
const conditions = [ const conditions = [
@@ -90,8 +92,8 @@ if (selectedMemberId) {
conditions.push(eq(timeEntries.userId, selectedMemberId)); conditions.push(eq(timeEntries.userId, selectedMemberId));
} }
if (selectedCategoryId) { if (selectedTagId) {
conditions.push(eq(timeEntries.categoryId, selectedCategoryId)); conditions.push(eq(timeEntries.tagId, selectedTagId));
} }
if (selectedClientId) { if (selectedClientId) {
@@ -102,12 +104,12 @@ const entries = await db.select({
entry: timeEntries, entry: timeEntries,
user: users, user: users,
client: clients, client: clients,
category: categories, tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.innerJoin(users, eq(timeEntries.userId, users.id)) .innerJoin(users, eq(timeEntries.userId, users.id))
.innerJoin(clients, eq(timeEntries.clientId, clients.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)) .where(and(...conditions))
.orderBy(desc(timeEntries.startTime)) .orderBy(desc(timeEntries.startTime))
.all(); .all();
@@ -128,9 +130,9 @@ const statsByMember = teamMembers.map(member => {
}; };
}).sort((a, b) => b.totalTime - a.totalTime); }).sort((a, b) => b.totalTime - a.totalTime);
const statsByCategory = allCategories.map(category => { const statsByTag = allTags.map(tag => {
const categoryEntries = entries.filter(e => e.category.id === category.id); const tagEntries = entries.filter(e => e.tag?.id === tag.id);
const totalTime = categoryEntries.reduce((sum, e) => { const totalTime = tagEntries.reduce((sum, e) => {
if (e.entry.endTime) { if (e.entry.endTime) {
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime()); return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
} }
@@ -138,9 +140,9 @@ const statsByCategory = allCategories.map(category => {
}, 0); }, 0);
return { return {
category, tag,
totalTime, totalTime,
entryCount: categoryEntries.length, entryCount: tagEntries.length,
}; };
}).sort((a, b) => b.totalTime - a.totalTime); }).sort((a, b) => b.totalTime - a.totalTime);
@@ -167,6 +169,74 @@ const totalTime = entries.reduce((sum, e) => {
return sum; return sum;
}, 0); }, 0);
// Fetch invoices and quotes for the same time period
const invoiceConditions = [
eq(invoices.organizationId, userMembership.organizationId),
gte(invoices.issueDate, startDate),
lte(invoices.issueDate, endDate),
];
if (selectedClientId) {
invoiceConditions.push(eq(invoices.clientId, selectedClientId));
}
const allInvoices = await db.select({
invoice: invoices,
client: clients,
})
.from(invoices)
.leftJoin(clients, eq(invoices.clientId, clients.id))
.where(and(...invoiceConditions))
.orderBy(desc(invoices.issueDate))
.all();
// Invoice statistics
const invoiceStats = {
total: allInvoices.filter(i => i.invoice.type === 'invoice').length,
paid: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid').length,
sent: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent').length,
draft: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'draft').length,
void: allInvoices.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'void').length,
};
// Quote statistics
const quoteStats = {
total: allInvoices.filter(i => i.invoice.type === 'quote').length,
accepted: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'accepted').length,
sent: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length,
declined: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'declined').length,
draft: allInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'draft').length,
};
// Revenue statistics
const revenueStats = {
total: allInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((sum, i) => sum + i.invoice.total, 0),
pending: allInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'sent')
.reduce((sum, i) => sum + i.invoice.total, 0),
quotedValue: allInvoices
.filter(i => i.invoice.type === 'quote' && (i.invoice.status === 'sent' || i.invoice.status === 'accepted'))
.reduce((sum, i) => sum + i.invoice.total, 0),
};
// Revenue by client
const revenueByClient = allClients.map(client => {
const clientInvoices = allInvoices.filter(i =>
i.client?.id === client.id &&
i.invoice.type === 'invoice' &&
i.invoice.status === 'paid'
);
const revenue = clientInvoices.reduce((sum, i) => sum + i.invoice.total, 0);
return {
client,
revenue,
invoiceCount: clientInvoices.length,
};
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
function getTimeRangeLabel(range: string) { function getTimeRangeLabel(range: string) {
switch (range) { switch (range) {
case 'today': return 'Today'; case 'today': return 'Today';
@@ -175,37 +245,62 @@ function getTimeRangeLabel(range: string) {
case 'mtd': return 'Month to Date'; case 'mtd': return 'Month to Date';
case 'ytd': return 'Year to Date'; case 'ytd': return 'Year to Date';
case 'last-month': return 'Last Month'; case 'last-month': return 'Last Month';
case 'custom': return 'Custom Range';
default: return 'Last 7 Days'; default: return 'Last 7 Days';
} }
} }
--- ---
<DashboardLayout title="Reports - Chronus"> <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 --> <!-- Filters -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Time Range</legend>
<span class="label-text font-medium">Time Range</span> <select id="reports-range" name="range" class="select w-full" onchange="this.form.submit()">
</label>
<select name="range" class="select select-bordered" onchange="this.form.submit()">
<option value="today" selected={timeRange === 'today'}>Today</option> <option value="today" selected={timeRange === 'today'}>Today</option>
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option> <option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option> <option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option> <option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year 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="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
</select> </select>
</div> </fieldset>
<div class="form-control"> {timeRange === 'custom' && (
<label class="label"> <>
<span class="label-text font-medium">Team Member</span> <fieldset class="fieldset">
</label> <legend class="fieldset-legend text-xs">From Date</legend>
<select name="member" class="select select-bordered" onchange="this.form.submit()"> <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'))}
onchange="this.form.submit()"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">To Date</legend>
<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'))}
onchange="this.form.submit()"
/>
</fieldset>
</>
)}
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Team Member</legend>
<select id="reports-member" name="member" class="select w-full" onchange="this.form.submit()">
<option value="">All Members</option> <option value="">All Members</option>
{teamMembers.map(member => ( {teamMembers.map(member => (
<option value={member.id} selected={selectedMemberId === member.id}> <option value={member.id} selected={selectedMemberId === member.id}>
@@ -213,27 +308,23 @@ function getTimeRangeLabel(range: string) {
</option> </option>
))} ))}
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Tag</legend>
<span class="label-text font-medium">Category</span> <select id="reports-tag" name="tag" class="select w-full" onchange="this.form.submit()">
</label> <option value="">All Tags</option>
<select name="category" class="select select-bordered" onchange="this.form.submit()"> {allTags.map(tag => (
<option value="">All Categories</option> <option value={tag.id} selected={selectedTagId === tag.id}>
{allCategories.map(category => ( {tag.name}
<option value={category.id} selected={selectedCategoryId === category.id}>
{category.name}
</option> </option>
))} ))}
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Client</legend>
<span class="label-text font-medium">Client</span> <select id="reports-client" name="client" class="select w-full" onchange="this.form.submit()">
</label>
<select name="client" class="select select-bordered" onchange="this.form.submit()">
<option value="">All Clients</option> <option value="">All Clients</option>
{allClients.map(client => ( {allClients.map(client => (
<option value={client.id} selected={selectedClientId === client.id}> <option value={client.id} selected={selectedClientId === client.id}>
@@ -241,66 +332,177 @@ function getTimeRangeLabel(range: string) {
</option> </option>
))} ))}
</select> </select>
</div> </fieldset>
</form> </form>
</div> </div>
</div> </div>
<!-- Summary Stats --> <!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<div class="stats shadow border border-base-300"> <StatCard
<div class="stat"> title="Total Time"
<div class="stat-figure text-primary"> value={formatDuration(totalTime)}
<Icon name="heroicons:clock" class="w-8 h-8" /> description={getTimeRangeLabel(timeRange)}
icon="heroicons:clock"
color="text-primary"
/>
<StatCard
title="Total Entries"
value={String(entries.length)}
description={getTimeRangeLabel(timeRange)}
icon="heroicons:list-bullet"
color="text-secondary"
/>
<StatCard
title="Revenue"
value={formatCurrency(revenueStats.total)}
description={`${invoiceStats.paid} paid invoices`}
icon="heroicons:currency-dollar"
color="text-success"
/>
<StatCard
title="Active Members"
value={String(statsByMember.filter(s => s.entryCount > 0).length)}
description={`of ${teamMembers.length} total`}
icon="heroicons:user-group"
color="text-accent"
/>
</div>
<!-- Invoice & Quote Stats -->
<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="heroicons:document-text" class="w-4 h-4" />
Invoices Overview
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Total Invoices</div>
<div class="stat-value text-2xl">{invoiceStats.total}</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<div class="stat-title text-xs">Paid</div>
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
</div>
<div class="stat bg-info/10 rounded-lg">
<div class="stat-title text-xs">Sent</div>
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
</div>
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Draft</div>
<div class="stat-value text-2xl">{invoiceStats.draft}</div>
</div>
</div>
<div class="divider my-2"></div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Revenue (Paid)</span>
<span class="font-bold text-success">{formatCurrency(revenueStats.total)}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Pending (Sent)</span>
<span class="font-bold text-warning">{formatCurrency(revenueStats.pending)}</span>
</div> </div>
<div 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> </div>
<div class="stats shadow border border-base-300"> <div class="card card-border bg-base-100">
<div class="stat"> <div class="card-body p-4">
<div class="stat-figure text-secondary"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:list-bullet" class="w-8 h-8" /> <Icon name="heroicons:clipboard-document-list" class="w-4 h-4" />
Quotes Overview
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Total Quotes</div>
<div class="stat-value text-2xl">{quoteStats.total}</div>
</div>
<div class="stat bg-success/10 rounded-lg">
<div class="stat-title text-xs">Accepted</div>
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
</div>
<div class="stat bg-info/10 rounded-lg">
<div class="stat-title text-xs">Pending</div>
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
</div>
<div class="stat bg-error/10 rounded-lg">
<div class="stat-title text-xs">Declined</div>
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
</div>
</div>
<div class="divider my-2"></div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Quoted Value</span>
<span class="font-bold">{formatCurrency(revenueStats.quotedValue)}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Conversion Rate</span>
<span class="font-bold">
{quoteStats.total > 0 ? Math.round((quoteStats.accepted / quoteStats.total) * 100) : 0}%
</span>
</div>
</div> </div>
<div 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> </div>
<div class="stats shadow border border-base-300"> <!-- Revenue by Client - Only show if there's revenue data and no client filter -->
<div class="stat"> {!selectedClientId && revenueByClient.length > 0 && (
<div class="stat-figure text-accent"> <div class="card card-border bg-base-100 mb-6">
<Icon name="heroicons:user-group" class="w-8 h-8" /> <div class="card-body p-4">
</div> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<div class="stat-title">Active Members</div> <Icon name="heroicons:banknotes" class="w-4 h-4" />
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div> Revenue by Client
<div class="stat-desc">of {teamMembers.length} total</div> </h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Client</th>
<th>Revenue</th>
<th>Invoices</th>
<th>Avg Invoice</th>
</tr>
</thead>
<tbody>
{revenueByClient.slice(0, 10).map(stat => (
<tr>
<td>
<div class="font-medium">{stat.client.name}</div>
</td>
<td class="font-mono font-semibold text-success text-sm">{formatCurrency(stat.revenue)}</td>
<td>{stat.invoiceCount}</td>
<td class="font-mono text-sm">
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
)}
{/* Charts Section - Only show if there's data */} {/* Charts Section - Only show if there's data */}
{totalTime > 0 && ( {totalTime > 0 && (
<> <>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{/* Category Distribution Chart - Only show when no category filter */} {/* Tag Distribution Chart - Only show when no tag filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && ( {!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:chart-pie" class="w-6 h-6" /> <Icon name="heroicons:chart-pie" class="w-4 h-4" />
Category Distribution Tag Distribution
</h2> </h2>
<div class="h-64 w-full"> <div class="h-64 w-full">
<CategoryChart <TagChart
client:load client:visible
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({ tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name, name: s.tag.name,
totalTime: s.totalTime, totalTime: s.totalTime,
color: s.category.color || '#3b82f6' color: s.tag.color || '#3b82f6'
}))} }))}
/> />
</div> </div>
@@ -310,15 +512,15 @@ function getTimeRangeLabel(range: string) {
{/* Client Distribution Chart - Only show when no client filter */} {/* Client Distribution Chart - Only show when no client filter */}
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && ( {!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:chart-bar" class="w-6 h-6" /> <Icon name="heroicons:chart-bar" class="w-4 h-4" />
Time by Client Time by Client
</h2> </h2>
<div class="h-64 w-full"> <div class="h-64 w-full">
<ClientChart <ClientChart
client:load client:visible
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({ clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
name: s.client.name, name: s.client.name,
totalTime: s.totalTime totalTime: s.totalTime
@@ -332,15 +534,15 @@ function getTimeRangeLabel(range: string) {
{/* Team Member Chart - Only show when no member filter */} {/* Team Member Chart - Only show when no member filter */}
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && ( {!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 card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:users" class="w-6 h-6" /> <Icon name="heroicons:users" class="w-4 h-4" />
Time by Team Member Time by Team Member
</h2> </h2>
<div class="h-64 w-full"> <div class="h-64 w-full">
<MemberChart <MemberChart
client:load client:visible
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({ members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
name: s.member.name, name: s.member.name,
totalTime: s.totalTime totalTime: s.totalTime
@@ -355,14 +557,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Member - Only show if there's data and no member filter */} {/* Stats by Member - Only show if there's data and no member filter */}
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && ( {!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 card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:users" class="w-6 h-6" /> <Icon name="heroicons:users" class="w-4 h-4" />
By Team Member By Team Member
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Member</th> <th>Member</th>
@@ -376,13 +578,13 @@ function getTimeRangeLabel(range: string) {
<tr> <tr>
<td> <td>
<div> <div>
<div class="font-bold">{stat.member.name}</div> <div class="font-medium">{stat.member.name}</div>
<div class="text-sm opacity-50">{stat.member.email}</div> <div class="text-xs text-base-content/40">{stat.member.email}</div>
</div> </div>
</td> </td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td> <td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</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)'} {stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
</td> </td>
</tr> </tr>
@@ -394,45 +596,45 @@ function getTimeRangeLabel(range: string) {
</div> </div>
)} )}
{/* Stats by Category - Only show if there's data and no category filter */} {/* Stats by Tag - Only show if there's data and no tag filter */}
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && ( {!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:tag" class="w-6 h-6" /> <Icon name="heroicons:tag" class="w-4 h-4" />
By Category By Tag
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Category</th> <th>Tag</th>
<th>Total Time</th> <th>Total Time</th>
<th>Entries</th> <th>Entries</th>
<th>% of Total</th> <th>% of Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{statsByCategory.filter(s => s.totalTime > 0).map(stat => ( {statsByTag.filter(s => s.totalTime > 0).map(stat => (
<tr> <tr>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{stat.category.color && ( {stat.tag.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span> <span class="w-3 h-3 rounded-full" style={`background-color: ${stat.tag.color}`}></span>
)} )}
<span>{stat.category.name}</span> <span>{stat.tag.name}</span>
</div> </div>
</td> </td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td> <td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td> <td>{stat.entryCount}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<progress <progress
class="progress progress-primary w-20" class="progress progress-primary w-16"
value={stat.totalTime} value={stat.totalTime}
max={totalTime} max={totalTime}
></progress> ></progress>
<span class="text-sm"> <span class="text-xs">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}% {totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span> </span>
</div> </div>
@@ -448,14 +650,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Client - Only show if there's data and no client filter */} {/* Stats by Client - Only show if there's data and no client filter */}
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && ( {!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 card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:building-office" class="w-6 h-6" /> <Icon name="heroicons:building-office" class="w-4 h-4" />
By Client By Client
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Client</th> <th>Client</th>
@@ -468,16 +670,16 @@ function getTimeRangeLabel(range: string) {
{statsByClient.filter(s => s.totalTime > 0).map(stat => ( {statsByClient.filter(s => s.totalTime > 0).map(stat => (
<tr> <tr>
<td>{stat.client.name}</td> <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>{stat.entryCount}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<progress <progress
class="progress progress-secondary w-20" class="progress progress-secondary w-16"
value={stat.totalTime} value={stat.totalTime}
max={totalTime} max={totalTime}
></progress> ></progress>
<span class="text-sm"> <span class="text-xs">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}% {totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span> </span>
</div> </div>
@@ -492,21 +694,29 @@ function getTimeRangeLabel(range: string) {
)} )}
{/* Detailed Entries */} {/* Detailed Entries */}
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <div class="flex justify-between items-center mb-3">
<Icon name="heroicons:document-text" class="w-6 h-6" /> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:document-text" class="w-4 h-4" />
Detailed Entries ({entries.length}) Detailed Entries ({entries.length})
</h2> </h2>
{entries.length > 0 && (
<a href={`/api/reports/export${url.search}`} class="btn btn-xs btn-ghost" target="_blank">
<Icon name="heroicons:arrow-down-tray" class="w-3.5 h-3.5" />
Export CSV
</a>
)}
</div>
{entries.length > 0 ? ( {entries.length > 0 ? (
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Member</th> <th>Member</th>
<th>Client</th> <th>Client</th>
<th>Category</th> <th>Tag</th>
<th>Description</th> <th>Description</th>
<th>Duration</th> <th>Duration</th>
</tr> </tr>
@@ -516,22 +726,26 @@ function getTimeRangeLabel(range: string) {
<tr> <tr>
<td class="whitespace-nowrap"> <td class="whitespace-nowrap">
{e.entry.startTime.toLocaleDateString()}<br/> {e.entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50"> <span class="text-xs text-base-content/40">
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</td> </td>
<td>{e.user.name}</td> <td>{e.user.name}</td>
<td>{e.client.name}</td> <td>{e.client.name}</td>
<td> <td>
<div class="flex items-center gap-2"> {e.tag ? (
{e.category.color && ( <div class="badge badge-xs badge-outline flex items-center gap-1">
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span> {e.tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
)} )}
<span>{e.category.name}</span> <span>{e.tag.name}</span>
</div> </div>
) : (
<span class="text-base-content/30">-</span>
)}
</td> </td>
<td>{e.entry.description || '-'}</td> <td class="text-base-content/60">{e.entry.description || '-'}</td>
<td class="font-mono"> <td class="font-mono text-sm">
{e.entry.endTime {e.entry.endTime
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime()) ? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
: 'Running...' : 'Running...'
@@ -543,12 +757,12 @@ function getTimeRangeLabel(range: string) {
</table> </table>
</div> </div>
) : ( ) : (
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex flex-col items-center justify-center py-10 text-center">
<Icon name="heroicons:inbox" class="w-16 h-16 text-base-content/20 mb-4" /> <Icon name="heroicons:inbox" class="w-12 h-12 text-base-content/15 mb-3" />
<h3 class="text-lg font-semibold mb-2">No time entries found</h3> <h3 class="text-base font-semibold mb-1">No time entries found</h3>
<p class="text-base-content/60 mb-4">Try adjusting your filters or select a different time range.</p> <p class="text-base-content/50 text-sm mb-4">Try adjusting your filters or select a different time range.</p>
<a href="/dashboard/tracker" class="btn btn-primary"> <a href="/dashboard/tracker" class="btn btn-primary btn-sm">
<Icon name="heroicons:play" class="w-5 h-5" /> <Icon name="heroicons:play" class="w-4 h-4" />
Start Tracking Time Start Tracking Time
</a> </a>
</div> </div>

View File

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

View File

@@ -1,27 +1,17 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import Avatar from '../../components/Avatar.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../db'; import { db } from '../../db';
import { members, users } from '../../db/schema'; import { members, users } from '../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
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 teamMembers = await db.select({ const teamMembers = await db.select({
member: members, member: members,
@@ -37,25 +27,28 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
--- ---
<DashboardLayout title="Team - Chronus"> <DashboardLayout title="Team - Chronus">
<div class="flex justify-between items-center mb-6"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h1 class="text-3xl font-bold">Team Members</h1> <div>
<h1 class="text-2xl font-extrabold tracking-tight">Team Members</h1>
<p class="text-base-content/60 text-sm mt-1">Manage your organization's team</p>
</div>
<div class="flex gap-2"> <div class="flex gap-2">
{isAdmin && ( {isAdmin && (
<> <>
<a href="/dashboard/team/settings" class="btn btn-ghost"> <a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" /> <Icon name="heroicons:cog-6-tooth" class="w-4 h-4" />
Settings Settings
</a> </a>
<a href="/dashboard/team/invite" class="btn btn-primary">Invite Member</a> <a href="/dashboard/team/invite" class="btn btn-primary btn-sm">Invite Member</a>
</> </>
)} )}
</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"> <div class="card-body p-0">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@@ -67,25 +60,21 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
</thead> </thead>
<tbody> <tbody>
{teamMembers.map(({ member, user: teamUser }) => ( {teamMembers.map(({ member, user: teamUser }) => (
<tr> <tr class="hover">
<td> <td>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="avatar placeholder"> <Avatar name={teamUser.name} />
<div class="bg-neutral text-neutral-content rounded-full w-10">
<span>{teamUser.name.charAt(0)}</span>
</div>
</div>
<div> <div>
<div class="font-bold">{teamUser.name}</div> <div class="font-medium">{teamUser.name}</div>
{teamUser.id === user.id && ( {teamUser.id === user.id && (
<span class="badge badge-sm">You</span> <span class="badge badge-xs">You</span>
)} )}
</div> </div>
</div> </div>
</td> </td>
<td>{teamUser.email}</td> <td class="text-base-content/60">{teamUser.email}</td>
<td> <td>
<span class={`badge ${ <span class={`badge badge-xs ${
member.role === 'owner' ? 'badge-primary' : member.role === 'owner' ? 'badge-primary' :
member.role === 'admin' ? 'badge-secondary' : member.role === 'admin' ? 'badge-secondary' :
'badge-ghost' 'badge-ghost'
@@ -93,15 +82,15 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
{member.role} {member.role}
</span> </span>
</td> </td>
<td>{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td> <td class="text-base-content/40">{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
{isAdmin && ( {isAdmin && (
<td> <td>
{teamUser.id !== user.id && member.role !== 'owner' && ( {teamUser.id !== user.id && member.role !== 'owner' && (
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-ghost btn-sm"> <div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" /> <Icon name="heroicons:ellipsis-vertical" class="w-4 h-4" />
</label> </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-200">
<li> <li>
<form method="POST" action={`/api/team/change-role`}> <form method="POST" action={`/api/team/change-role`}>
<input type="hidden" name="userId" value={teamUser.id} /> <input type="hidden" name="userId" value={teamUser.id} />

View File

@@ -29,45 +29,39 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
<DashboardLayout title="Invite Team Member - Chronus"> <DashboardLayout title="Invite Team Member - Chronus">
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<h1 class="text-3xl font-bold mb-6">Invite Team Member</h1> <h1 class="text-2xl font-extrabold tracking-tight mb-6">Invite Team Member</h1>
<form method="POST" action="/api/team/invite" class="card bg-base-100 shadow-xl border border-base-200"> <form method="POST" action="/api/team/invite" class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<div class="alert alert-info mb-4"> <div class="alert alert-info mb-4">
<Icon name="heroicons:information-circle" class="w-6 h-6" /> <Icon name="heroicons:information-circle" class="w-4 h-4 shrink-0" />
<span>The user must already have an account. They'll be added to your organization.</span> <span class="text-sm">The user must already have an account. They'll be added to your organization.</span>
</div> </div>
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="email"> <legend class="fieldset-legend text-xs">Email Address</legend>
<span class="label-text">Email Address</span>
</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
placeholder="user@example.com" placeholder="user@example.com"
class="input input-bordered" class="input"
required required
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label" for="role"> <legend class="fieldset-legend text-xs">Role</legend>
<span class="label-text">Role</span> <select id="role" name="role" class="select" required>
</label>
<select id="role" name="role" class="select select-bordered" required>
<option value="member">Member</option> <option value="member">Member</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </select>
<label class="label"> <p class="text-xs text-base-content/40 mt-1">Members can track time. Admins can manage team and clients.</p>
<span class="label-text-alt">Members can track time. Admins can manage team and clients.</span> </fieldset>
</label>
</div>
<div class="card-actions justify-end mt-6"> <div class="flex justify-end gap-2 mt-4">
<a href="/dashboard/team" class="btn btn-ghost">Cancel</a> <a href="/dashboard/team" class="btn btn-ghost btn-sm">Cancel</a>
<button type="submit" class="btn btn-primary">Invite Member</button> <button type="submit" class="btn btn-primary btn-sm">Invite Member</button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -2,26 +2,15 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import { db } from '../../../db'; import { db } from '../../../db';
import { categories, members, organizations } from '../../../db/schema'; import { organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
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'; const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team'); if (!isAdmin) return Astro.redirect('/dashboard/team');
@@ -35,9 +24,11 @@ const organization = await db.select()
if (!organization) return Astro.redirect('/dashboard'); if (!organization) return Astro.redirect('/dashboard');
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, orgId)) const allTags = await db.select()
.from(tags)
.where(eq(tags.organizationId, orgId))
.all(); .all();
const url = new URL(Astro.request.url); const url = new URL(Astro.request.url);
@@ -46,50 +37,188 @@ const successType = url.searchParams.get('success');
<DashboardLayout title="Team Settings - Chronus"> <DashboardLayout title="Team Settings - Chronus">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team" class="btn btn-ghost btn-sm"> <a href="/dashboard/team" class="btn btn-ghost btn-xs">
<Icon name="heroicons:arrow-left" class="w-5 h-5" /> <Icon name="heroicons:arrow-left" class="w-4 h-4" />
</a> </a>
<h1 class="text-3xl font-bold">Team Settings</h1> <h1 class="text-2xl font-extrabold tracking-tight">Team Settings</h1>
</div> </div>
<!-- Team Settings --> <!-- Team Settings -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
<Icon name="heroicons:building-office-2" class="w-6 h-6" /> <Icon name="heroicons:building-office-2" class="w-4 h-4" />
Team Settings Team Settings
</h2> </h2>
{successType === 'org-name' && ( {successType === 'org-name' && (
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
<Icon name="heroicons:check-circle" class="w-6 h-6" /> <Icon name="heroicons:check-circle" class="w-4 h-4" />
<span>Team name updated successfully!</span> <span class="text-sm">Team information updated successfully!</span>
</div> </div>
)} )}
<form action="/api/organizations/update-name" method="POST" class="space-y-4"> <form
action="/api/organizations/update-name"
method="POST"
class="space-y-3"
enctype="multipart/form-data"
>
<input type="hidden" name="organizationId" value={organization.id} /> <input type="hidden" name="organizationId" value={organization.id} />
<label class="form-control"> <fieldset class="fieldset">
<div class="label"> <legend class="fieldset-legend text-xs">Team Logo</legend>
<span class="label-text font-medium">Team Name</span> <div class="flex items-center gap-4">
<div class="avatar placeholder">
<div class="bg-base-200 text-neutral-content rounded-xl w-20 border border-base-200 flex items-center justify-center overflow-hidden">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
class="w-full h-full object-cover"
/>
) : (
<Icon
name="heroicons:photo"
class="w-6 h-6 opacity-40 text-base-content"
/>
)}
</div> </div>
</div>
<div>
<input
type="file"
name="logo"
accept="image/png, image/jpeg"
class="file-input file-input-bordered file-input-sm w-full max-w-xs"
/>
<div class="text-xs text-base-content/40 mt-1">
Upload a company logo (PNG, JPG). Will be displayed on invoices and quotes.
</div>
</div>
</div>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Team Name</legend>
<input <input
type="text" type="text"
id="team-name"
name="name" name="name"
value={organization.name} value={organization.name}
placeholder="Organization name" placeholder="Organization name"
class="input input-bordered w-full" class="input w-full"
required required
/> />
<div class="label"> <p class="text-xs text-base-content/40 mt-1">This name is visible to all team members</p>
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span> </fieldset>
</div>
</label>
<div class="flex justify-end"> <div class="divider text-xs text-base-content/40 my-2">Address Information</div>
<button type="submit" class="btn btn-primary">
<Icon name="heroicons:check" class="w-5 h-5" /> <fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Street Address</legend>
<input
type="text"
id="team-street"
name="street"
value={organization.street || ''}
placeholder="123 Main Street"
class="input w-full"
/>
</fieldset>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">City</legend>
<input
type="text"
id="team-city"
name="city"
value={organization.city || ''}
placeholder="City"
class="input w-full"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">State/Province</legend>
<input
type="text"
id="team-state"
name="state"
value={organization.state || ''}
placeholder="State/Province"
class="input w-full"
/>
</fieldset>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Postal Code</legend>
<input
type="text"
id="team-zip"
name="zip"
value={organization.zip || ''}
placeholder="12345"
class="input w-full"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Country</legend>
<input
type="text"
id="team-country"
name="country"
value={organization.country || ''}
placeholder="Country"
class="input w-full"
/>
</fieldset>
</div>
<div class="divider text-xs text-base-content/40 my-2">Defaults</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Default Tax Rate (%)</legend>
<input
type="number"
id="default-tax-rate"
name="defaultTaxRate"
step="0.01"
min="0"
max="100"
value={organization.defaultTaxRate || 0}
class="input w-full"
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Default Currency</legend>
<select
id="default-currency"
name="defaultCurrency"
class="select w-full"
>
<option value="USD" selected={!organization.defaultCurrency || organization.defaultCurrency === 'USD'}>USD ($)</option>
<option value="EUR" selected={organization.defaultCurrency === 'EUR'}>EUR (€)</option>
<option value="GBP" selected={organization.defaultCurrency === 'GBP'}>GBP (£)</option>
<option value="CAD" selected={organization.defaultCurrency === 'CAD'}>CAD ($)</option>
<option value="AUD" selected={organization.defaultCurrency === 'AUD'}>AUD ($)</option>
</select>
</fieldset>
</div>
<div class="flex flex-col sm:flex-row justify-between items-center gap-3 mt-4">
<span class="text-xs text-base-content/40 text-center sm:text-left">
Address information appears on invoices and quotes
</span>
<button type="submit" class="btn btn-primary btn-sm w-full sm:w-auto">
<Icon name="heroicons:check" class="w-4 h-4" />
Save Changes Save Changes
</button> </button>
</div> </div>
@@ -97,60 +226,142 @@ const successType = url.searchParams.get('success');
</div> </div>
</div> </div>
<!-- Categories Section --> <!-- Tags Section -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:tag" class="w-6 h-6" /> <Icon name="heroicons:tag" class="w-4 h-4" />
Work Categories Tags & Rates
</h2> </h2>
<a href="/dashboard/team/settings/categories/new" class="btn btn-primary btn-sm"> <button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-xs">
<Icon name="heroicons:plus" class="w-5 h-5" /> <Icon name="heroicons:plus" class="w-3 h-3" />
Add Category Add Tag
</a> </button>
</div> </div>
<p class="text-base-content/70 mb-4"> <p class="text-base-content/60 text-xs mb-4">
Categories help organize time tracking by type of work. All team members use the same categories. Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes.
</p> </p>
{allCategories.length === 0 ? ( {allTags.length === 0 ? (
<div class="alert alert-info"> <div class="alert alert-info">
<Icon name="heroicons:information-circle" class="w-6 h-6" /> <Icon name="heroicons:information-circle" class="w-4 h-4" />
<div> <div>
<div class="font-bold">No categories yet</div> <div class="font-semibold text-sm">No tags yet</div>
<div class="text-sm">Create your first category to start organizing time entries.</div> <div class="text-xs">Create tags to add context and rates to your time entries.</div>
</div> </div>
</div> </div>
) : ( ) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="overflow-x-auto">
{allCategories.map(category => ( <table class="table table-sm">
<div class="card bg-base-200 border border-base-300"> <thead>
<div class="card-body p-4"> <tr>
<div class="flex items-center gap-3"> <th>Name</th>
{category.color && ( <th>Rate / Hr</th>
<span class="w-4 h-4 rounded-full shrink-0" style={`background-color: ${category.color}`}></span> <th class="w-20"></th>
</tr>
</thead>
<tbody>
{allTags.map(tag => (
<tr class="hover">
<td>
<div class="flex items-center gap-2">
{tag.color && (
<div class="w-3 h-3 rounded-full" style={`background-color: ${tag.color}`}></div>
)} )}
<div class="grow min-w-0"> <span class="font-medium">{tag.name}</span>
<h3 class="font-semibold truncate">{category.name}</h3>
<p class="text-xs text-base-content/60">
Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}
</p>
</div> </div>
<a </td>
href={`/dashboard/team/settings/categories/${category.id}/edit`} <td>
class="btn btn-ghost btn-xs" {tag.rate ? (
<span class="font-mono text-sm">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
) : (
<span class="text-base-content/40 text-xs italic">No rate</span>
)}
</td>
<td>
<div class="flex gap-1">
<button
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`}
class="btn btn-ghost btn-xs btn-square"
> >
<Icon name="heroicons:pencil" class="w-4 h-4" /> <Icon name="heroicons:pencil" class="w-3 h-3" />
</a> </button>
<form method="POST" action={`/api/tags/${tag.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this tag?');">
<button class="btn btn-ghost btn-xs btn-square text-error">
<Icon name="heroicons:trash" class="w-3 h-3" />
</button>
</form>
</div> </div>
{/* Edit Modal */}
<dialog id={`edit_tag_modal_${tag.id}`} class="modal">
<div class="modal-box">
<h3 class="font-semibold text-base">Edit Tag</h3>
<form method="POST" action={`/api/tags/${tag.id}/update`}>
<fieldset class="fieldset mb-3">
<legend class="fieldset-legend text-xs">Name</legend>
<input type="text" name="name" value={tag.name} class="input w-full" required />
</fieldset>
<fieldset class="fieldset mb-3">
<legend class="fieldset-legend text-xs">Color</legend>
<input type="color" name="color" value={tag.color || '#3b82f6'} class="input w-full h-12 p-1" />
</fieldset>
<fieldset class="fieldset mb-4">
<legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input w-full" />
<p class="text-xs text-base-content/40 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
</fieldset>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div> </div>
</form>
</div> </div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
)} )}
</div> </div>
</div> </div>
<dialog id="new_tag_modal" class="modal">
<div class="modal-box">
<h3 class="font-semibold text-base">New Tag</h3>
<form method="POST" action="/api/tags/create">
<input type="hidden" name="organizationId" value={organization.id} />
<fieldset class="fieldset mb-3">
<legend class="fieldset-legend text-xs">Name</legend>
<input type="text" name="name" class="input w-full" required placeholder="e.g. Billable, Rush" />
</fieldset>
<fieldset class="fieldset mb-3">
<legend class="fieldset-legend text-xs">Color</legend>
<input type="color" name="color" value="#3b82f6" class="input w-full h-12 p-1" />
</fieldset>
<fieldset class="fieldset mb-4">
<legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
<input type="number" name="rate" value="0" min="0" class="input w-full" />
<p class="text-xs text-base-content/40 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
</fieldset>
<div class="modal-action">
<button type="button" class="btn btn-sm" onclick="document.getElementById('new_tag_modal').close()">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm">Create Tag</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</DashboardLayout> </DashboardLayout>

View File

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

View File

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

View File

@@ -2,28 +2,18 @@
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue'; import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema'; import { timeEntries, clients, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm'; import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime'; import { formatTimeRange } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
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 organizationId = userMembership.organizationId; const organizationId = userMembership.organizationId;
@@ -32,11 +22,6 @@ const allClients = await db.select()
.where(eq(clients.organizationId, organizationId)) .where(eq(clients.organizationId, organizationId))
.all(); .all();
const allCategories = await db.select()
.from(categories)
.where(eq(categories.organizationId, organizationId))
.all();
const allTags = await db.select() const allTags = await db.select()
.from(tags) .from(tags)
.where(eq(tags.organizationId, organizationId)) .where(eq(tags.organizationId, organizationId))
@@ -49,8 +34,9 @@ const pageSize = 20;
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const filterClient = url.searchParams.get('client') || ''; const filterClient = url.searchParams.get('client') || '';
const filterCategory = url.searchParams.get('category') || '';
const filterStatus = url.searchParams.get('status') || ''; const filterStatus = url.searchParams.get('status') || '';
const filterType = url.searchParams.get('type') || '';
const sortBy = url.searchParams.get('sort') || 'start-desc'; const sortBy = url.searchParams.get('sort') || 'start-desc';
const searchTerm = url.searchParams.get('search') || ''; const searchTerm = url.searchParams.get('search') || '';
@@ -60,10 +46,6 @@ if (filterClient) {
conditions.push(eq(timeEntries.clientId, filterClient)); conditions.push(eq(timeEntries.clientId, filterClient));
} }
if (filterCategory) {
conditions.push(eq(timeEntries.categoryId, filterCategory));
}
if (filterStatus === 'completed') { if (filterStatus === 'completed') {
conditions.push(sql`${timeEntries.endTime} IS NOT NULL`); conditions.push(sql`${timeEntries.endTime} IS NOT NULL`);
} else if (filterStatus === 'running') { } else if (filterStatus === 'running') {
@@ -74,6 +56,12 @@ if (searchTerm) {
conditions.push(like(timeEntries.description, `%${searchTerm}%`)); conditions.push(like(timeEntries.description, `%${searchTerm}%`));
} }
if (filterType === 'manual') {
conditions.push(eq(timeEntries.isManual, true));
} else if (filterType === 'timed') {
conditions.push(eq(timeEntries.isManual, false));
}
const totalCount = await db.select({ count: sql<number>`count(*)` }) const totalCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries) .from(timeEntries)
.where(and(...conditions)) .where(and(...conditions))
@@ -99,13 +87,13 @@ switch (sortBy) {
const entries = await db.select({ const entries = await db.select({
entry: timeEntries, entry: timeEntries,
client: clients, client: clients,
category: categories,
user: users, user: users,
tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id)) .leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
.leftJoin(users, eq(timeEntries.userId, users.id)) .leftJoin(users, eq(timeEntries.userId, users.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and(...conditions)) .where(and(...conditions))
.orderBy(orderBy) .orderBy(orderBy)
.limit(pageSize) .limit(pageSize)
@@ -115,9 +103,11 @@ const entries = await db.select({
const runningEntry = await db.select({ const runningEntry = await db.select({
entry: timeEntries, entry: timeEntries,
client: clients, client: clients,
tag: tags,
}) })
.from(timeEntries) .from(timeEntries)
.leftJoin(clients, eq(timeEntries.clientId, clients.id)) .leftJoin(clients, eq(timeEntries.clientId, clients.id))
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
.where(and( .where(and(
eq(timeEntries.userId, user.id), eq(timeEntries.userId, user.id),
sql`${timeEntries.endTime} IS NULL` sql`${timeEntries.endTime} IS NULL`
@@ -149,17 +139,17 @@ const paginationPages = getPaginationPages(page, totalPages);
--- ---
<DashboardLayout title="Time Tracker - Chronus"> <DashboardLayout title="Time Tracker - Chronus">
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1> <h1 class="text-2xl font-extrabold tracking-tight mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry -->
<div class="tabs tabs-lift mb-6">
<input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
<div class="tab-content bg-base-100 border-base-300 p-6">
{allClients.length === 0 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning mb-6"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<span>You need to create a client before tracking time.</span> <Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a> <span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
</div> <a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
) : allCategories.length === 0 ? (
<div class="alert alert-warning mb-6">
<span>You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
</div> </div>
) : ( ) : (
<Timer <Timer
@@ -168,36 +158,55 @@ const paginationPages = getPaginationPages(page, totalPages);
startTime: runningEntry.entry.startTime.getTime(), startTime: runningEntry.entry.startTime.getTime(),
description: runningEntry.entry.description, description: runningEntry.entry.description,
clientId: runningEntry.entry.clientId, clientId: runningEntry.entry.clientId,
categoryId: runningEntry.entry.categoryId, tagId: runningEntry.tag?.id,
} : null} } : null}
clients={allClients.map(c => ({ id: c.id, name: c.name }))} clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))} tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/> />
)} )}
<!-- Filters and Search -->
<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-5 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Search</span>
</label>
<input
type="text"
name="search"
placeholder="Search descriptions..."
class="input input-bordered"
value={searchTerm}
/>
</div> </div>
<div class="form-control"> <input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
<label class="label"> <div class="tab-content bg-base-100 border-base-300 p-6">
<span class="label-text font-medium">Client</span> {allClients.length === 0 ? (
</label> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<select name="client" class="select select-bordered" onchange="this.form.submit()"> <Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : (
<ManualEntry
client:idle
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
/>
)}
</div>
</div>
{allClients.length === 0 ? (
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
) : null}
<!-- Filters and Search -->
<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-6 gap-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Search</legend>
<input
type="text"
id="tracker-search"
name="search"
placeholder="Search descriptions..."
class="input w-full"
value={searchTerm}
/>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Client</legend>
<select id="tracker-client" name="client" class="select w-full" onchange="this.form.submit()">
<option value="">All Clients</option> <option value="">All Clients</option>
{allClients.map(client => ( {allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}> <option value={client.id} selected={filterClient === client.id}>
@@ -205,49 +214,40 @@ const paginationPages = getPaginationPages(page, totalPages);
</option> </option>
))} ))}
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Status</legend>
<span class="label-text font-medium">Category</span> <select id="tracker-status" name="status" class="select w-full" onchange="this.form.submit()">
</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={filterCategory === category.id}>
{category.name}
</option>
))}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select name="status" class="select select-bordered" onchange="this.form.submit()">
<option value="" selected={filterStatus === ''}>All Entries</option> <option value="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option> <option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option> <option value="running" selected={filterStatus === 'running'}>Running</option>
</select> </select>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Entry Type</legend>
<span class="label-text font-medium">Sort By</span> <select id="tracker-type" name="type" class="select w-full" onchange="this.form.submit()">
</label> <option value="" selected={filterType === ''}>All Types</option>
<select name="sort" class="select select-bordered" onchange="this.form.submit()"> <option value="timed" selected={filterType === 'timed'}>Timed</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Sort By</legend>
<select id="tracker-sort" name="sort" class="select w-full" onchange="this.form.submit()">
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option> <option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option> <option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option> <option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option> <option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select> </select>
</div> </fieldset>
<input type="hidden" name="page" value="1" /> <input type="hidden" name="page" value="1" />
<div class="form-control md:col-span-2 lg:col-span-5"> <div class="flex items-end md:col-span-2 lg:col-span-1">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary btn-sm w-full">
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" /> <Icon name="heroicons:magnifying-glass" class="w-4 h-4" />
Search Search
</button> </button>
</div> </div>
@@ -255,26 +255,26 @@ const paginationPages = getPaginationPages(page, totalPages);
</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"> <div class="card-body p-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-3">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:list-bullet" class="w-6 h-6" /> <Icon name="heroicons:list-bullet" class="w-4 h-4" />
Time Entries ({totalCount?.count || 0} total) Time Entries ({totalCount?.count || 0} total)
</h2> </h2>
{(filterClient || filterCategory || filterStatus || searchTerm) && ( {(filterClient || filterStatus || filterType || searchTerm) && (
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost"> <a href="/dashboard/tracker" class="btn btn-xs btn-ghost">
<Icon name="heroicons:x-mark" class="w-4 h-4" /> <Icon name="heroicons:x-mark" class="w-3 h-3" />
Clear Filters Clear Filters
</a> </a>
)} )}
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Type</th>
<th>Client</th> <th>Client</th>
<th>Category</th>
<th>Description</th> <th>Description</th>
<th>Member</th> <th>Member</th>
<th>Start Time</th> <th>Start Time</th>
@@ -284,22 +284,27 @@ const paginationPages = getPaginationPages(page, totalPages);
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{entries.map(({ entry, client, category, user: entryUser }) => ( {entries.map(({ entry, client, user: entryUser }) => (
<tr> <tr class="hover">
<td>{client?.name || 'Unknown'}</td>
<td> <td>
{category ? ( {entry.isManual ? (
<div class="flex items-center gap-2"> <span class="badge badge-info badge-xs gap-1" title="Manual Entry">
<span class="w-3 h-3 rounded-full" style={`background-color: ${category.color}`}></span> <Icon name="heroicons:pencil" class="w-3 h-3" />
<span>{category.name}</span> Manual
</div> </span>
) : '-'} ) : (
<span class="badge badge-success badge-xs gap-1" title="Timed Entry">
<Icon name="heroicons:clock" class="w-3 h-3" />
Timed
</span>
)}
</td> </td>
<td>{entry.description || '-'}</td> <td class="font-medium">{client?.name || 'Unknown'}</td>
<td class="text-base-content/60">{entry.description || '-'}</td>
<td>{entryUser?.name || 'Unknown'}</td> <td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap"> <td class="whitespace-nowrap">
{entry.startTime.toLocaleDateString()}<br/> {entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50"> <span class="text-xs text-base-content/40">
{entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</td> </td>
@@ -307,23 +312,23 @@ const paginationPages = getPaginationPages(page, totalPages);
{entry.endTime ? ( {entry.endTime ? (
<> <>
{entry.endTime.toLocaleDateString()}<br/> {entry.endTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50"> <span class="text-xs text-base-content/40">
{entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</> </>
) : ( ) : (
<span class="badge badge-success">Running</span> <span class="badge badge-success badge-xs">Running</span>
)} )}
</td> </td>
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td> <td class="font-mono font-semibold text-primary text-sm">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td> <td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline"> <form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button <button
type="submit" type="submit"
class="btn btn-ghost btn-sm text-error" class="btn btn-ghost btn-xs text-error"
onclick="return confirm('Are you sure you want to delete this entry?')" onclick="return confirm('Are you sure you want to delete this entry?')"
> >
<Icon name="heroicons:trash" class="w-4 h-4" /> <Icon name="heroicons:trash" class="w-3.5 h-3.5" />
</button> </button>
</form> </form>
</td> </td>
@@ -335,20 +340,20 @@ const paginationPages = getPaginationPages(page, totalPages);
<!-- Pagination --> <!-- Pagination -->
{totalPages > 1 && ( {totalPages > 1 && (
<div class="flex justify-center items-center gap-2 mt-6"> <div class="flex justify-center items-center gap-1 mt-4">
<a <a
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`} href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === 1 ? 'btn-disabled' : ''}`} class={`btn btn-xs ${page === 1 ? 'btn-disabled' : ''}`}
> >
<Icon name="heroicons:chevron-left" class="w-4 h-4" /> <Icon name="heroicons:chevron-left" class="w-3 h-3" />
Previous Prev
</a> </a>
<div class="flex gap-1"> <div class="flex gap-0.5">
{paginationPages.map(pageNum => ( {paginationPages.map(pageNum => (
<a <a
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`} href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === pageNum ? 'btn-active' : ''}`} class={`btn btn-xs ${page === pageNum ? 'btn-active' : ''}`}
> >
{pageNum} {pageNum}
</a> </a>
@@ -356,11 +361,11 @@ const paginationPages = getPaginationPages(page, totalPages);
</div> </div>
<a <a
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`} href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm ${page === totalPages ? 'btn-disabled' : ''}`} class={`btn btn-xs ${page === totalPages ? 'btn-disabled' : ''}`}
> >
Next Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" /> <Icon name="heroicons:chevron-right" class="w-3 h-3" />
</a> </a>
</div> </div>
)} )}

View File

@@ -7,48 +7,64 @@ if (Astro.locals.user) {
--- ---
<Layout title="Chronus - Time Tracking"> <Layout title="Chronus - Time Tracking">
<div class="hero h-full bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12"> <div class="flex-1 flex flex-col">
<div class="hero-content text-center"> <!-- Hero -->
<div class="max-w-4xl"> <div class="flex-1 flex items-center justify-center px-4 py-16 sm:py-24 bg-base-100">
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" /> <div class="max-w-3xl text-center">
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary"> <img src="/logo.webp" alt="Chronus Logo" class="h-20 w-20 mx-auto mb-8" />
Chronus <h1 class="text-5xl sm:text-6xl lg:text-7xl font-extrabold tracking-tight text-base-content mb-4">
Track time,<br />
<span class="text-primary">effortlessly.</span>
</h1> </h1>
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto"> <p class="text-lg sm:text-xl text-base-content/60 max-w-xl mx-auto mb-10 leading-relaxed">
Modern time tracking designed for teams that value simplicity and precision. Modern time tracking designed for teams that value simplicity and precision.
</p> </p>
<div class="flex gap-4 justify-center mt-8 flex-wrap"> <div class="flex gap-3 justify-center flex-wrap">
<a href="/signup" class="btn btn-primary btn-lg"> <a href="/signup" class="btn btn-primary btn-lg px-8">
Get Started Get Started
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
</a> </a>
<a href="/login" class="btn btn-outline btn-lg">Login</a> <a href="/login" class="btn btn-ghost btn-lg px-8">Login</a>
</div>
</div>
</div> </div>
<!-- Feature highlights --> <!-- Features -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16"> <div class="bg-base-200/50 border-t border-base-200 px-4 py-16 sm:py-20">
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> <div class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card-body items-start"> <div class="card bg-base-100 card-border">
<div class="text-4xl mb-3">⚡</div> <div class="card-body">
<h3 class="card-title text-lg">Lightning Fast</h3> <div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
<p class="text-sm text-base-content/70">Track tasks with a single click.</p> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd" />
</svg>
</div>
<h3 class="card-title text-base">Lightning Fast</h3>
<p class="text-sm text-base-content/60">Track tasks with a single click. Start, stop, and organize in seconds.</p>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> <div class="card bg-base-100 card-border">
<div class="card-body items-start"> <div class="card-body">
<div class="text-4xl mb-3">📊</div> <div class="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center mb-2">
<h3 class="card-title text-lg">Detailed Reports</h3> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor">
<p class="text-sm text-base-content/70">Get actionable insights into your team's tasks.</p> <path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z" />
</svg>
</div>
<h3 class="card-title text-base">Detailed Reports</h3>
<p class="text-sm text-base-content/60">Get actionable insights with charts, filters, and CSV exports.</p>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> <div class="card bg-base-100 card-border">
<div class="card-body items-start"> <div class="card-body">
<div class="text-4xl mb-3">👥</div> <div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center mb-2">
<h3 class="card-title text-lg">Team Collaboration</h3> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 20 20" fill="currentColor">
<p class="text-sm text-base-content/70">Built for multiple team members.</p> <path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
</svg>
</div> </div>
<h3 class="card-title text-base">Team Collaboration</h3>
<p class="text-sm text-base-content/60">Built for teams with roles, permissions, and shared workspaces.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,58 +1,75 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
if (Astro.locals.user) { if (Astro.locals.user) {
return Astro.redirect('/dashboard'); return Astro.redirect('/dashboard');
} }
const error = Astro.url.searchParams.get('error');
const errorMessage =
error === 'invalid_credentials'
? 'Invalid email or password'
: error === 'missing_fields'
? 'Please fill in all fields'
: null;
--- ---
<Layout title="Login - Chronus"> <Layout title="Login - Chronus">
<div class="flex justify-center items-center min-h-screen bg-base-100"> <div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4"> <div class="card card-border bg-base-100 w-full max-w-sm mx-4">
<div class="card-body"> <div class="card-body gap-0">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" /> <img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2> <h2 class="text-2xl font-extrabold tracking-tight text-center">Welcome Back</h2>
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p> <p class="text-center text-base-content/60 text-sm mt-1 mb-5">Sign in to continue to Chronus</p>
<form action="/api/auth/login" method="POST" class="space-y-4"> {errorMessage && (
<label class="form-control"> <div role="alert" class="alert alert-error mb-4 text-sm">
<div class="label"> <Icon name="heroicons:exclamation-circle" class="w-5 h-5" />
<span class="label-text font-medium">Email</span> <span>{errorMessage}</span>
</div> </div>
)}
<form action="/api/auth/login" method="POST" class="space-y-3">
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Email</legend>
<input <input
type="email" type="email"
id="email"
name="email" name="email"
placeholder="your@email.com" placeholder="your@email.com"
class="input input-bordered w-full" class="input w-full"
autocomplete="email"
required required
/> />
</label> </fieldset>
<label class="form-control"> <fieldset class="fieldset">
<div class="label"> <legend class="fieldset-legend text-xs">Password</legend>
<span class="label-text font-medium">Password</span>
</div>
<input <input
type="password" type="password"
id="password"
name="password" name="password"
placeholder="Enter your password" placeholder="Enter your password"
class="input input-bordered w-full" class="input w-full"
autocomplete="current-password"
required required
/> />
</label> </fieldset>
<button class="btn btn-primary w-full mt-6">Sign In</button> <button class="btn btn-primary w-full my-4">Sign In</button>
</form> </form>
<div class="divider">OR</div> <PasskeyLogin client:idle />
<div class="text-center"> <div class="divider text-xs">OR</div>
<p class="text-sm text-base-content/70">
<p class="text-center text-sm text-base-content/60">
Don't have an account? Don't have an account?
<a href="/signup" class="link link-primary font-semibold">Create one</a> <a href="/signup" class="link link-primary font-semibold">Create one</a>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div>
</Layout> </Layout>

View File

@@ -20,83 +20,96 @@ if (!isFirstUser) {
.get(); .get();
registrationDisabled = registrationSetting?.value !== 'true'; registrationDisabled = registrationSetting?.value !== 'true';
} }
const error = Astro.url.searchParams.get('error');
const errorMessage =
error === 'user_exists'
? 'An account with this email already exists'
: error === 'missing_fields'
? 'Please fill in all fields'
: error === 'registration_disabled'
? 'Registration is currently disabled'
: null;
--- ---
<Layout title="Sign Up - Chronus"> <Layout title="Sign Up - Chronus">
<div class="flex justify-center items-center min-h-screen bg-base-100"> <div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4"> <div class="card card-border bg-base-100 w-full max-w-sm mx-4">
<div class="card-body"> <div class="card-body gap-0">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" /> <img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2> <h2 class="text-2xl font-extrabold tracking-tight text-center">Create Account</h2>
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p> <p class="text-center text-base-content/60 text-sm mt-1 mb-5">Join Chronus to start tracking time</p>
{errorMessage && (
<div role="alert" class="alert alert-error mb-4 text-sm">
<Icon name="heroicons:exclamation-circle" class="w-5 h-5" />
<span>{errorMessage}</span>
</div>
)}
{registrationDisabled ? ( {registrationDisabled ? (
<> <>
<div class="alert alert-warning"> <div class="alert alert-warning text-sm">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" /> <Icon name="heroicons:exclamation-triangle" class="w-5 h-5" />
<span>Registration is currently disabled by the site administrator.</span> <span>Registration is currently disabled by the site administrator.</span>
</div> </div>
<div class="divider"></div> <div class="divider text-xs"></div>
<div class="text-center"> <p class="text-center text-sm text-base-content/60">
<p class="text-sm text-base-content/70">
Already have an account? Already have an account?
<a href="/login" class="link link-primary font-semibold">Sign in</a> <a href="/login" class="link link-primary font-semibold">Sign in</a>
</p> </p>
</div>
</> </>
) : ( ) : (
<> <>
<form action="/api/auth/signup" method="POST" class="space-y-4"> <form action="/api/auth/signup" method="POST" class="space-y-3">
<label class="form-control"> <fieldset class="fieldset">
<div class="label"> <legend class="fieldset-legend text-xs">Full Name</legend>
<span class="label-text font-medium">Full Name</span>
</div>
<input <input
type="text" type="text"
id="name"
name="name" name="name"
placeholder="John Doe" placeholder="John Doe"
class="input input-bordered w-full" class="input w-full"
autocomplete="name"
required required
/> />
</label> </fieldset>
<label class="form-control"> <fieldset class="fieldset">
<div class="label"> <legend class="fieldset-legend text-xs">Email</legend>
<span class="label-text font-medium">Email</span>
</div>
<input <input
type="email" type="email"
id="email"
name="email" name="email"
placeholder="your@email.com" placeholder="your@email.com"
class="input input-bordered w-full" class="input w-full"
autocomplete="email"
required required
/> />
</label> </fieldset>
<label class="form-control"> <fieldset class="fieldset">
<div class="label"> <legend class="fieldset-legend text-xs">Password</legend>
<span class="label-text font-medium">Password</span>
</div>
<input <input
type="password" type="password"
id="password"
name="password" name="password"
placeholder="Create a strong password" placeholder="Create a strong password"
class="input input-bordered w-full" class="input w-full"
autocomplete="new-password"
required required
/> />
</label> </fieldset>
<button class="btn btn-primary w-full mt-6">Create Account</button> <button class="btn btn-primary w-full mt-4">Create Account</button>
</form> </form>
<div class="divider">OR</div> <div class="divider text-xs">OR</div>
<div class="text-center"> <p class="text-center text-sm text-base-content/60">
<p class="text-sm text-base-content/70">
Already have an account? Already have an account?
<a href="/login" class="link link-primary font-semibold">Sign in</a> <a href="/login" class="link link-primary font-semibold">Sign in</a>
</p> </p>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -0,0 +1,68 @@
import type { APIRoute } from "astro";
import { promises as fs, constants } from "fs";
import path from "path";
export const GET: APIRoute = async ({ params }) => {
const filePathParam = params.path;
if (!filePathParam) {
return new Response("Not found", { status: 404 });
}
const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR
: import.meta.env.DATA_DIR;
if (!dataDir) {
return new Response("DATA_DIR environment variable is not set", {
status: 500,
});
}
const uploadDir = path.join(dataDir, "uploads");
const safePath = path.normalize(filePathParam).replace(/^(\.\.[\/\\])+/, "");
const fullPath = path.join(uploadDir, safePath);
if (!fullPath.startsWith(uploadDir)) {
return new Response("Forbidden", { status: 403 });
}
try {
await fs.access(fullPath, constants.R_OK);
const fileStats = await fs.stat(fullPath);
if (!fileStats.isFile()) {
return new Response("Not found", { status: 404 });
}
const fileContent = await fs.readFile(fullPath);
const ext = path.extname(fullPath).toLowerCase();
let contentType = "application/octet-stream";
switch (ext) {
case ".png":
contentType = "image/png";
break;
case ".jpg":
case ".jpeg":
contentType = "image/jpeg";
break;
case ".gif":
contentType = "image/gif";
break;
// SVG excluded to prevent stored XSS
// WebP omitted — not supported in PDF generation
}
return new Response(fileContent, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
return new Response("Not found", { status: 404 });
}
};

View File

@@ -0,0 +1,564 @@
import { h } from "vue";
import { Document, Page, Text, View, Image } from "@ceereals/vue-pdf";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import type { Style } from "@react-pdf/types";
interface InvoiceItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
interface Client {
name: string;
email: string | null;
street: string | null;
city: string | null;
state: string | null;
zip: string | null;
country: string | null;
}
interface Organization {
name: string;
street: string | null;
city: string | null;
state: string | null;
zip: string | null;
country: string | null;
logoUrl?: string | null;
}
interface Invoice {
number: string;
type: string;
status: string;
issueDate: Date;
dueDate: Date;
currency: string;
subtotal: number;
discountValue: number | null;
discountType: string | null;
discountAmount: number | null;
taxRate: number | null;
taxAmount: number;
total: number;
notes: string | null;
}
interface InvoiceDocumentProps {
invoice: Invoice;
items: InvoiceItem[];
client: Client;
organization: Organization;
}
const styles = {
page: {
padding: 60,
fontFamily: "Helvetica",
fontSize: 10,
lineHeight: 1.6,
color: "#1F2937",
backgroundColor: "#FFFFFF",
} as Style,
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 45,
paddingBottom: 24,
borderBottomWidth: 2,
borderBottomColor: "#E5E7EB",
} as Style,
headerLeft: {
flex: 1,
maxWidth: 280,
} as Style,
logo: {
height: 40,
marginBottom: 8,
objectFit: "contain",
objectPosition: "left",
} as Style,
headerRight: {
flex: 1,
alignItems: "flex-end",
} as Style,
organizationName: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 8,
color: "#1F2937",
letterSpacing: -0.5,
} as Style,
organizationAddress: {
fontSize: 9,
color: "#6B7280",
lineHeight: 1.5,
marginBottom: 12,
} as Style,
invoiceTypeContainer: {
alignItems: "flex-end",
marginBottom: 16,
} as Style,
invoiceType: {
fontSize: 36,
fontWeight: "normal",
color: "#9CA3AF",
textTransform: "uppercase",
letterSpacing: 6,
lineHeight: 1,
} as Style,
metaContainer: {
alignItems: "flex-end",
marginTop: 4,
} as Style,
metaRow: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
marginBottom: 5,
minWidth: 220,
} as Style,
metaLabel: {
color: "#6B7280",
fontSize: 9,
textTransform: "uppercase",
letterSpacing: 0.5,
marginRight: 12,
width: 70,
textAlign: "right",
} as Style,
metaValue: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
color: "#1F2937",
flex: 1,
textAlign: "right",
} as Style,
billToSection: {
marginBottom: 40,
} as Style,
sectionLabel: {
fontSize: 9,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1.5,
color: "#9CA3AF",
marginBottom: 12,
} as Style,
clientName: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 4,
color: "#1F2937",
} as Style,
clientEmail: {
fontSize: 10,
color: "#6B7280",
} as Style,
clientAddress: {
fontSize: 10,
color: "#6B7280",
lineHeight: 1.5,
} as Style,
table: {
marginBottom: 40,
} as Style,
tableHeader: {
flexDirection: "row",
backgroundColor: "#F9FAFB",
paddingVertical: 12,
paddingHorizontal: 16,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
} as Style,
tableHeaderCell: {
fontSize: 9,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
color: "#6B7280",
} as Style,
tableRow: {
flexDirection: "row",
paddingVertical: 16,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: "#F3F4F6",
} as Style,
tableCell: {
fontSize: 10,
color: "#1F2937",
} as Style,
colDescription: {
flex: 3,
paddingRight: 16,
} as Style,
colQty: {
width: 60,
textAlign: "center",
} as Style,
colPrice: {
width: 90,
textAlign: "right",
paddingRight: 16,
} as Style,
colAmount: {
width: 100,
textAlign: "right",
fontFamily: "Helvetica-Bold",
} as Style,
totalsSection: {
flexDirection: "row",
justifyContent: "flex-end",
marginTop: 20,
marginBottom: 50,
} as Style,
totalsBox: {
width: 280,
backgroundColor: "#F9FAFB",
padding: 20,
borderRadius: 8,
} as Style,
totalRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 10,
fontSize: 10,
} as Style,
totalLabel: {
color: "#6B7280",
fontSize: 10,
} as Style,
totalValue: {
fontFamily: "Helvetica-Bold",
color: "#1F2937",
fontSize: 10,
} as Style,
divider: {
borderBottomWidth: 2,
borderBottomColor: "#E5E7EB",
marginVertical: 12,
} as Style,
grandTotalRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingTop: 8,
} as Style,
grandTotalLabel: {
fontSize: 16,
fontWeight: "bold",
color: "#1F2937",
} as Style,
grandTotalValue: {
fontSize: 18,
fontWeight: "bold",
color: "#2563EB",
} as Style,
notesSection: {
marginTop: 30,
paddingTop: 30,
borderTopWidth: 2,
borderTopColor: "#E5E7EB",
} as Style,
notesText: {
fontSize: 9,
color: "#6B7280",
lineHeight: 1.6,
whiteSpace: "pre-wrap",
} as Style,
};
export function createInvoiceDocument(props: InvoiceDocumentProps) {
const { invoice, items, client, organization } = props;
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: invoice.currency,
}).format(amount / 100);
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
return h(Document, [
h(
Page,
{ size: "A4", style: styles.page },
[
// Header
h(View, { style: styles.header }, [
h(View, { style: styles.headerLeft }, [
(() => {
if (organization.logoUrl) {
try {
let logoPath;
// Handle uploads directory which might be external to public/
if (organization.logoUrl.startsWith("/uploads/")) {
const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR
: import.meta.env.DATA_DIR;
if (!dataDir) {
throw new Error(
"DATA_DIR environment variable is not set",
);
}
const uploadDir = join(dataDir, "uploads");
const filename = organization.logoUrl.replace(
"/uploads/",
"",
);
logoPath = join(uploadDir, filename);
} else {
logoPath = join(
process.cwd(),
"public",
organization.logoUrl,
);
}
if (existsSync(logoPath)) {
const ext = logoPath.split(".").pop()?.toLowerCase();
if (ext === "png" || ext === "jpg" || ext === "jpeg") {
return h(Image, {
src: {
data: readFileSync(logoPath),
format: ext === "png" ? "png" : "jpg",
},
style: styles.logo,
});
}
}
} catch (e) {
// Ignore errors
}
}
return null;
})(),
h(Text, { style: styles.organizationName }, organization.name),
organization.street || organization.city
? h(
View,
{ style: styles.organizationAddress },
[
organization.street ? h(Text, organization.street) : null,
organization.city || organization.state || organization.zip
? h(
Text,
[
organization.city,
organization.state,
organization.zip,
]
.filter(Boolean)
.join(", "),
)
: null,
organization.country ? h(Text, organization.country) : null,
].filter(Boolean),
)
: null,
]),
h(View, { style: styles.headerRight }, [
h(View, { style: styles.invoiceTypeContainer }, [
h(Text, { style: styles.invoiceType }, invoice.type),
]),
h(View, { style: styles.metaContainer }, [
h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Number"),
h(Text, { style: styles.metaValue }, invoice.number),
]),
h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Date"),
h(
Text,
{ style: styles.metaValue },
formatDate(invoice.issueDate),
),
]),
invoice.type !== "quote"
? h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Due Date"),
h(
Text,
{ style: styles.metaValue },
formatDate(invoice.dueDate),
),
])
: null,
]),
]),
]),
// Bill To
h(
View,
{ style: styles.billToSection },
[
h(Text, { style: styles.sectionLabel }, "Bill To"),
h(Text, { style: styles.clientName }, client.name),
client.street ||
client.city ||
client.state ||
client.zip ||
client.country
? h(
View,
{ style: styles.clientAddress },
[
client.street ? h(Text, client.street) : null,
client.city || client.state || client.zip
? h(
Text,
[client.city, client.state, client.zip]
.filter(Boolean)
.join(", "),
)
: null,
client.country ? h(Text, client.country) : null,
].filter(Boolean),
)
: null,
client.email
? h(Text, { style: styles.clientEmail }, client.email)
: null,
].filter(Boolean),
),
// Items Table
h(View, { style: styles.table }, [
h(View, { style: styles.tableHeader }, [
h(
Text,
{
style: { ...styles.tableHeaderCell, ...styles.colDescription },
},
"Description",
),
h(
Text,
{ style: { ...styles.tableHeaderCell, ...styles.colQty } },
"Qty",
),
h(
Text,
{ style: { ...styles.tableHeaderCell, ...styles.colPrice } },
"Unit Price",
),
h(
Text,
{ style: { ...styles.tableHeaderCell, ...styles.colAmount } },
"Amount",
),
]),
...items.map((item) =>
h(View, { key: item.id, style: styles.tableRow }, [
h(
Text,
{ style: { ...styles.tableCell, ...styles.colDescription } },
item.description,
),
h(
Text,
{ style: { ...styles.tableCell, ...styles.colQty } },
item.quantity.toString(),
),
h(
Text,
{ style: { ...styles.tableCell, ...styles.colPrice } },
formatCurrency(item.unitPrice),
),
h(
Text,
{ style: { ...styles.tableCell, ...styles.colAmount } },
formatCurrency(item.amount),
),
]),
),
]),
// Totals
h(View, { style: styles.totalsSection }, [
h(
View,
{ style: styles.totalsBox },
[
h(View, { style: styles.totalRow }, [
h(Text, { style: styles.totalLabel }, "Subtotal"),
h(
Text,
{ style: styles.totalValue },
formatCurrency(invoice.subtotal),
),
]),
(invoice.discountAmount ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(
Text,
{ style: styles.totalLabel },
`Discount${
invoice.discountType === "percentage"
? ` (${invoice.discountValue}%)`
: ""
}`,
),
h(
Text,
{ style: styles.totalValue },
`-${formatCurrency(invoice.discountAmount ?? 0)}`,
),
])
: null,
(invoice.taxRate ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(
Text,
{ style: styles.totalLabel },
`Tax (${invoice.taxRate}%)`,
),
h(
Text,
{ style: styles.totalValue },
formatCurrency(invoice.taxAmount),
),
])
: null,
h(View, { style: styles.divider }),
h(View, { style: styles.grandTotalRow }, [
h(Text, { style: styles.grandTotalLabel }, "Total"),
h(
Text,
{ style: styles.grandTotalValue },
formatCurrency(invoice.total),
),
]),
].filter(Boolean),
),
]),
// Notes
invoice.notes
? h(View, { style: styles.notesSection }, [
h(Text, { style: styles.sectionLabel }, "Notes"),
h(Text, { style: styles.notesText }, invoice.notes),
])
: null,
].filter(Boolean),
),
]);
}

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