Compare commits
45 Commits
38fe0ea9ce
...
2.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
caf763aa1e
|
|||
|
12d59bb42f
|
|||
|
c39865031a
|
|||
|
abbf39f160
|
|||
|
e2949a28ef
|
|||
|
8b91ec7a71
|
|||
|
815c08dd50
|
|||
|
55eb03165e
|
|||
|
a4071d6e40
|
|||
|
fff0e14a4b
|
|||
|
ad7dc18780
|
|||
|
de5b1063b7
|
|||
|
82b45fdfe4
|
|||
|
b5ac2e0608
|
|||
|
6bed4b4709
|
|||
|
54cac49b70
|
|||
|
effc6ac37e
|
|||
|
df82a02f41
|
|||
|
8a3932a013
|
|||
|
d4a2c5853b
|
|||
|
ee9807e8e0
|
|||
|
bf2a1816db
|
|||
|
1063bf99f1
|
|||
|
ea0a83f44d
|
|||
|
fa2c92644a
|
|||
|
3d4b8762e5
|
|||
|
5e70dd6bb8
|
|||
|
ce47de9e56
|
|||
|
db1d180afc
|
|||
|
82e1b8a626
|
|||
|
253c24c89b
|
|||
|
39c51b1115
|
|||
|
091766d6e4
|
|||
|
0cd77677f2
|
|||
|
3734b2693a
|
|||
|
996092d14e
|
|||
|
aae8693dd3
|
|||
|
bebc4b2743
|
|||
|
7026435cd3
|
|||
|
85750a5c79
|
|||
|
6aa4548a38
|
|||
|
42fbea6ae7
|
|||
|
c4ecc0b899
|
|||
|
5aa9388678
|
|||
|
15b903f1af
|
@@ -1,3 +1,4 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=4321
|
||||
DATABASE_URL=chronus.db
|
||||
DATA_DIR=./data
|
||||
ROOT_DIR=./data
|
||||
APP_PORT=4321
|
||||
IMAGE=git.atri.dad/atash/chronus:latest
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# build output
|
||||
dist/
|
||||
data/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -1,31 +1,35 @@
|
||||
FROM node:lts-alpine AS builder
|
||||
FROM node:lts-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm i -g pnpm
|
||||
|
||||
FROM base AS prod-deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
RUN pnpm install
|
||||
FROM base AS build-deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM build-deps AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:lts-alpine AS runtime
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm i -g pnpm
|
||||
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN pnpm install --prod
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY package.json ./
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV DATABASE_URL=/app/data/chronus.db
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"]
|
||||
|
||||
10
README.md
10
README.md
@@ -1,2 +1,10 @@
|
||||
# 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
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import vue from '@astrojs/vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import icon from 'astro-icon';
|
||||
import { defineConfig } from "astro/config";
|
||||
import vue from "@astrojs/vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import icon from "astro-icon";
|
||||
|
||||
import node from '@astrojs/node';
|
||||
import node from "@astrojs/node";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
output: "server",
|
||||
integrations: [vue(), icon()],
|
||||
|
||||
security: {
|
||||
csp: process.env.NODE_ENV === "production",
|
||||
},
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
ssr: {
|
||||
external: ['better-sqlite3'],
|
||||
},
|
||||
},
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
mode: "standalone",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- DATABASE_URL=/app/data/chronus.db
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- ${ROOT_DIR}:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
throw new Error("DATA_DIR environment variable is not set");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbUrl = `file:${path.join(dataDir, "chronus.db")}`;
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'sqlite',
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "turso",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'chronus.db',
|
||||
url: dbUrl,
|
||||
},
|
||||
});
|
||||
|
||||
181
drizzle/0000_lazy_rictor.sql
Normal file
181
drizzle/0000_lazy_rictor.sql
Normal 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`);
|
||||
3
drizzle/0001_demonic_red_skull.sql
Normal file
3
drizzle/0001_demonic_red_skull.sql
Normal 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`);
|
||||
1266
drizzle/meta/0000_snapshot.json
Normal file
1266
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1219
drizzle/meta/0001_snapshot.json
Normal file
1219
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
20
drizzle/meta/_journal.json
Normal file
20
drizzle/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
28
package.json
28
package.json
@@ -1,37 +1,43 @@
|
||||
{
|
||||
"name": "chronus",
|
||||
"type": "module",
|
||||
"version": "1.2.0",
|
||||
"version": "2.4.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"migrate": "node scripts/migrate.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.6",
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@astrojs/vue": "^5.1.4",
|
||||
"@astrojs/check": "0.9.6",
|
||||
"@astrojs/node": "10.0.0-beta.2",
|
||||
"@astrojs/vue": "6.0.0-beta.0",
|
||||
"@ceereals/vue-pdf": "^0.2.1",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@simplewebauthn/server": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.16.11",
|
||||
"astro": "6.0.0-beta.9",
|
||||
"astro-icon": "^1.1.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"daisyui": "^5.5.14",
|
||||
"daisyui": "^5.5.18",
|
||||
"dotenv": "^17.2.4",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.26",
|
||||
"vue": "^3.5.28",
|
||||
"vue-chartjs": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/daisyui": "^2.1.1",
|
||||
"@iconify-json/heroicons": "^1.2.3",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"drizzle-kit": "0.31.8"
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"drizzle-kit": "0.31.9"
|
||||
}
|
||||
}
|
||||
|
||||
2839
pnpm-lock.yaml
generated
2839
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
40
scripts/migrate.js
Normal file
40
scripts/migrate.js
Normal 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();
|
||||
15
src/components/Avatar.astro
Normal file
15
src/components/Avatar.astro
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div style="position: relative; height: 100%; width: 100%;">
|
||||
<div style="position: relative; height: 100%; width: 100%">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import { computed } from "vue";
|
||||
import { Bar } from "vue-chartjs";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
@@ -14,10 +14,18 @@ import {
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController
|
||||
} from 'chart.js';
|
||||
BarController,
|
||||
type ChartOptions,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
||||
ChartJS.register(
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController,
|
||||
);
|
||||
|
||||
interface ClientData {
|
||||
name: string;
|
||||
@@ -29,57 +37,61 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.clients.map(c => c.name),
|
||||
datasets: [{
|
||||
label: 'Time Tracked',
|
||||
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: '#6366f1',
|
||||
borderColor: '#4f46e5',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
labels: props.clients.map((c) => c.name),
|
||||
datasets: [
|
||||
{
|
||||
label: "Time Tracked",
|
||||
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: "#6366f1",
|
||||
borderColor: "#4f46e5",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = {
|
||||
const chartOptions: ChartOptions<"bar"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#e2e8f0',
|
||||
callback: function(value: number) {
|
||||
const hours = Math.floor(value / 60);
|
||||
const mins = value % 60;
|
||||
color: "#e2e8f0",
|
||||
callback: function (value: string | number) {
|
||||
const numValue =
|
||||
typeof value === "string" ? parseFloat(value) : value;
|
||||
const hours = Math.floor(numValue / 60);
|
||||
const mins = Math.round(numValue % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
color: "#334155",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#e2e8f0'
|
||||
color: "#e2e8f0",
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const minutes = Math.round(context.raw);
|
||||
label: function (context) {
|
||||
const minutes = Math.round(context.raw as number);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return ` ${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
327
src/components/ManualEntry.vue
Normal file
327
src/components/ManualEntry.vue
Normal 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>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div style="position: relative; height: 100%; width: 100%;">
|
||||
<div style="position: relative; height: 100%; width: 100%">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import { computed } from "vue";
|
||||
import { Bar } from "vue-chartjs";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
@@ -14,10 +14,18 @@ import {
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController
|
||||
} from 'chart.js';
|
||||
BarController,
|
||||
type ChartOptions,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
||||
ChartJS.register(
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController,
|
||||
);
|
||||
|
||||
interface MemberData {
|
||||
name: string;
|
||||
@@ -29,58 +37,62 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.members.map(m => m.name),
|
||||
datasets: [{
|
||||
label: 'Time Tracked',
|
||||
data: props.members.map(m => m.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: '#10b981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
labels: props.members.map((m) => m.name),
|
||||
datasets: [
|
||||
{
|
||||
label: "Time Tracked",
|
||||
data: props.members.map((m) => m.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: "#10b981",
|
||||
borderColor: "#059669",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = {
|
||||
indexAxis: 'y' as const,
|
||||
const chartOptions: ChartOptions<"bar"> = {
|
||||
indexAxis: "y" as const,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#e2e8f0',
|
||||
callback: function(value: number) {
|
||||
const hours = Math.floor(value / 60);
|
||||
const mins = value % 60;
|
||||
color: "#e2e8f0",
|
||||
callback: function (value: string | number) {
|
||||
const numValue =
|
||||
typeof value === "string" ? parseFloat(value) : value;
|
||||
const hours = Math.floor(numValue / 60);
|
||||
const mins = Math.round(numValue % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
color: "#334155",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: '#e2e8f0'
|
||||
color: "#e2e8f0",
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const minutes = Math.round(context.raw);
|
||||
label: function (context) {
|
||||
const minutes = Math.round(context.raw as number);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return ` ${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
29
src/components/StatCard.astro
Normal file
29
src/components/StatCard.astro
Normal 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>
|
||||
67
src/components/TagChart.vue
Normal file
67
src/components/TagChart.vue
Normal 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>
|
||||
34
src/components/ThemeToggle.vue
Normal file
34
src/components/ThemeToggle.vue
Normal 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>
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
initialRunningEntry: {
|
||||
startTime: number;
|
||||
description: string | null;
|
||||
clientId: string;
|
||||
categoryId: string;
|
||||
tagId?: string;
|
||||
} | null;
|
||||
clients: { id: string; name: string }[];
|
||||
categories: { id: string; name: string; color: string | null }[];
|
||||
tags: { id: string; name: string; color: string | null }[];
|
||||
}>();
|
||||
|
||||
@@ -18,8 +18,7 @@ const startTime = ref<number | null>(null);
|
||||
const elapsedTime = ref(0);
|
||||
const description = ref("");
|
||||
const selectedClientId = ref("");
|
||||
const selectedCategoryId = ref("");
|
||||
const selectedTags = ref<string[]>([]);
|
||||
const selectedTagId = ref<string | null>(null);
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function formatTime(ms: number) {
|
||||
@@ -30,7 +29,6 @@ function formatTime(ms: number) {
|
||||
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
|
||||
// Calculate rounded version
|
||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||
const roundedHours = Math.floor(totalMinutes / 60);
|
||||
const roundedMinutes = totalMinutes % 60;
|
||||
@@ -49,11 +47,10 @@ function formatTime(ms: number) {
|
||||
}
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const index = selectedTags.value.indexOf(tagId);
|
||||
if (index > -1) {
|
||||
selectedTags.value.splice(index, 1);
|
||||
if (selectedTagId.value === tagId) {
|
||||
selectedTagId.value = null;
|
||||
} else {
|
||||
selectedTags.value.push(tagId);
|
||||
selectedTagId.value = tagId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +60,7 @@ onMounted(() => {
|
||||
startTime.value = props.initialRunningEntry.startTime;
|
||||
description.value = props.initialRunningEntry.description || "";
|
||||
selectedClientId.value = props.initialRunningEntry.clientId;
|
||||
selectedCategoryId.value = props.initialRunningEntry.categoryId;
|
||||
selectedTagId.value = props.initialRunningEntry.tagId || null;
|
||||
elapsedTime.value = Date.now() - startTime.value;
|
||||
interval = setInterval(() => {
|
||||
elapsedTime.value = Date.now() - startTime.value!;
|
||||
@@ -81,19 +78,13 @@ async function startTimer() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedCategoryId.value) {
|
||||
alert("Please select a category");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/time-entries/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
description: description.value,
|
||||
clientId: selectedClientId.value,
|
||||
categoryId: selectedCategoryId.value,
|
||||
tags: selectedTags.value,
|
||||
tagId: selectedTagId.value,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -119,86 +110,63 @@ async function stopTimer() {
|
||||
startTime.value = null;
|
||||
description.value = "";
|
||||
selectedClientId.value = "";
|
||||
selectedCategoryId.value = "";
|
||||
selectedTags.value = [];
|
||||
selectedTagId.value = null;
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<!-- Client and Description Row -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedClientId"
|
||||
class="select select-bordered w-full"
|
||||
:disabled="isRunning"
|
||||
>
|
||||
<option value="">Select a client...</option>
|
||||
<option
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
>
|
||||
{{ client.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedCategoryId"
|
||||
class="select select-bordered w-full"
|
||||
:disabled="isRunning"
|
||||
>
|
||||
<option value="">Select a category...</option>
|
||||
<option
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
:value="category.id"
|
||||
>
|
||||
{{ category.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Client Row -->
|
||||
<div class="form-control">
|
||||
<label class="label pb-2 font-medium" for="timer-client">
|
||||
Client
|
||||
</label>
|
||||
<select
|
||||
id="timer-client"
|
||||
v-model="selectedClientId"
|
||||
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
||||
:disabled="isRunning"
|
||||
>
|
||||
<option value="">Select a client...</option>
|
||||
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||
{{ client.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Description Row -->
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
<label class="label pb-2 font-medium" for="timer-description">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
id="timer-description"
|
||||
v-model="description"
|
||||
type="text"
|
||||
placeholder="What are you working on?"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags Section -->
|
||||
<div v-if="tags.length > 0" class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Tags</span>
|
||||
</label>
|
||||
<label class="label pb-2 font-medium" for="timer-tags"> Tags </label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
@click="toggleTag(tag.id)"
|
||||
:class="[
|
||||
'badge badge-lg cursor-pointer transition-all',
|
||||
selectedTags.includes(tag.id) ? 'badge-primary' : 'badge-outline',
|
||||
'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="isRunning"
|
||||
type="button"
|
||||
@@ -211,19 +179,25 @@ async function stopTimer() {
|
||||
<!-- Timer and Action Row -->
|
||||
<div class="flex flex-col sm:flex-row items-center gap-6 pt-4">
|
||||
<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) }}
|
||||
</div>
|
||||
<button
|
||||
v-if="!isRunning"
|
||||
@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 v-else @click="stopTimer" class="btn btn-error btn-lg min-w-40">
|
||||
⏹️ Stop Timer
|
||||
<button
|
||||
v-else
|
||||
@click="stopTimer"
|
||||
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
|
||||
>
|
||||
<Icon icon="heroicons:stop" class="w-5 h-5" />
|
||||
Stop Timer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
72
src/components/auth/PasskeyLogin.vue
Normal file
72
src/components/auth/PasskeyLogin.vue
Normal 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>
|
||||
257
src/components/settings/ApiTokenManager.vue
Normal file
257
src/components/settings/ApiTokenManager.vue
Normal 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>
|
||||
169
src/components/settings/PasskeyManager.vue
Normal file
169
src/components/settings/PasskeyManager.vue
Normal 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>
|
||||
174
src/components/settings/PasswordForm.vue
Normal file
174
src/components/settings/PasswordForm.vue
Normal 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>
|
||||
139
src/components/settings/ProfileForm.vue
Normal file
139
src/components/settings/ProfileForm.vue
Normal 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>
|
||||
@@ -1,22 +1,41 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
import path from 'path';
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { createClient } from "@libsql/client";
|
||||
import * as schema from "./schema";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
function initDb() {
|
||||
let _db: Database | null = null;
|
||||
|
||||
function initDb(): Database {
|
||||
if (!_db) {
|
||||
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'chronus.db');
|
||||
const sqlite = new Database(dbUrl, { readonly: false });
|
||||
_db = drizzle(sqlite, { schema });
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: import.meta.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
throw new Error("DATA_DIR environment variable is not set");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const url = `file:${path.join(dataDir, "chronus.db")}`;
|
||||
|
||||
const client = createClient({
|
||||
url,
|
||||
});
|
||||
|
||||
_db = drizzle(client, { schema });
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
export const db = new Proxy({} as Database, {
|
||||
get(_target, prop) {
|
||||
const database = initDb();
|
||||
return database[prop as keyof typeof database];
|
||||
}
|
||||
return database[prop as keyof Database];
|
||||
},
|
||||
});
|
||||
|
||||
181
src/db/schema.ts
181
src/db/schema.ts
@@ -2,8 +2,10 @@ import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
real,
|
||||
primaryKey,
|
||||
foreignKey,
|
||||
index,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -25,6 +27,14 @@ export const organizations = sqliteTable("organizations", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
logoUrl: text("logo_url"),
|
||||
street: text("street"),
|
||||
city: text("city"),
|
||||
state: text("state"),
|
||||
zip: text("zip"),
|
||||
country: text("country"),
|
||||
defaultTaxRate: real("default_tax_rate").default(0),
|
||||
defaultCurrency: text("default_currency").default("USD"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -50,6 +60,10 @@ export const members = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
userIdIdx: index("members_user_id_idx").on(table.userId),
|
||||
organizationIdIdx: index("members_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -62,6 +76,12 @@ export const clients = sqliteTable(
|
||||
organizationId: text("organization_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
street: text("street"),
|
||||
city: text("city"),
|
||||
state: text("state"),
|
||||
zip: text("zip"),
|
||||
country: text("country"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -71,11 +91,14 @@ export const clients = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
organizationIdIdx: index("clients_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const categories = sqliteTable(
|
||||
"categories",
|
||||
export const tags = sqliteTable(
|
||||
"tags",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
@@ -83,6 +106,7 @@ export const categories = sqliteTable(
|
||||
organizationId: text("organization_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
rate: integer("rate").default(0),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -92,6 +116,9 @@ export const categories = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
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(),
|
||||
organizationId: text("organization_id").notNull(),
|
||||
clientId: text("client_id").notNull(),
|
||||
categoryId: text("category_id").notNull(),
|
||||
tagId: text("tag_id"),
|
||||
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
||||
endTime: integer("end_time", { mode: "timestamp" }),
|
||||
description: text("description"),
|
||||
invoiceId: text("invoice_id"),
|
||||
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -125,50 +154,18 @@ export const timeEntries = sqliteTable(
|
||||
columns: [table.clientId],
|
||||
foreignColumns: [clients.id],
|
||||
}),
|
||||
categoryFk: foreignKey({
|
||||
columns: [table.categoryId],
|
||||
foreignColumns: [categories.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const tags = sqliteTable(
|
||||
"tags",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organization_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
},
|
||||
(table: any) => ({
|
||||
orgFk: foreignKey({
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const timeEntryTags = sqliteTable(
|
||||
"time_entry_tags",
|
||||
{
|
||||
timeEntryId: text("time_entry_id").notNull(),
|
||||
tagId: text("tag_id").notNull(),
|
||||
},
|
||||
(table: any) => ({
|
||||
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
|
||||
timeEntryFk: foreignKey({
|
||||
columns: [table.timeEntryId],
|
||||
foreignColumns: [timeEntries.id],
|
||||
}),
|
||||
tagFk: foreignKey({
|
||||
columns: [table.tagId],
|
||||
foreignColumns: [tags.id],
|
||||
}),
|
||||
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
||||
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
|
||||
tagIdIdx: index("time_entries_tag_id_idx").on(table.tagId),
|
||||
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
|
||||
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -184,6 +181,7 @@ export const sessions = sqliteTable(
|
||||
columns: [table.userId],
|
||||
foreignColumns: [users.id],
|
||||
}),
|
||||
userIdIdx: index("sessions_user_id_idx").on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -218,5 +216,102 @@ export const apiTokens = sqliteTable(
|
||||
columns: [table.userId],
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../db';
|
||||
import { members, organizations } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Avatar from '../components/Avatar.astro';
|
||||
import ThemeToggle from '../components/ThemeToggle.vue';
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -17,7 +19,6 @@ if (!user) {
|
||||
return Astro.redirect('/login');
|
||||
}
|
||||
|
||||
// Get user's team memberships
|
||||
const userMemberships = await db.select({
|
||||
membership: members,
|
||||
organization: organizations,
|
||||
@@ -27,13 +28,26 @@ const userMemberships = await db.select({
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
// Get current team from cookie or use first membership
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
||||
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
||||
|
||||
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>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="Chronus Dashboard" />
|
||||
@@ -41,133 +55,148 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
<ClientRouter />
|
||||
<script is:inline>
|
||||
const theme = localStorage.getItem('theme') || 'macchiato';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
</script>
|
||||
</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">
|
||||
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col h-full overflow-auto">
|
||||
<!-- Navbar -->
|
||||
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-300">
|
||||
<div class="flex-none lg:hidden">
|
||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
||||
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
||||
<!-- Mobile Navbar -->
|
||||
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-200">
|
||||
<div class="flex-none">
|
||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm">
|
||||
<Icon name="heroicons:bars-3" class="w-5 h-5" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 px-2 flex items-center gap-2">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||
<img src="/logo.webp" alt="Chronus" class="h-7 w-7" />
|
||||
<span class="text-lg font-bold text-primary">Chronus</span>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ThemeToggle client:load />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page content here -->
|
||||
<main class="p-6 md:p-8">
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-50">
|
||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<ul class="menu bg-base-200 min-h-full w-80 p-4">
|
||||
<!-- Sidebar content here -->
|
||||
<li class="mb-6">
|
||||
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||
Chronus
|
||||
<aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-300/40">
|
||||
<!-- Logo -->
|
||||
<div class="px-5 pt-5 pb-3">
|
||||
<a href="/dashboard" class="flex items-center gap-2.5 group">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
|
||||
{/* Team Switcher */}
|
||||
<!-- Team Switcher -->
|
||||
{userMemberships.length > 0 && (
|
||||
<li class="mb-4">
|
||||
<div class="form-control">
|
||||
<select
|
||||
class="select select-bordered w-full font-semibold"
|
||||
id="team-switcher"
|
||||
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
||||
>
|
||||
{userMemberships.map(({ membership, organization }) => (
|
||||
<option
|
||||
value={organization.id}
|
||||
selected={organization.id === currentTeamId}
|
||||
>
|
||||
{organization.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</li>
|
||||
<div class="px-4 pb-2">
|
||||
<select
|
||||
class="select select-sm w-full bg-base-300/40 border-base-300/60 focus:border-primary/50 focus:outline-none text-sm font-medium"
|
||||
id="team-switcher"
|
||||
aria-label="Switch team"
|
||||
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
||||
>
|
||||
{userMemberships.map(({ membership, organization }) => (
|
||||
<option
|
||||
value={organization.id}
|
||||
selected={organization.id === currentTeamId}
|
||||
>
|
||||
{organization.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userMemberships.length === 0 && (
|
||||
<li class="mb-4">
|
||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||
<div class="px-4 pb-2">
|
||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm btn-block">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Create Team
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
<div class="divider my-1 mx-4"></div>
|
||||
|
||||
<li><a href="/dashboard">
|
||||
<Icon name="heroicons:home" class="w-5 h-5" />
|
||||
Dashboard
|
||||
</a></li>
|
||||
<li><a href="/dashboard/tracker">
|
||||
<Icon name="heroicons:clock" class="w-5 h-5" />
|
||||
Time Tracker
|
||||
</a></li>
|
||||
<li><a href="/dashboard/reports">
|
||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||
Reports
|
||||
</a></li>
|
||||
<li><a href="/dashboard/clients">
|
||||
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
||||
Clients
|
||||
</a></li>
|
||||
<li><a href="/dashboard/team">
|
||||
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
||||
Team
|
||||
</a></li>
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 px-3">
|
||||
<ul class="menu menu-sm gap-0.5 p-0">
|
||||
{navItems.map(item => (
|
||||
<li>
|
||||
<a href={item.href} class:list={[
|
||||
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
|
||||
isActive(item)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-base-content/70 hover:text-base-content hover:bg-base-300/50"
|
||||
]}>
|
||||
<Icon name={item.icon} class="w-[18px] h-[18px]" />
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{user.isSiteAdmin && (
|
||||
<>
|
||||
<div class="divider"></div>
|
||||
<li><a href="/admin" class="font-semibold">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||
Site Admin
|
||||
</a></li>
|
||||
</>
|
||||
)}
|
||||
{user.isSiteAdmin && (
|
||||
<>
|
||||
<div class="divider my-1"></div>
|
||||
<ul class="menu menu-sm p-0">
|
||||
<li>
|
||||
<a href="/admin" class:list={[
|
||||
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
|
||||
Astro.url.pathname.startsWith("/admin")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-base-content/70 hover:text-base-content hover:bg-base-300/50"
|
||||
]}>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-[18px] h-[18px]" />
|
||||
Site Admin
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<li>
|
||||
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-100 hover:bg-base-300 rounded-lg p-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-linear-to-br from-primary via-secondary to-accent text-primary-content rounded-full w-10 ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||
<span class="text-sm font-bold">{user.name.charAt(0).toUpperCase()}</span>
|
||||
<!-- 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>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm truncate">{user.name}</div>
|
||||
<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>
|
||||
</li>
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4 text-base-content/30 group-hover:text-base-content/50" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<form action="/api/auth/logout" method="POST">
|
||||
<button type="submit" class="w-full text-error hover:bg-error/10">
|
||||
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
<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">
|
||||
<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-[18px] h-[18px]" />
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -10,7 +10,7 @@ const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="Chronus Time Tracking" />
|
||||
@@ -18,11 +18,15 @@ const { title } = Astro.props;
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
<ClientRouter />
|
||||
<script is:inline>
|
||||
const theme = localStorage.getItem('theme') || 'macchiato';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
|
||||
<div class="flex-1 overflow-auto">
|
||||
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last used at
|
||||
await db
|
||||
.update(apiTokens)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
|
||||
@@ -4,26 +4,14 @@
|
||||
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
// Calculate rounded version for easy reading
|
||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||
const roundedHours = Math.floor(totalMinutes / 60);
|
||||
const roundedMinutes = totalMinutes % 60;
|
||||
|
||||
let roundedStr = '';
|
||||
if (roundedHours > 0) {
|
||||
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`;
|
||||
} else {
|
||||
roundedStr = `${roundedMinutes}m`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
return `${timeStr} (${roundedStr})`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +21,20 @@ export function formatDuration(ms: number): string {
|
||||
* @returns Formatted duration string or "Running..."
|
||||
*/
|
||||
export function formatTimeRange(start: Date, end: Date | null): string {
|
||||
if (!end) return 'Running...';
|
||||
if (!end) return "Running...";
|
||||
const ms = end.getTime() - start.getTime();
|
||||
return formatDuration(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
24
src/lib/getCurrentTeam.ts
Normal 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
92
src/lib/validation.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import StatCard from '../../components/StatCard.astro';
|
||||
import { db } from '../../db';
|
||||
import { siteSettings, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -20,52 +22,52 @@ const allUsers = await db.select().from(users).all();
|
||||
---
|
||||
|
||||
<DashboardLayout title="Site Admin - Chronus">
|
||||
<h1 class="text-3xl font-bold mb-6">Site Administration</h1>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Site Administration</h1>
|
||||
<p class="text-base-content/60 text-sm mt-1">Manage users and site settings</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Statistics -->
|
||||
<div class="stats shadow border border-base-200">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Users</div>
|
||||
<div class="stat-value">{allUsers.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={String(allUsers.length)}
|
||||
description="Registered accounts"
|
||||
icon="heroicons:users"
|
||||
color="text-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Site Settings</h2>
|
||||
|
||||
<form method="POST" action="/api/admin/settings">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">
|
||||
<div class="font-semibold">Allow New Registrations</div>
|
||||
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="registration_enabled"
|
||||
class="toggle toggle-primary"
|
||||
checked={registrationEnabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">Site Settings</h2>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
||||
<form method="POST" action="/api/admin/settings">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Allow New Registrations</legend>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="registration_enabled"
|
||||
class="toggle toggle-primary shrink-0"
|
||||
checked={registrationEnabled}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users List -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">All Users</h2>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<h2 class="text-sm font-semibold">All Users</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -76,26 +78,22 @@ const allUsers = await db.select().from(users).all();
|
||||
</thead>
|
||||
<tbody>
|
||||
{allUsers.map(u => (
|
||||
<tr>
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
||||
<span>{u.name.charAt(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-bold">{u.name}</div>
|
||||
<Avatar name={u.name} />
|
||||
<div class="font-medium">{u.name}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{u.email}</td>
|
||||
<td class="text-base-content/60">{u.email}</td>
|
||||
<td>
|
||||
{u.isSiteAdmin ? (
|
||||
<span class="badge badge-primary">Yes</span>
|
||||
<span class="badge badge-xs badge-primary">Yes</span>
|
||||
) : (
|
||||
<span class="badge badge-ghost">No</span>
|
||||
<span class="badge badge-xs badge-ghost">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
<td class="text-base-content/40">{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { verifyPassword, createSession } from '../../../lib/auth';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { verifyPassword, createSession } from "../../../lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email')?.toString();
|
||||
const password = formData.get('password')?.toString();
|
||||
const email = formData.get("email")?.toString();
|
||||
const password = formData.get("password")?.toString();
|
||||
|
||||
if (!email || !password) {
|
||||
return new Response('Missing fields', { status: 400 });
|
||||
return redirect("/login?error=missing_fields");
|
||||
}
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.email, email)).get();
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
return new Response('Invalid email or password', { status: 400 });
|
||||
return redirect("/login?error=invalid_credentials");
|
||||
}
|
||||
|
||||
const { sessionId, expiresAt } = await createSession(user.id);
|
||||
|
||||
cookies.set('session_id', sessionId, {
|
||||
path: '/',
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'lax',
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
return redirect('/dashboard');
|
||||
return redirect("/dashboard");
|
||||
};
|
||||
|
||||
35
src/pages/api/auth/passkey/delete/index.ts
Normal file
35
src/pages/api/auth/passkey/delete/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
103
src/pages/api/auth/passkey/login/finish.ts
Normal file
103
src/pages/api/auth/passkey/login/finish.ts
Normal 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 });
|
||||
};
|
||||
23
src/pages/api/auth/passkey/login/start.ts
Normal file
23
src/pages/api/auth/passkey/login/start.ts
Normal 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));
|
||||
};
|
||||
82
src/pages/api/auth/passkey/register/finish.ts
Normal file
82
src/pages/api/auth/passkey/register/finish.ts
Normal 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 });
|
||||
};
|
||||
48
src/pages/api/auth/passkey/register/start.ts
Normal file
48
src/pages/api/auth/passkey/register/start.ts
Normal 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));
|
||||
};
|
||||
@@ -1,39 +1,66 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users, organizations, members, siteSettings } from '../../../db/schema';
|
||||
import { hashPassword, createSession } from '../../../lib/auth';
|
||||
import { eq, count, sql } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import {
|
||||
users,
|
||||
organizations,
|
||||
members,
|
||||
siteSettings,
|
||||
} from "../../../db/schema";
|
||||
import { hashPassword, createSession } from "../../../lib/auth";
|
||||
import { 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 }) => {
|
||||
const userCountResult = await db.select({ count: count() }).from(users).get();
|
||||
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
|
||||
|
||||
if (!isFirstUser) {
|
||||
const registrationSetting = await db.select()
|
||||
const registrationSetting = await db
|
||||
.select()
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, 'registration_enabled'))
|
||||
.where(eq(siteSettings.key, "registration_enabled"))
|
||||
.get();
|
||||
|
||||
const registrationEnabled = registrationSetting?.value === 'true';
|
||||
|
||||
const registrationEnabled = registrationSetting?.value === "true";
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return new Response('Registration is currently disabled', { status: 403 });
|
||||
return redirect("/signup?error=registration_disabled");
|
||||
}
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name')?.toString();
|
||||
const email = formData.get('email')?.toString();
|
||||
const password = formData.get('password')?.toString();
|
||||
const name = formData.get("name")?.toString();
|
||||
const email = formData.get("email")?.toString();
|
||||
const password = formData.get("password")?.toString();
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return new Response('Missing fields', { status: 400 });
|
||||
return redirect("/signup?error=missing_fields");
|
||||
}
|
||||
|
||||
const existingUser = await db.select().from(users).where(eq(users.email, email)).get();
|
||||
if (!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) {
|
||||
return new Response('User already exists', { status: 400 });
|
||||
return redirect("/login?registered=true");
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
@@ -56,18 +83,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
await db.insert(members).values({
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
role: 'owner',
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
const { sessionId, expiresAt } = await createSession(userId);
|
||||
|
||||
cookies.set('session_id', sessionId, {
|
||||
path: '/',
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'lax',
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
return redirect('/dashboard');
|
||||
return redirect("/dashboard");
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import {
|
||||
clients,
|
||||
members,
|
||||
timeEntries,
|
||||
timeEntryTags,
|
||||
} from "../../../../db/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { clients, members, timeEntries } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -57,23 +52,19 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||
return new Response("Not authorized", { status: 403 });
|
||||
}
|
||||
|
||||
const clientEntries = await db
|
||||
.select({ id: timeEntries.id })
|
||||
.from(timeEntries)
|
||||
.where(eq(timeEntries.clientId, id))
|
||||
.all();
|
||||
|
||||
const entryIds = clientEntries.map((e) => e.id);
|
||||
|
||||
if (entryIds.length > 0) {
|
||||
await db
|
||||
.delete(timeEntryTags)
|
||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
||||
.run();
|
||||
|
||||
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
if (locals.scopes) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only owners and admins can delete clients" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("Only owners and admins can delete clients", { status: 403 });
|
||||
}
|
||||
|
||||
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||
|
||||
await db.delete(clients).where(eq(clients.id, id)).run();
|
||||
|
||||
if (locals.scopes) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
|
||||
import { db } from "../../../../db";
|
||||
import { clients, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -16,21 +17,58 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
|
||||
let name: string | undefined;
|
||||
let email: string | undefined;
|
||||
let phone: string | undefined;
|
||||
let street: string | undefined;
|
||||
let city: string | undefined;
|
||||
let state: string | undefined;
|
||||
let zip: string | undefined;
|
||||
let country: string | undefined;
|
||||
|
||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
email = body.email;
|
||||
phone = body.phone;
|
||||
street = body.street;
|
||||
city = body.city;
|
||||
state = body.state;
|
||||
zip = body.zip;
|
||||
country = body.country;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
email = formData.get("email")?.toString();
|
||||
phone = formData.get("phone")?.toString();
|
||||
street = formData.get("street")?.toString();
|
||||
city = formData.get("city")?.toString();
|
||||
state = formData.get("state")?.toString();
|
||||
zip = formData.get("zip")?.toString();
|
||||
country = formData.get("country")?.toString();
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return new Response("Client name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const lengthError =
|
||||
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||
if (lengthError) {
|
||||
if (locals.scopes) {
|
||||
return new Response(JSON.stringify({ error: lengthError }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await db
|
||||
.select()
|
||||
@@ -69,11 +107,28 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
return new Response("Not authorized", { status: 403 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
if (locals.scopes) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only owners and admins can update clients" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("Only owners and admins can update clients", { status: 403 });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
name: name.trim(),
|
||||
email: email?.trim() || null,
|
||||
phone: phone?.trim() || null,
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
})
|
||||
.where(eq(clients.id, id))
|
||||
.run();
|
||||
@@ -85,6 +140,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
id,
|
||||
name: name.trim(),
|
||||
email: email?.trim() || null,
|
||||
phone: phone?.trim() || null,
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from "../../../db";
|
||||
import { clients, members } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -12,21 +13,58 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
|
||||
let name: string | undefined;
|
||||
let email: string | undefined;
|
||||
let phone: string | undefined;
|
||||
let street: string | undefined;
|
||||
let city: string | undefined;
|
||||
let state: string | undefined;
|
||||
let zip: string | undefined;
|
||||
let country: string | undefined;
|
||||
|
||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
email = body.email;
|
||||
phone = body.phone;
|
||||
street = body.street;
|
||||
city = body.city;
|
||||
state = body.state;
|
||||
zip = body.zip;
|
||||
country = body.country;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
email = formData.get("email")?.toString();
|
||||
phone = formData.get("phone")?.toString();
|
||||
street = formData.get("street")?.toString();
|
||||
city = formData.get("city")?.toString();
|
||||
state = formData.get("state")?.toString();
|
||||
zip = formData.get("zip")?.toString();
|
||||
country = formData.get("country")?.toString();
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return new Response("Name is required", { status: 400 });
|
||||
}
|
||||
|
||||
const lengthError =
|
||||
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||
if (lengthError) {
|
||||
if (locals.scopes) {
|
||||
return new Response(JSON.stringify({ error: lengthError }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(lengthError, { status: 400 });
|
||||
}
|
||||
|
||||
const userOrg = await db
|
||||
.select()
|
||||
.from(members)
|
||||
@@ -37,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
return new Response("No organization found", { status: 400 });
|
||||
}
|
||||
|
||||
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
|
||||
if (!isAdminOrOwner) {
|
||||
if (locals.scopes) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Only owners and admins can create clients" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response("Only owners and admins can create clients", { status: 403 });
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
|
||||
await db.insert(clients).values({
|
||||
@@ -44,13 +93,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
organizationId: userOrg.organizationId,
|
||||
name,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
street: street || null,
|
||||
city: city || null,
|
||||
state: state || null,
|
||||
zip: zip || null,
|
||||
country: country || null,
|
||||
});
|
||||
|
||||
if (locals.scopes) {
|
||||
return new Response(JSON.stringify({ id, name, email: email || null }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
name,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
street: street || null,
|
||||
city: city || null,
|
||||
state: state || null,
|
||||
zip: zip || null,
|
||||
country: country || null,
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return redirect("/dashboard/clients");
|
||||
|
||||
94
src/pages/api/invoices/[id]/convert.ts
Normal file
94
src/pages/api/invoices/[id]/convert.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
117
src/pages/api/invoices/[id]/generate.ts
Normal file
117
src/pages/api/invoices/[id]/generate.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
277
src/pages/api/invoices/[id]/import-time.ts
Normal file
277
src/pages/api/invoices/[id]/import-time.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
92
src/pages/api/invoices/[id]/items/add.ts
Normal file
92
src/pages/api/invoices/[id]/items/add.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
79
src/pages/api/invoices/[id]/items/delete.ts
Normal file
79
src/pages/api/invoices/[id]/items/delete.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
81
src/pages/api/invoices/[id]/status.ts
Normal file
81
src/pages/api/invoices/[id]/status.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
77
src/pages/api/invoices/[id]/update-tax.ts
Normal file
77
src/pages/api/invoices/[id]/update-tax.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
100
src/pages/api/invoices/[id]/update.ts
Normal file
100
src/pages/api/invoices/[id]/update.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
84
src/pages/api/invoices/create.ts
Normal file
84
src/pages/api/invoices/create.ts
Normal 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");
|
||||
};
|
||||
63
src/pages/api/invoices/delete.ts
Normal file
63
src/pages/api/invoices/delete.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { db } from "../../../db";
|
||||
import { organizations, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -12,6 +15,14 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const formData = await request.formData();
|
||||
const organizationId = formData.get("organizationId") 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) {
|
||||
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 {
|
||||
// Verify user is admin/owner of this organization
|
||||
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
|
||||
.update(organizations)
|
||||
.set({ name: name.trim() })
|
||||
.set(updateData)
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.run();
|
||||
|
||||
return redirect("/dashboard/team/settings?success=org-name");
|
||||
} catch (error) {
|
||||
console.error("Error updating organization name:", error);
|
||||
return new Response("Failed to update organization name", { status: 500 });
|
||||
console.error("Error updating organization:", error);
|
||||
return new Response("Failed to update organization", { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
169
src/pages/api/reports/export.ts
Normal file
169
src/pages/api/reports/export.ts
Normal 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"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
57
src/pages/api/tags/[id]/delete.ts
Normal file
57
src/pages/api/tags/[id]/delete.ts
Normal 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");
|
||||
};
|
||||
77
src/pages/api/tags/[id]/update.ts
Normal file
77
src/pages/api/tags/[id]/update.ts
Normal 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");
|
||||
};
|
||||
72
src/pages/api/tags/create.ts
Normal file
72
src/pages/api/tags/create.ts
Normal 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");
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users, members } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { isValidEmail } from '../../../lib/validation';
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
@@ -26,6 +27,10 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
return new Response('Email is required', { status: 400 });
|
||||
}
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return new Response('Invalid email format', { status: 400 });
|
||||
}
|
||||
|
||||
if (!['member', 'admin'].includes(role)) {
|
||||
return new Response('Invalid role', { status: 400 });
|
||||
}
|
||||
|
||||
133
src/pages/api/time-entries/manual.ts
Normal file
133
src/pages/api/time-entries/manual.ts
Normal 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,51 +1,56 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { timeEntries, members, timeEntryTags, categories } from '../../../db/schema';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { timeEntries, members } from "../../../db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
|
||||
|
||||
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 description = body.description || '';
|
||||
const description = body.description || "";
|
||||
const clientId = body.clientId;
|
||||
const categoryId = body.categoryId;
|
||||
const tags = body.tags || [];
|
||||
const tagId = body.tagId || null;
|
||||
|
||||
if (!clientId) {
|
||||
return new Response('Client is required', { status: 400 });
|
||||
return new Response("Client is required", { status: 400 });
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return new Response('Category is required', { status: 400 });
|
||||
if (description && description.length > MAX_LENGTHS.description) {
|
||||
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
|
||||
}
|
||||
|
||||
const runningEntry = await db.select().from(timeEntries).where(
|
||||
and(
|
||||
eq(timeEntries.userId, locals.user.id),
|
||||
isNull(timeEntries.endTime)
|
||||
const runningEntry = await db
|
||||
.select()
|
||||
.from(timeEntries)
|
||||
.where(
|
||||
and(eq(timeEntries.userId, locals.user.id), isNull(timeEntries.endTime)),
|
||||
)
|
||||
).get();
|
||||
.get();
|
||||
|
||||
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) {
|
||||
return new Response('No organization found', { status: 400 });
|
||||
return new Response("No organization found", { status: 400 });
|
||||
}
|
||||
|
||||
const category = await db.select().from(categories).where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, member.organizationId)
|
||||
)
|
||||
).get();
|
||||
const validation = await validateTimeEntryResources({
|
||||
organizationId: member.organizationId,
|
||||
clientId,
|
||||
tagId,
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return new Response('Invalid category', { status: 400 });
|
||||
if (!validation.valid) {
|
||||
return new Response(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = new Date();
|
||||
@@ -56,19 +61,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
userId: locals.user.id,
|
||||
organizationId: member.organizationId,
|
||||
clientId,
|
||||
categoryId,
|
||||
tagId,
|
||||
startTime,
|
||||
description,
|
||||
isManual: false,
|
||||
});
|
||||
|
||||
if (tags.length > 0) {
|
||||
await db.insert(timeEntryTags).values(
|
||||
tags.map((tagId: string) => ({
|
||||
timeEntryId: id,
|
||||
tagId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
|
||||
};
|
||||
|
||||
@@ -1,61 +1,138 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users, sessions } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
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 contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const currentPassword = formData.get('currentPassword') as string;
|
||||
const newPassword = formData.get('newPassword') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
let currentPassword, newPassword, confirmPassword;
|
||||
|
||||
if (isJson) {
|
||||
const body = await request.json();
|
||||
currentPassword = body.currentPassword;
|
||||
newPassword = body.newPassword;
|
||||
confirmPassword = body.confirmPassword;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
currentPassword = formData.get("currentPassword") as string;
|
||||
newPassword = formData.get("newPassword") as string;
|
||||
confirmPassword = formData.get("confirmPassword") as string;
|
||||
}
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return new Response('All fields are required', { status: 400 });
|
||||
const msg = "All fields are required";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
return new Response('New passwords do not match', { status: 400 });
|
||||
const msg = "New passwords do not match";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
return new Response('Password must be at least 8 characters', { status: 400 });
|
||||
const msg = "Password must be at least 8 characters";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
|
||||
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user from database
|
||||
const dbUser = await db.select()
|
||||
const dbUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.get();
|
||||
|
||||
if (!dbUser) {
|
||||
return new Response('User not found', { status: 404 });
|
||||
const msg = "User not found";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 404 });
|
||||
return new Response(msg, { status: 404 });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const passwordMatch = await bcrypt.compare(currentPassword, dbUser.passwordHash);
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
currentPassword,
|
||||
dbUser.passwordHash,
|
||||
);
|
||||
if (!passwordMatch) {
|
||||
return new Response('Current password is incorrect', { status: 400 });
|
||||
const msg = "Current password is incorrect";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await db.update(users)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash: hashedPassword })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return redirect('/dashboard/settings?success=password');
|
||||
// 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) {
|
||||
console.error('Error changing password:', error);
|
||||
return new Response('Failed to change password', { status: 500 });
|
||||
console.error("Error changing password:", error);
|
||||
const msg = "Failed to change password";
|
||||
if (isJson)
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,8 +12,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name")?.toString();
|
||||
let name: string | undefined;
|
||||
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return new Response(JSON.stringify({ error: "Name is required" }), {
|
||||
|
||||
@@ -1,30 +1,58 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const user = locals.user;
|
||||
const contentType = request.headers.get("content-type");
|
||||
const isJson = contentType?.includes("application/json");
|
||||
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
let name: string | undefined;
|
||||
|
||||
if (isJson) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name") as string;
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return new Response('Name is required', { status: 400 });
|
||||
const msg = "Name is required";
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||
}
|
||||
return new Response(msg, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.update(users)
|
||||
await db
|
||||
.update(users)
|
||||
.set({ name: name.trim() })
|
||||
.where(eq(users.id, user.id))
|
||||
.run();
|
||||
|
||||
return redirect('/dashboard/settings?success=profile');
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||
}
|
||||
|
||||
return redirect("/dashboard/settings?success=profile");
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return new Response('Failed to update profile', { status: 500 });
|
||||
console.error("Error updating profile:", error);
|
||||
const msg = "Failed to update profile";
|
||||
if (isJson) {
|
||||
return new Response(JSON.stringify({ error: msg }), { status: 500 });
|
||||
}
|
||||
return new Response(msg, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,26 +1,15 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { db } from '../../db';
|
||||
import { clients, members } from '../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { clients } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const organizationId = userMembership.organizationId;
|
||||
|
||||
@@ -32,20 +21,20 @@ const allClients = await db.select()
|
||||
|
||||
<DashboardLayout title="Clients - Chronus">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Clients</h1>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Clients</h1>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Client</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{allClients.map(client => (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{client.name}</h2>
|
||||
{client.email && <p class="text-sm text-gray-500">{client.email}</p>}
|
||||
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-sm btn-ghost">View</a>
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4 gap-1">
|
||||
<h2 class="font-semibold">{client.name}</h2>
|
||||
{client.email && <p class="text-sm text-base-content/60">{client.email}</p>}
|
||||
<p class="text-xs text-base-content/40">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||
<div class="card-actions justify-end mt-3">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-xs btn-ghost">View</a>
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-xs btn-primary">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,9 +42,9 @@ const allClients = await db.select()
|
||||
</div>
|
||||
|
||||
{allClients.length === 0 && (
|
||||
<div class="text-center py-12">
|
||||
<p class="text-gray-500 mb-4">No clients yet</p>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Your First Client</a>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p class="text-base-content/50 text-sm mb-4">No clients yet</p>
|
||||
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Your First Client</a>
|
||||
</div>
|
||||
)}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, members } from '../../../../db/schema';
|
||||
import { clients } from '../../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
|
||||
const { id } = Astro.params;
|
||||
if (!id) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const client = await db.select()
|
||||
.from(clients)
|
||||
@@ -40,55 +29,129 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
<DashboardLayout title={`Edit ${client.name} - Chronus`}>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Edit Client</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Edit Client</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action={`/api/clients/${client.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text">Client Name</span>
|
||||
</label>
|
||||
<form method="POST" action={`/api/clients/${client.id}/update`} class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Client Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={client.name}
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text">Email (optional)</span>
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email (optional)</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={client.email || ''}
|
||||
placeholder="contact@acme.com"
|
||||
class="input input-bordered"
|
||||
placeholder="jason.borne@cia.com"
|
||||
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 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
|
||||
type="button"
|
||||
class="btn btn-error btn-outline"
|
||||
class="btn btn-error btn-outline btn-sm"
|
||||
onclick={`document.getElementById('delete_modal').showModal()`}
|
||||
>
|
||||
Delete Client
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,17 +161,17 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<dialog id="delete_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg text-error">Delete Client?</h3>
|
||||
<p class="py-4">
|
||||
<h3 class="font-semibold text-base text-error">Delete Client?</h3>
|
||||
<p class="py-4 text-sm">
|
||||
Are you sure you want to delete <strong>{client.name}</strong>?
|
||||
This action cannot be undone and will delete all associated time entries.
|
||||
</p>
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn">Cancel</button>
|
||||
<button class="btn btn-sm">Cancel</button>
|
||||
</form>
|
||||
<form method="POST" action={`/api/clients/${client.id}/delete`}>
|
||||
<button type="submit" class="btn btn-error">Delete</button>
|
||||
<button type="submit" class="btn btn-error btn-sm">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../../db';
|
||||
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
|
||||
import { clients, timeEntries, tags, users } from '../../../../db/schema';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||
import StatCard from '../../../../components/StatCard.astro';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
|
||||
const { id } = Astro.params;
|
||||
if (!id) return Astro.redirect('/dashboard/clients');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const client = await db.select()
|
||||
.from(clients)
|
||||
@@ -40,12 +30,12 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
// Get recent activity
|
||||
const recentEntries = await db.select({
|
||||
entry: timeEntries,
|
||||
category: categories,
|
||||
user: users,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(eq(timeEntries.clientId, client.id))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.limit(10)
|
||||
@@ -73,112 +63,133 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
|
||||
<DashboardLayout title={`${client.name} - Clients - Chronus`}>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href="/dashboard/clients" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">{client.name}</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">{client.name}</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
|
||||
<!-- Client Details Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 lg:col-span-2">
|
||||
<div class="card-body">
|
||||
<div class="card card-border bg-base-100 lg:col-span-2">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
||||
{client.email && (
|
||||
<div class="flex items-center gap-2 text-base-content/70 mb-4">
|
||||
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
||||
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
||||
</div>
|
||||
)}
|
||||
<h2 class="text-sm font-semibold mb-3">{client.name}</h2>
|
||||
<div class="space-y-2 mb-4">
|
||||
{client.email && (
|
||||
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
||||
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div class="flex items-center gap-2 text-base-content/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 class="flex gap-2">
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-xs">
|
||||
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||
Edit
|
||||
</a>
|
||||
<form method="POST" action={`/api/clients/${client.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this client? This will also delete all associated time entries.');">
|
||||
<button type="submit" class="btn btn-error btn-outline btn-sm">
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
<button type="submit" class="btn btn-error btn-outline btn-xs">
|
||||
<Icon name="heroicons:trash" class="w-3 h-3" />
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<div class="stats shadow w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Time Tracked</div>
|
||||
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
|
||||
<div class="stat-desc">Across all projects</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Total Entries</div>
|
||||
<div class="stat-value text-secondary">{totalEntriesCount}</div>
|
||||
<div class="stat-desc">Recorded entries</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
title="Total Time Tracked"
|
||||
value={`${totalHours}h ${totalMinutes}m`}
|
||||
description="Across all projects"
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Entries"
|
||||
value={String(totalEntriesCount)}
|
||||
description="Recorded entries"
|
||||
icon="heroicons:list-bullet"
|
||||
color="text-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info Card -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg mb-4">Information</h3>
|
||||
<div class="card card-border bg-base-100 h-fit">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-sm font-semibold mb-3">Information</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-base-content/60">Created</div>
|
||||
<div>{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
||||
<div class="text-xs text-base-content/40">Created</div>
|
||||
<div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Recent Activity</h2>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-4 py-3 border-b border-base-200">
|
||||
<h2 class="text-sm font-semibold">Recent Activity</h2>
|
||||
</div>
|
||||
|
||||
{recentEntries.length > 0 ? (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Category</th>
|
||||
<th>Tag</th>
|
||||
<th>User</th>
|
||||
<th>Date</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentEntries.map(({ entry, category, user: entryUser }) => (
|
||||
<tr>
|
||||
{recentEntries.map(({ entry, tag, user: entryUser }) => (
|
||||
<tr class="hover">
|
||||
<td>{entry.description || '-'}</td>
|
||||
<td>
|
||||
{category ? (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
|
||||
<span>{category.name}</span>
|
||||
{tag ? (
|
||||
<div class="badge badge-xs badge-outline flex items-center gap-1">
|
||||
{tag.color && (
|
||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span>
|
||||
)}
|
||||
<span>{tag.name}</span>
|
||||
</div>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td>{entryUser?.name || 'Unknown'}</td>
|
||||
<td>{entry.startTime.toLocaleDateString()}</td>
|
||||
<td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
|
||||
<td class="text-base-content/40">{entry.startTime.toLocaleDateString()}</td>
|
||||
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -186,14 +197,14 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-center py-8 text-base-content/60">
|
||||
<div class="text-center py-8 text-base-content/40 text-sm">
|
||||
No time entries recorded for this client yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentEntries.length > 0 && (
|
||||
<div class="card-actions justify-center mt-4">
|
||||
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-sm">
|
||||
<div class="flex justify-center py-3 border-t border-base-200">
|
||||
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs">
|
||||
View All Entries
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -7,40 +7,108 @@ if (!user) return Astro.redirect('/login');
|
||||
|
||||
<DashboardLayout title="New Client - Chronus">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
||||
|
||||
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text">Client Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Add New Client</h1>
|
||||
|
||||
<form method="POST" action="/api/clients/create" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Client Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered"
|
||||
required
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text">Email (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email (optional)</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="contact@acme.com"
|
||||
class="input input-bordered"
|
||||
name="email"
|
||||
placeholder="jason.borne@cia.com"
|
||||
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 class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Client</button>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
placeholder="10001"
|
||||
class="input 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>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import StatCard from '../../components/StatCard.astro';
|
||||
import { db } from '../../db';
|
||||
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
|
||||
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||
import { formatDuration } from '../../lib/formatTime';
|
||||
|
||||
@@ -41,61 +42,57 @@ if (currentOrg) {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const weekEntries = await db.select()
|
||||
const weekStats = await db.select({
|
||||
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||
})
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||
gte(timeEntries.startTime, weekAgo)
|
||||
gte(timeEntries.startTime, weekAgo),
|
||||
sql`${timeEntries.endTime} IS NOT NULL`
|
||||
))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
|
||||
if (e.endTime) {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
|
||||
|
||||
const monthEntries = await db.select()
|
||||
const monthStats = await db.select({
|
||||
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||
})
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||
gte(timeEntries.startTime, monthAgo)
|
||||
gte(timeEntries.startTime, monthAgo),
|
||||
sql`${timeEntries.endTime} IS NOT NULL`
|
||||
))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
|
||||
if (e.endTime) {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
|
||||
|
||||
const activeCount = await db.select()
|
||||
const activeCount = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||
isNull(timeEntries.endTime)
|
||||
))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.activeTimers = activeCount.length;
|
||||
stats.activeTimers = activeCount?.count || 0;
|
||||
|
||||
const clientCount = await db.select()
|
||||
const clientCount = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, currentOrg.organizationId))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.totalClients = clientCount.length;
|
||||
stats.totalClients = clientCount?.count || 0;
|
||||
|
||||
stats.recentEntries = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
category: categories,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.limit(5)
|
||||
@@ -107,25 +104,25 @@ const hasMembership = userOrgs.length > 0;
|
||||
---
|
||||
|
||||
<DashboardLayout title="Dashboard - Chronus">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-6">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-primary mb-2">
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p class="text-base-content/60">Welcome back, {user.name}!</p>
|
||||
<p class="text-base-content/60 text-sm mt-1">Welcome back, {user.name}!</p>
|
||||
</div>
|
||||
<a href="/dashboard/organizations/new" class="btn btn-outline">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
<a href="/dashboard/organizations/new" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
New Team
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{!hasMembership && (
|
||||
<div class="alert alert-info mb-8">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<div class="alert alert-info mb-6 text-sm">
|
||||
<Icon name="heroicons:information-circle" class="w-5 h-5" />
|
||||
<div>
|
||||
<h3 class="font-bold">Welcome to Chronus!</h3>
|
||||
<div class="text-sm">You're not part of any team yet. Create one or wait for an invitation.</div>
|
||||
<div class="text-xs">You're not part of any team yet. Create one or wait for an invitation.</div>
|
||||
</div>
|
||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
@@ -137,63 +134,56 @@ const hasMembership = userOrgs.length > 0;
|
||||
{hasMembership && (
|
||||
<>
|
||||
<!-- Stats Overview -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">This Week</div>
|
||||
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
|
||||
<div class="stat-desc">Total tracked time</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:calendar" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">This Month</div>
|
||||
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
|
||||
<div class="stat-desc">Total tracked time</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-accent">
|
||||
<Icon name="heroicons:play-circle" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Active Timers</div>
|
||||
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
|
||||
<div class="stat-desc">Currently running</div>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-info">
|
||||
<Icon name="heroicons:building-office" class="w-8 h-8" />
|
||||
</div>
|
||||
<div class="stat-title">Clients</div>
|
||||
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
|
||||
<div class="stat-desc">Total active</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="This Week"
|
||||
value={formatDuration(stats.totalTimeThisWeek)}
|
||||
description="Total tracked time"
|
||||
icon="heroicons:clock"
|
||||
color="text-primary"
|
||||
/>
|
||||
<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 class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Quick Actions -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:bolt" class="w-6 h-6 text-warning" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:bolt" class="w-4 h-4 text-warning" />
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div class="flex flex-col gap-3 mt-4">
|
||||
<a href="/dashboard/tracker" class="btn btn-primary">
|
||||
<Icon name="heroicons:play" class="w-5 h-5" />
|
||||
<div class="flex flex-col gap-2 mt-3">
|
||||
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:play" class="w-4 h-4" />
|
||||
Start Timer
|
||||
</a>
|
||||
<a href="/dashboard/clients/new" class="btn btn-outline">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
<a href="/dashboard/clients/new" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Add Client
|
||||
</a>
|
||||
<a href="/dashboard/reports" class="btn btn-outline">
|
||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
||||
<a href="/dashboard/reports" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:chart-bar" class="w-4 h-4" />
|
||||
View Reports
|
||||
</a>
|
||||
</div>
|
||||
@@ -201,27 +191,32 @@ const hasMembership = userOrgs.length > 0;
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:clock" class="w-6 h-6 text-success" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:clock" class="w-4 h-4 text-success" />
|
||||
Recent Activity
|
||||
</h2>
|
||||
{stats.recentEntries.length > 0 ? (
|
||||
<ul class="space-y-3 mt-4">
|
||||
{stats.recentEntries.map(({ entry, client, category }) => (
|
||||
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
|
||||
<div class="font-semibold text-sm">{client.name}</div>
|
||||
<div class="text-xs text-base-content/60 mt-1">
|
||||
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
|
||||
<ul class="space-y-2 mt-3">
|
||||
{stats.recentEntries.map(({ entry, client, tag }) => (
|
||||
<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-medium text-sm">{client.name}</div>
|
||||
<div class="text-xs text-base-content/50 mt-0.5 flex flex-wrap gap-2 items-center">
|
||||
<span class="flex gap-1 flex-wrap">
|
||||
{tag ? (
|
||||
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
||||
) : <span class="italic opacity-50">No tag</span>}
|
||||
</span>
|
||||
<span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center py-8 text-center mt-4">
|
||||
<Icon name="heroicons:clock" class="w-12 h-12 text-base-content/20 mb-3" />
|
||||
<p class="text-base-content/60 text-sm">No recent time entries</p>
|
||||
<div class="flex flex-col items-center justify-center py-6 text-center mt-3">
|
||||
<Icon name="heroicons:clock" class="w-10 h-10 text-base-content/15 mb-2" />
|
||||
<p class="text-base-content/40 text-sm">No recent time entries</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
409
src/pages/dashboard/invoices/[id].astro
Normal file
409
src/pages/dashboard/invoices/[id].astro
Normal 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>
|
||||
170
src/pages/dashboard/invoices/[id]/edit.astro
Normal file
170
src/pages/dashboard/invoices/[id]/edit.astro
Normal 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>
|
||||
316
src/pages/dashboard/invoices/index.astro
Normal file
316
src/pages/dashboard/invoices/index.astro
Normal 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>
|
||||
225
src/pages/dashboard/invoices/new.astro
Normal file
225
src/pages/dashboard/invoices/new.astro
Normal 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>
|
||||
@@ -12,36 +12,34 @@ if (!user) return Astro.redirect('/login');
|
||||
<DashboardLayout title="Create Team - Chronus">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/dashboard" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href="/dashboard" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Create New Team</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Create New Team</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/api/organizations/create" class="card bg-base-200 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
|
||||
<form method="POST" action="/api/organizations/create" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-info mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<span>Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4" />
|
||||
<span class="text-sm">Create a new team to manage separate projects and collaborators. You'll be the owner.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2" for="name">
|
||||
<span class="label-text font-medium">Team Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Team Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Team</button>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<a href="/dashboard" class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Create Team</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
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 MemberChart from '../../components/MemberChart.vue';
|
||||
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 { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
||||
import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const teamMembers = await db.select({
|
||||
id: users.id,
|
||||
@@ -37,9 +27,9 @@ const teamMembers = await db.select({
|
||||
.where(eq(members.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, userMembership.organizationId))
|
||||
const allTags = await db.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.organizationId, userMembership.organizationId))
|
||||
.all();
|
||||
|
||||
const allClients = await db.select()
|
||||
@@ -49,9 +39,11 @@ const allClients = await db.select()
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedMemberId = url.searchParams.get('member') || '';
|
||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
||||
const selectedTagId = url.searchParams.get('tag') || '';
|
||||
const selectedClientId = url.searchParams.get('client') || '';
|
||||
const timeRange = url.searchParams.get('range') || 'week';
|
||||
const customFrom = url.searchParams.get('from');
|
||||
const customTo = url.searchParams.get('to');
|
||||
|
||||
const now = new Date();
|
||||
let startDate = new Date();
|
||||
@@ -78,6 +70,16 @@ switch (timeRange) {
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
break;
|
||||
case 'custom':
|
||||
if (customFrom) {
|
||||
const parts = customFrom.split('-');
|
||||
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
|
||||
}
|
||||
if (customTo) {
|
||||
const parts = customTo.split('-');
|
||||
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
@@ -90,8 +92,8 @@ if (selectedMemberId) {
|
||||
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
||||
}
|
||||
|
||||
if (selectedCategoryId) {
|
||||
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
|
||||
if (selectedTagId) {
|
||||
conditions.push(eq(timeEntries.tagId, selectedTagId));
|
||||
}
|
||||
|
||||
if (selectedClientId) {
|
||||
@@ -102,12 +104,12 @@ const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
user: users,
|
||||
client: clients,
|
||||
category: categories,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(timeEntries.startTime))
|
||||
.all();
|
||||
@@ -120,7 +122,7 @@ const statsByMember = teamMembers.map(member => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
member,
|
||||
totalTime,
|
||||
@@ -128,19 +130,19 @@ const statsByMember = teamMembers.map(member => {
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
const statsByCategory = allCategories.map(category => {
|
||||
const categoryEntries = entries.filter(e => e.category.id === category.id);
|
||||
const totalTime = categoryEntries.reduce((sum, e) => {
|
||||
const statsByTag = allTags.map(tag => {
|
||||
const tagEntries = entries.filter(e => e.tag?.id === tag.id);
|
||||
const totalTime = tagEntries.reduce((sum, e) => {
|
||||
if (e.entry.endTime) {
|
||||
return sum + (e.entry.endTime.getTime() - e.entry.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
category,
|
||||
tag,
|
||||
totalTime,
|
||||
entryCount: categoryEntries.length,
|
||||
entryCount: tagEntries.length,
|
||||
};
|
||||
}).sort((a, b) => b.totalTime - a.totalTime);
|
||||
|
||||
@@ -152,7 +154,7 @@ const statsByClient = allClients.map(client => {
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
|
||||
return {
|
||||
client,
|
||||
totalTime,
|
||||
@@ -167,6 +169,74 @@ const totalTime = entries.reduce((sum, e) => {
|
||||
return sum;
|
||||
}, 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) {
|
||||
switch (range) {
|
||||
case 'today': return 'Today';
|
||||
@@ -175,37 +245,62 @@ function getTimeRangeLabel(range: string) {
|
||||
case 'mtd': return 'Month to Date';
|
||||
case 'ytd': return 'Year to Date';
|
||||
case 'last-month': return 'Last Month';
|
||||
case 'custom': return 'Custom Range';
|
||||
default: return 'Last 7 Days';
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<DashboardLayout title="Reports - Chronus">
|
||||
<h1 class="text-3xl font-bold mb-6">Team Reports</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Team Reports</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Time Range</span>
|
||||
</label>
|
||||
<select name="range" class="select select-bordered" onchange="this.form.submit()">
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Time Range</legend>
|
||||
<select id="reports-range" name="range" class="select w-full" onchange="this.form.submit()">
|
||||
<option value="today" selected={timeRange === 'today'}>Today</option>
|
||||
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
|
||||
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
|
||||
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
|
||||
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
|
||||
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
|
||||
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Team Member</span>
|
||||
</label>
|
||||
<select name="member" class="select select-bordered" onchange="this.form.submit()">
|
||||
{timeRange === 'custom' && (
|
||||
<>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">From Date</legend>
|
||||
<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>
|
||||
{teamMembers.map(member => (
|
||||
<option value={member.id} selected={selectedMemberId === member.id}>
|
||||
@@ -213,27 +308,23 @@ function getTimeRangeLabel(range: string) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={selectedCategoryId === category.id}>
|
||||
{category.name}
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Tag</legend>
|
||||
<select id="reports-tag" name="tag" class="select w-full" onchange="this.form.submit()">
|
||||
<option value="">All Tags</option>
|
||||
{allTags.map(tag => (
|
||||
<option value={tag.id} selected={selectedTagId === tag.id}>
|
||||
{tag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Client</legend>
|
||||
<select id="reports-client" name="client" class="select w-full" onchange="this.form.submit()">
|
||||
<option value="">All Clients</option>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={selectedClientId === client.id}>
|
||||
@@ -241,66 +332,177 @@ function getTimeRangeLabel(range: string) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard
|
||||
title="Total Time"
|
||||
value={formatDuration(totalTime)}
|
||||
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 class="stat-title">Total Time</div>
|
||||
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
|
||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-secondary">
|
||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
||||
<div 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: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="stat-title">Total Entries</div>
|
||||
<div class="stat-value text-secondary">{entries.length}</div>
|
||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow border border-base-300">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-accent">
|
||||
<Icon name="heroicons:user-group" class="w-8 h-8" />
|
||||
<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 class="stat-title">Active Members</div>
|
||||
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
|
||||
<div class="stat-desc">of {teamMembers.length} total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue by Client - Only show if there's revenue data and no client filter -->
|
||||
{!selectedClientId && revenueByClient.length > 0 && (
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:banknotes" class="w-4 h-4" />
|
||||
Revenue by Client
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Charts Section - Only show if there's data */}
|
||||
{totalTime > 0 && (
|
||||
<>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Category Distribution Chart - Only show when no category filter */}
|
||||
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:chart-pie" class="w-6 h-6" />
|
||||
Category Distribution
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
{/* Tag Distribution Chart - Only show when no tag filter */}
|
||||
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:chart-pie" class="w-4 h-4" />
|
||||
Tag Distribution
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<CategoryChart
|
||||
client:load
|
||||
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.category.name,
|
||||
<TagChart
|
||||
client:visible
|
||||
tags={statsByTag.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.tag.name,
|
||||
totalTime: s.totalTime,
|
||||
color: s.category.color || '#3b82f6'
|
||||
color: s.tag.color || '#3b82f6'
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -310,15 +512,15 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Client Distribution Chart - Only show when no client filter */}
|
||||
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:chart-bar" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:chart-bar" class="w-4 h-4" />
|
||||
Time by Client
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<ClientChart
|
||||
client:load
|
||||
<ClientChart
|
||||
client:visible
|
||||
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.client.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -332,15 +534,15 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Team Member Chart - Only show when no member filter */}
|
||||
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:users" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:users" class="w-4 h-4" />
|
||||
Time by Team Member
|
||||
</h2>
|
||||
<div class="h-64 w-full">
|
||||
<MemberChart
|
||||
client:load
|
||||
<MemberChart
|
||||
client:visible
|
||||
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
|
||||
name: s.member.name,
|
||||
totalTime: s.totalTime
|
||||
@@ -355,14 +557,14 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Stats by Member - Only show if there's data and no member filter */}
|
||||
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:users" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:users" class="w-4 h-4" />
|
||||
By Team Member
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Member</th>
|
||||
@@ -376,13 +578,13 @@ function getTimeRangeLabel(range: string) {
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<div class="font-bold">{stat.member.name}</div>
|
||||
<div class="text-sm opacity-50">{stat.member.email}</div>
|
||||
<div class="font-medium">{stat.member.name}</div>
|
||||
<div class="text-xs text-base-content/40">{stat.member.email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
|
||||
<td>{stat.entryCount}</td>
|
||||
<td class="font-mono">
|
||||
<td class="font-mono text-sm">
|
||||
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -394,45 +596,45 @@ function getTimeRangeLabel(range: string) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats by Category - Only show if there's data and no category filter */}
|
||||
{!selectedCategoryId && statsByCategory.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
By Category
|
||||
{/* Stats by Tag - Only show if there's data and no tag filter */}
|
||||
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:tag" class="w-4 h-4" />
|
||||
By Tag
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Tag</th>
|
||||
<th>Total Time</th>
|
||||
<th>Entries</th>
|
||||
<th>% of Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsByCategory.filter(s => s.totalTime > 0).map(stat => (
|
||||
{statsByTag.filter(s => s.totalTime > 0).map(stat => (
|
||||
<tr>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{stat.category.color && (
|
||||
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.category.color}`}></span>
|
||||
{stat.tag.color && (
|
||||
<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>
|
||||
</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-primary w-20"
|
||||
value={stat.totalTime}
|
||||
<progress
|
||||
class="progress progress-primary w-16"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
<span class="text-xs">
|
||||
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -448,14 +650,14 @@ function getTimeRangeLabel(range: string) {
|
||||
|
||||
{/* Stats by Client - Only show if there's data and no client filter */}
|
||||
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:building-office" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
|
||||
<Icon name="heroicons:building-office" class="w-4 h-4" />
|
||||
By Client
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
@@ -468,16 +670,16 @@ function getTimeRangeLabel(range: string) {
|
||||
{statsByClient.filter(s => s.totalTime > 0).map(stat => (
|
||||
<tr>
|
||||
<td>{stat.client.name}</td>
|
||||
<td class="font-mono">{formatDuration(stat.totalTime)}</td>
|
||||
<td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
|
||||
<td>{stat.entryCount}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<progress
|
||||
class="progress progress-secondary w-20"
|
||||
value={stat.totalTime}
|
||||
<progress
|
||||
class="progress progress-secondary w-16"
|
||||
value={stat.totalTime}
|
||||
max={totalTime}
|
||||
></progress>
|
||||
<span class="text-sm">
|
||||
<span class="text-xs">
|
||||
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -492,21 +694,29 @@ function getTimeRangeLabel(range: string) {
|
||||
)}
|
||||
|
||||
{/* Detailed Entries */}
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:document-text" class="w-6 h-6" />
|
||||
Detailed Entries ({entries.length})
|
||||
</h2>
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:document-text" class="w-4 h-4" />
|
||||
Detailed Entries ({entries.length})
|
||||
</h2>
|
||||
{entries.length > 0 && (
|
||||
<a href={`/api/reports/export${url.search}`} class="btn btn-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 ? (
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Member</th>
|
||||
<th>Client</th>
|
||||
<th>Category</th>
|
||||
<th>Tag</th>
|
||||
<th>Description</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
@@ -516,23 +726,27 @@ function getTimeRangeLabel(range: string) {
|
||||
<tr>
|
||||
<td class="whitespace-nowrap">
|
||||
{e.entry.startTime.toLocaleDateString()}<br/>
|
||||
<span class="text-xs opacity-50">
|
||||
<span class="text-xs text-base-content/40">
|
||||
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||
</span>
|
||||
</td>
|
||||
<td>{e.user.name}</td>
|
||||
<td>{e.client.name}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
{e.category.color && (
|
||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${e.category.color}`}></span>
|
||||
)}
|
||||
<span>{e.category.name}</span>
|
||||
</div>
|
||||
{e.tag ? (
|
||||
<div class="badge badge-xs badge-outline flex items-center gap-1">
|
||||
{e.tag.color && (
|
||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span>
|
||||
)}
|
||||
<span>{e.tag.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span class="text-base-content/30">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{e.entry.description || '-'}</td>
|
||||
<td class="font-mono">
|
||||
{e.entry.endTime
|
||||
<td class="text-base-content/60">{e.entry.description || '-'}</td>
|
||||
<td class="font-mono text-sm">
|
||||
{e.entry.endTime
|
||||
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
|
||||
: 'Running...'
|
||||
}
|
||||
@@ -543,12 +757,12 @@ function getTimeRangeLabel(range: string) {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Icon name="heroicons:inbox" class="w-16 h-16 text-base-content/20 mb-4" />
|
||||
<h3 class="text-lg font-semibold mb-2">No time entries found</h3>
|
||||
<p class="text-base-content/60 mb-4">Try adjusting your filters or select a different time range.</p>
|
||||
<a href="/dashboard/tracker" class="btn btn-primary">
|
||||
<Icon name="heroicons:play" class="w-5 h-5" />
|
||||
<div class="flex flex-col items-center justify-center py-10 text-center">
|
||||
<Icon name="heroicons:inbox" class="w-12 h-12 text-base-content/15 mb-3" />
|
||||
<h3 class="text-base font-semibold mb-1">No time entries found</h3>
|
||||
<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 btn-sm">
|
||||
<Icon name="heroicons:play" class="w-4 h-4" />
|
||||
Start Tracking Time
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { apiTokens } from '../../db/schema';
|
||||
import { apiTokens, passkeys } from '../../db/schema';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import ProfileForm from '../../components/settings/ProfileForm.vue';
|
||||
import PasswordForm from '../../components/settings/PasswordForm.vue';
|
||||
import ApiTokenManager from '../../components/settings/ApiTokenManager.vue';
|
||||
import PasskeyManager from '../../components/settings/PasskeyManager.vue';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
@@ -16,11 +20,17 @@ const userTokens = await db.select()
|
||||
.where(eq(apiTokens.userId, user.id))
|
||||
.orderBy(desc(apiTokens.createdAt))
|
||||
.all();
|
||||
|
||||
const userPasskeys = await db.select()
|
||||
.from(passkeys)
|
||||
.where(eq(passkeys.userId, user.id))
|
||||
.orderBy(desc(passkeys.createdAt))
|
||||
.all();
|
||||
---
|
||||
|
||||
<DashboardLayout title="Account Settings - Chronus">
|
||||
<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
|
||||
</h1>
|
||||
|
||||
@@ -40,196 +50,44 @@ const userTokens = await db.select()
|
||||
)}
|
||||
|
||||
<!-- Profile Information -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Profile Information
|
||||
</h2>
|
||||
|
||||
<form action="/api/user/update-profile" method="POST" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={user.name}
|
||||
placeholder="Your full name"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={user.email}
|
||||
placeholder="your@email.com"
|
||||
class="input input-bordered w-full"
|
||||
disabled
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileForm client:idle user={user} />
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
Change Password
|
||||
</h2>
|
||||
<PasswordForm client:idle />
|
||||
|
||||
<form action="/api/user/change-password" method="POST" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
placeholder="Enter current password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="newPassword"
|
||||
placeholder="Enter new password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="Confirm new password"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
|
||||
Update Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Passkeys -->
|
||||
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
|
||||
...pk,
|
||||
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
|
||||
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
|
||||
}))} />
|
||||
|
||||
<!-- API Tokens -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="card-title text-lg sm:text-xl">
|
||||
<Icon name="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
API Tokens
|
||||
</h2>
|
||||
<button class="btn btn-primary btn-sm" onclick="createTokenModal.showModal()">
|
||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
<ApiTokenManager client:idle initialTokens={userTokens.map(t => ({
|
||||
...t,
|
||||
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
|
||||
createdAt: t.createdAt ? t.createdAt.toISOString() : ''
|
||||
}))} />
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Last Used</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userTokens.length === 0 ? (
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||
No API tokens found. Create one to access the API.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
userTokens.map(token => (
|
||||
<tr>
|
||||
<td class="font-medium">{token.name}</td>
|
||||
<td class="text-sm">
|
||||
{token.lastUsedAt ? token.lastUsedAt.toLocaleDateString() : 'Never'}
|
||||
</td>
|
||||
<td class="text-sm">
|
||||
{token.createdAt ? token.createdAt.toLocaleDateString() : 'N/A'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={`deleteToken('${token.id}')`}
|
||||
>
|
||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Info -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body p-4 sm:p-6">
|
||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||
<Icon name="heroicons:information-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4" />
|
||||
Account Information
|
||||
</h2>
|
||||
|
||||
<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">
|
||||
<span class="text-base-content/70 text-sm sm:text-base">Account ID</span>
|
||||
<span class="font-mono text-xs sm:text-sm break-all">{user.id}</span>
|
||||
<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/60 text-sm">Account ID</span>
|
||||
<span class="font-mono text-xs break-all">{user.id}</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
|
||||
<span class="text-base-content/70 text-sm sm:text-base">Email</span>
|
||||
<span class="text-sm sm:text-base break-all">{user.email}</span>
|
||||
<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/60 text-sm">Email</span>
|
||||
<span class="text-sm break-all">{user.email}</span>
|
||||
</div>
|
||||
<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={user.isSiteAdmin ? "badge badge-primary" : "badge badge-ghost"}>
|
||||
<span class="text-base-content/60 text-sm">Site Administrator</span>
|
||||
<span class={user.isSiteAdmin ? "badge badge-xs badge-primary" : "badge badge-xs badge-ghost"}>
|
||||
{user.isSiteAdmin ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -238,132 +96,5 @@ const userTokens = await db.select()
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Token Modal -->
|
||||
<dialog id="createTokenModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Create API Token</h3>
|
||||
<p class="py-4 text-sm text-base-content/70">
|
||||
API tokens allow you to authenticate with the API programmatically.
|
||||
Give your token a descriptive name.
|
||||
</p>
|
||||
|
||||
<form id="createTokenForm" method="dialog" class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium">Token Name</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="tokenName"
|
||||
placeholder="e.g. CI/CD Pipeline"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="createTokenModal.close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Generate Token</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Show Token Modal -->
|
||||
<dialog id="showTokenModal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||
Token Created
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
Make sure to copy your personal access token now. You won't be able to see it again!
|
||||
</p>
|
||||
|
||||
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
|
||||
<span id="newTokenDisplay"></span>
|
||||
<button
|
||||
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick="copyToken()"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Icon name="heroicons:clipboard" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary" onclick="closeShowTokenModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script is:inline>
|
||||
// Handle Token Creation
|
||||
const createTokenForm = document.getElementById('createTokenForm');
|
||||
|
||||
createTokenForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('tokenName').value;
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/tokens', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
document.getElementById('createTokenModal').close();
|
||||
document.getElementById('newTokenDisplay').innerText = data.token;
|
||||
document.getElementById('showTokenModal').showModal();
|
||||
document.getElementById('tokenName').value = ''; // Reset form
|
||||
} else {
|
||||
alert('Failed to create token');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating token:', error);
|
||||
alert('An error occurred');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Token Copy
|
||||
function copyToken() {
|
||||
const token = document.getElementById('newTokenDisplay').innerText;
|
||||
navigator.clipboard.writeText(token);
|
||||
}
|
||||
|
||||
// Handle Closing Show Token Modal (refresh page to show new token in list)
|
||||
function closeShowTokenModal() {
|
||||
document.getElementById('showTokenModal').close();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Handle Token Deletion
|
||||
async function deleteToken(id) {
|
||||
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/user/tokens/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete token');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting token:', error);
|
||||
alert('An error occurred');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
---
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import Avatar from '../../components/Avatar.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../db';
|
||||
import { members, users } from '../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const teamMembers = await db.select({
|
||||
member: members,
|
||||
@@ -37,25 +27,28 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
---
|
||||
|
||||
<DashboardLayout title="Team - Chronus">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">Team Members</h1>
|
||||
<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">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">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
||||
<a href="/dashboard/team/settings" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4" />
|
||||
Settings
|
||||
</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 class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -67,25 +60,21 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
</thead>
|
||||
<tbody>
|
||||
{teamMembers.map(({ member, user: teamUser }) => (
|
||||
<tr>
|
||||
<tr class="hover">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-10">
|
||||
<span>{teamUser.name.charAt(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Avatar name={teamUser.name} />
|
||||
<div>
|
||||
<div class="font-bold">{teamUser.name}</div>
|
||||
<div class="font-medium">{teamUser.name}</div>
|
||||
{teamUser.id === user.id && (
|
||||
<span class="badge badge-sm">You</span>
|
||||
<span class="badge badge-xs">You</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{teamUser.email}</td>
|
||||
<td class="text-base-content/60">{teamUser.email}</td>
|
||||
<td>
|
||||
<span class={`badge ${
|
||||
<span class={`badge badge-xs ${
|
||||
member.role === 'owner' ? 'badge-primary' :
|
||||
member.role === 'admin' ? 'badge-secondary' :
|
||||
'badge-ghost'
|
||||
@@ -93,15 +82,15 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
{member.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
<td class="text-base-content/40">{member.joinedAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||
{isAdmin && (
|
||||
<td>
|
||||
{teamUser.id !== user.id && member.role !== 'owner' && (
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-1 menu p-2 shadow bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<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 z-1 menu p-2 bg-base-100 rounded-box w-52 border border-base-200">
|
||||
<li>
|
||||
<form method="POST" action={`/api/team/change-role`}>
|
||||
<input type="hidden" name="userId" value={teamUser.id} />
|
||||
|
||||
@@ -29,45 +29,39 @@ if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
|
||||
<DashboardLayout title="Invite Team Member - Chronus">
|
||||
<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">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/api/team/invite" class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="alert alert-info mb-4">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<span>The user must already have an account. They'll be added to your organization.</span>
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4 shrink-0" />
|
||||
<span class="text-sm">The user must already have an account. They'll be added to your organization.</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text">Email Address</span>
|
||||
</label>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email Address</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="user@example.com"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="role">
|
||||
<span class="label-text">Role</span>
|
||||
</label>
|
||||
<select id="role" name="role" class="select select-bordered" required>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Role</legend>
|
||||
<select id="role" name="role" class="select" required>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Members can track time. Admins can manage team and clients.</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/40 mt-1">Members can track time. Admins can manage team and clients.</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/team" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Invite Member</button>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<a href="/dashboard/team" class="btn btn-ghost btn-sm">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Invite Member</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,26 +2,15 @@
|
||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../../../db';
|
||||
import { categories, members, organizations } from '../../../db/schema';
|
||||
import { organizations, tags } from '../../../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||
@@ -35,9 +24,11 @@ const organization = await db.select()
|
||||
|
||||
if (!organization) return Astro.redirect('/dashboard');
|
||||
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, orgId))
|
||||
|
||||
|
||||
const allTags = await db.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.organizationId, orgId))
|
||||
.all();
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
@@ -46,50 +37,188 @@ const successType = url.searchParams.get('success');
|
||||
|
||||
<DashboardLayout title="Team Settings - Chronus">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<a href="/dashboard/team" class="btn btn-ghost btn-sm">
|
||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||
<a href="/dashboard/team" class="btn btn-ghost btn-xs">
|
||||
<Icon name="heroicons:arrow-left" class="w-4 h-4" />
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold">Team Settings</h1>
|
||||
<h1 class="text-2xl font-extrabold tracking-tight">Team Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Team Settings -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
<Icon name="heroicons:building-office-2" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
|
||||
<Icon name="heroicons:building-office-2" class="w-4 h-4" />
|
||||
Team Settings
|
||||
</h2>
|
||||
|
||||
{successType === 'org-name' && (
|
||||
<div class="alert alert-success mb-4">
|
||||
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||
<span>Team name updated successfully!</span>
|
||||
<Icon name="heroicons:check-circle" class="w-4 h-4" />
|
||||
<span class="text-sm">Team information updated successfully!</span>
|
||||
</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} />
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Team Name</span>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Team Logo</legend>
|
||||
<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>
|
||||
<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
|
||||
type="text"
|
||||
id="team-name"
|
||||
name="name"
|
||||
value={organization.name}
|
||||
placeholder="Organization name"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span>
|
||||
</div>
|
||||
</label>
|
||||
<p class="text-xs text-base-content/40 mt-1">This name is visible to all team members</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
<div class="divider text-xs text-base-content/40 my-2">Address Information</div>
|
||||
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -97,60 +226,142 @@ const successType = url.searchParams.get('success');
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories Section -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||
<div class="card-body">
|
||||
<!-- Tags Section -->
|
||||
<div class="card card-border bg-base-100 mb-6">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:tag" class="w-6 h-6" />
|
||||
Work Categories
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:tag" class="w-4 h-4" />
|
||||
Tags & Rates
|
||||
</h2>
|
||||
<a href="/dashboard/team/settings/categories/new" class="btn btn-primary btn-sm">
|
||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
||||
Add Category
|
||||
</a>
|
||||
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-xs">
|
||||
<Icon name="heroicons:plus" class="w-3 h-3" />
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Categories help organize time tracking by type of work. All team members use the same categories.
|
||||
<p class="text-base-content/60 text-xs mb-4">
|
||||
Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes.
|
||||
</p>
|
||||
|
||||
{allCategories.length === 0 ? (
|
||||
{allTags.length === 0 ? (
|
||||
<div class="alert alert-info">
|
||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
||||
<Icon name="heroicons:information-circle" class="w-4 h-4" />
|
||||
<div>
|
||||
<div class="font-bold">No categories yet</div>
|
||||
<div class="text-sm">Create your first category to start organizing time entries.</div>
|
||||
<div class="font-semibold text-sm">No tags yet</div>
|
||||
<div class="text-xs">Create tags to add context and rates to your time entries.</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{allCategories.map(category => (
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{category.color && (
|
||||
<span class="w-4 h-4 rounded-full shrink-0" style={`background-color: ${category.color}`}></span>
|
||||
)}
|
||||
<div class="grow min-w-0">
|
||||
<h3 class="font-semibold truncate">{category.name}</h3>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Created {category.createdAt?.toLocaleDateString() ?? 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href={`/dashboard/team/settings/categories/${category.id}/edit`}
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Rate / Hr</th>
|
||||
<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>
|
||||
)}
|
||||
<span class="font-medium">{tag.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{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-3 h-3" />
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="new_tag_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -2,28 +2,18 @@
|
||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import Timer from '../../components/Timer.vue';
|
||||
import ManualEntry from '../../components/ManualEntry.vue';
|
||||
import { db } from '../../db';
|
||||
import { timeEntries, clients, members, tags, timeEntryTags, categories, users } from '../../db/schema';
|
||||
import { timeEntries, clients, tags, users } from '../../db/schema';
|
||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||
import { formatTimeRange } from '../../lib/formatTime';
|
||||
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||
|
||||
const user = Astro.locals.user;
|
||||
if (!user) return Astro.redirect('/login');
|
||||
|
||||
// Get current team from cookie
|
||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||
|
||||
const userMemberships = await db.select()
|
||||
.from(members)
|
||||
.where(eq(members.userId, user.id))
|
||||
.all();
|
||||
|
||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||
|
||||
// Use current team or fallback to first membership
|
||||
const userMembership = currentTeamId
|
||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||
: userMemberships[0];
|
||||
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||
if (!userMembership) return Astro.redirect('/dashboard');
|
||||
|
||||
const organizationId = userMembership.organizationId;
|
||||
|
||||
@@ -32,11 +22,6 @@ const allClients = await db.select()
|
||||
.where(eq(clients.organizationId, organizationId))
|
||||
.all();
|
||||
|
||||
const allCategories = await db.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.organizationId, organizationId))
|
||||
.all();
|
||||
|
||||
const allTags = await db.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.organizationId, organizationId))
|
||||
@@ -49,8 +34,9 @@ const pageSize = 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const filterClient = url.searchParams.get('client') || '';
|
||||
const filterCategory = url.searchParams.get('category') || '';
|
||||
|
||||
const filterStatus = url.searchParams.get('status') || '';
|
||||
const filterType = url.searchParams.get('type') || '';
|
||||
const sortBy = url.searchParams.get('sort') || 'start-desc';
|
||||
const searchTerm = url.searchParams.get('search') || '';
|
||||
|
||||
@@ -60,10 +46,6 @@ if (filterClient) {
|
||||
conditions.push(eq(timeEntries.clientId, filterClient));
|
||||
}
|
||||
|
||||
if (filterCategory) {
|
||||
conditions.push(eq(timeEntries.categoryId, filterCategory));
|
||||
}
|
||||
|
||||
if (filterStatus === 'completed') {
|
||||
conditions.push(sql`${timeEntries.endTime} IS NOT NULL`);
|
||||
} else if (filterStatus === 'running') {
|
||||
@@ -74,6 +56,12 @@ if (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(*)` })
|
||||
.from(timeEntries)
|
||||
.where(and(...conditions))
|
||||
@@ -99,13 +87,13 @@ switch (sortBy) {
|
||||
const entries = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
category: categories,
|
||||
user: users,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
|
||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderBy)
|
||||
.limit(pageSize)
|
||||
@@ -115,9 +103,11 @@ const entries = await db.select({
|
||||
const runningEntry = await db.select({
|
||||
entry: timeEntries,
|
||||
client: clients,
|
||||
tag: tags,
|
||||
})
|
||||
.from(timeEntries)
|
||||
.leftJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||
.where(and(
|
||||
eq(timeEntries.userId, user.id),
|
||||
sql`${timeEntries.endTime} IS NULL`
|
||||
@@ -127,7 +117,7 @@ const runningEntry = await db.select({
|
||||
function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
||||
const pages: number[] = [];
|
||||
const numPagesToShow = Math.min(5, totalPages);
|
||||
|
||||
|
||||
for (let i = 0; i < numPagesToShow; i++) {
|
||||
let pageNum;
|
||||
if (totalPages <= 5) {
|
||||
@@ -141,7 +131,7 @@ function getPaginationPages(currentPage: number, totalPages: number): number[] {
|
||||
}
|
||||
pages.push(pageNum);
|
||||
}
|
||||
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
@@ -149,55 +139,74 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
---
|
||||
|
||||
<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 ? (
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<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 tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
) : (
|
||||
<Timer
|
||||
client:load
|
||||
initialRunningEntry={runningEntry ? {
|
||||
startTime: runningEntry.entry.startTime.getTime(),
|
||||
description: runningEntry.entry.description,
|
||||
clientId: runningEntry.entry.clientId,
|
||||
tagId: runningEntry.tag?.id,
|
||||
} : null}
|
||||
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<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 ? (
|
||||
<div class="alert alert-warning mb-6">
|
||||
<span>You need to create a client before tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||
</div>
|
||||
) : allCategories.length === 0 ? (
|
||||
<div class="alert alert-warning mb-6">
|
||||
<span>You need to create a category before tracking time.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||
</div>
|
||||
) : (
|
||||
<Timer
|
||||
client:load
|
||||
initialRunningEntry={runningEntry ? {
|
||||
startTime: runningEntry.entry.startTime.getTime(),
|
||||
description: runningEntry.entry.description,
|
||||
clientId: runningEntry.entry.clientId,
|
||||
categoryId: runningEntry.entry.categoryId,
|
||||
} : null}
|
||||
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
|
||||
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
|
||||
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}
|
||||
/>
|
||||
)}
|
||||
<!-- If no clients/categories, show nothing extra here since tabs handle warnings -->
|
||||
) : null}
|
||||
|
||||
<!-- 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"
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered" onchange="this.form.submit()">
|
||||
<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>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={filterClient === client.id}>
|
||||
@@ -205,49 +214,40 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={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()">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Status</legend>
|
||||
<select id="tracker-status" name="status" class="select w-full" onchange="this.form.submit()">
|
||||
<option value="" selected={filterStatus === ''}>All Entries</option>
|
||||
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
||||
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Sort By</span>
|
||||
</label>
|
||||
<select name="sort" class="select select-bordered" onchange="this.form.submit()">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Entry Type</legend>
|
||||
<select id="tracker-type" name="type" class="select w-full" onchange="this.form.submit()">
|
||||
<option value="" selected={filterType === ''}>All Types</option>
|
||||
<option value="timed" selected={filterType === 'timed'}>Timed</option>
|
||||
<option value="manual" selected={filterType === 'manual'}>Manual</option>
|
||||
</select>
|
||||
</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-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
||||
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
||||
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<div class="form-control md:col-span-2 lg:col-span-5">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" />
|
||||
<div class="flex items-end md:col-span-2 lg:col-span-1">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-full">
|
||||
<Icon name="heroicons:magnifying-glass" class="w-4 h-4" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
@@ -255,26 +255,26 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">
|
||||
<Icon name="heroicons:list-bullet" class="w-6 h-6" />
|
||||
<div class="card card-border bg-base-100">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon name="heroicons:list-bullet" class="w-4 h-4" />
|
||||
Time Entries ({totalCount?.count || 0} total)
|
||||
</h2>
|
||||
{(filterClient || filterCategory || filterStatus || searchTerm) && (
|
||||
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost">
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
{(filterClient || filterStatus || filterType || searchTerm) && (
|
||||
<a href="/dashboard/tracker" class="btn btn-xs btn-ghost">
|
||||
<Icon name="heroicons:x-mark" class="w-3 h-3" />
|
||||
Clear Filters
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Client</th>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
<th>Member</th>
|
||||
<th>Start Time</th>
|
||||
@@ -284,22 +284,27 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(({ entry, client, category, user: entryUser }) => (
|
||||
<tr>
|
||||
<td>{client?.name || 'Unknown'}</td>
|
||||
{entries.map(({ entry, client, user: entryUser }) => (
|
||||
<tr class="hover">
|
||||
<td>
|
||||
{category ? (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${category.color}`}></span>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
) : '-'}
|
||||
{entry.isManual ? (
|
||||
<span class="badge badge-info badge-xs gap-1" title="Manual Entry">
|
||||
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||
Manual
|
||||
</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>{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 class="whitespace-nowrap">
|
||||
{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'})}
|
||||
</span>
|
||||
</td>
|
||||
@@ -307,23 +312,23 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
{entry.endTime ? (
|
||||
<>
|
||||
{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'})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="badge badge-success">Running</span>
|
||||
<span class="badge badge-success badge-xs">Running</span>
|
||||
)}
|
||||
</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>
|
||||
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
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>
|
||||
</form>
|
||||
</td>
|
||||
@@ -335,32 +340,32 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
|
||||
<!-- Pagination -->
|
||||
{totalPages > 1 && (
|
||||
<div class="flex justify-center items-center gap-2 mt-6">
|
||||
<a
|
||||
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm ${page === 1 ? 'btn-disabled' : ''}`}
|
||||
<div class="flex justify-center items-center gap-1 mt-4">
|
||||
<a
|
||||
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-xs ${page === 1 ? 'btn-disabled' : ''}`}
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
|
||||
Previous
|
||||
<Icon name="heroicons:chevron-left" class="w-3 h-3" />
|
||||
Prev
|
||||
</a>
|
||||
|
||||
<div class="flex gap-1">
|
||||
|
||||
<div class="flex gap-0.5">
|
||||
{paginationPages.map(pageNum => (
|
||||
<a
|
||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm ${page === pageNum ? 'btn-active' : ''}`}
|
||||
<a
|
||||
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-xs ${page === pageNum ? 'btn-active' : ''}`}
|
||||
>
|
||||
{pageNum}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterCategory ? `&category=${filterCategory}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
|
||||
class={`btn btn-sm ${page === totalPages ? 'btn-disabled' : ''}`}
|
||||
<a
|
||||
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-xs ${page === totalPages ? 'btn-disabled' : ''}`}
|
||||
>
|
||||
Next
|
||||
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
|
||||
<Icon name="heroicons:chevron-right" class="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,48 +7,64 @@ if (Astro.locals.user) {
|
||||
---
|
||||
|
||||
<Layout title="Chronus - Time Tracking">
|
||||
<div class="hero h-full bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-4xl">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
||||
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
|
||||
Chronus
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- Hero -->
|
||||
<div class="flex-1 flex items-center justify-center px-4 py-16 sm:py-24 bg-base-100">
|
||||
<div class="max-w-3xl text-center">
|
||||
<img src="/logo.webp" alt="Chronus Logo" class="h-20 w-20 mx-auto mb-8" />
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center mt-8 flex-wrap">
|
||||
<a href="/signup" class="btn btn-primary btn-lg">
|
||||
<div class="flex gap-3 justify-center flex-wrap">
|
||||
<a href="/signup" class="btn btn-primary btn-lg px-8">
|
||||
Get Started
|
||||
<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" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<!-- Feature highlights -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16">
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body items-start">
|
||||
<div class="text-4xl mb-3">⚡</div>
|
||||
<h3 class="card-title text-lg">Lightning Fast</h3>
|
||||
<p class="text-sm text-base-content/70">Track tasks with a single click.</p>
|
||||
<!-- Features -->
|
||||
<div class="bg-base-200/50 border-t border-base-200 px-4 py-16 sm:py-20">
|
||||
<div class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="card bg-base-100 card-border">
|
||||
<div class="card-body">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center mb-2">
|
||||
<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 class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body items-start">
|
||||
<div class="text-4xl mb-3">📊</div>
|
||||
<h3 class="card-title text-lg">Detailed Reports</h3>
|
||||
<p class="text-sm text-base-content/70">Get actionable insights into your team's tasks.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 card-border">
|
||||
<div class="card-body">
|
||||
<div class="w-10 h-10 rounded-lg bg-secondary/10 flex items-center justify-center mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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 class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body items-start">
|
||||
<div class="text-4xl mb-3">👥</div>
|
||||
<h3 class="card-title text-lg">Team Collaboration</h3>
|
||||
<p class="text-sm text-base-content/70">Built for multiple team members.</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 card-border">
|
||||
<div class="card-body">
|
||||
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-accent" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -1,57 +1,74 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
|
||||
|
||||
if (Astro.locals.user) {
|
||||
return Astro.redirect('/dashboard');
|
||||
}
|
||||
|
||||
const error = Astro.url.searchParams.get('error');
|
||||
const errorMessage =
|
||||
error === 'invalid_credentials'
|
||||
? 'Invalid email or password'
|
||||
: error === 'missing_fields'
|
||||
? 'Please fill in all fields'
|
||||
: null;
|
||||
---
|
||||
|
||||
<Layout title="Login - Chronus">
|
||||
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||
<div class="card-body">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
||||
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
||||
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||
<div class="card card-border bg-base-100 w-full max-w-sm mx-4">
|
||||
<div class="card-body gap-0">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
|
||||
<h2 class="text-2xl font-extrabold tracking-tight text-center">Welcome Back</h2>
|
||||
<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">
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Email</span>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<form action="/api/auth/login" method="POST" class="space-y-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Password</span>
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Password</legend>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Enter your password"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="current-password"
|
||||
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>
|
||||
|
||||
<div class="divider">OR</div>
|
||||
<PasskeyLogin client:idle />
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="link link-primary font-semibold">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider text-xs">OR</div>
|
||||
|
||||
<p class="text-center text-sm text-base-content/60">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="link link-primary font-semibold">Create one</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,83 +20,96 @@ if (!isFirstUser) {
|
||||
.get();
|
||||
registrationDisabled = registrationSetting?.value !== 'true';
|
||||
}
|
||||
|
||||
const error = Astro.url.searchParams.get('error');
|
||||
const errorMessage =
|
||||
error === 'user_exists'
|
||||
? 'An account with this email already exists'
|
||||
: error === 'missing_fields'
|
||||
? 'Please fill in all fields'
|
||||
: error === 'registration_disabled'
|
||||
? 'Registration is currently disabled'
|
||||
: null;
|
||||
---
|
||||
|
||||
<Layout title="Sign Up - Chronus">
|
||||
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||
<div class="card-body">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
||||
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
||||
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||
<div class="card card-border bg-base-100 w-full max-w-sm mx-4">
|
||||
<div class="card-body gap-0">
|
||||
<img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
|
||||
<h2 class="text-2xl font-extrabold tracking-tight text-center">Create Account</h2>
|
||||
<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 ? (
|
||||
<>
|
||||
<div class="alert alert-warning">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" />
|
||||
<div class="alert alert-warning text-sm">
|
||||
<Icon name="heroicons:exclamation-triangle" class="w-5 h-5" />
|
||||
<span>Registration is currently disabled by the site administrator.</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider text-xs"></div>
|
||||
<p class="text-center text-sm text-base-content/60">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<form action="/api/auth/signup" method="POST" class="space-y-4">
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Full Name</span>
|
||||
</div>
|
||||
<form action="/api/auth/signup" method="POST" class="space-y-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Full Name</legend>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="John Doe"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="name"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Email</span>
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Email</legend>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="your@email.com"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Password</span>
|
||||
</div>
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend text-xs">Password</legend>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Create a strong password"
|
||||
class="input input-bordered w-full"
|
||||
class="input w-full"
|
||||
autocomplete="new-password"
|
||||
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>
|
||||
|
||||
<div class="divider">OR</div>
|
||||
<div class="divider text-xs">OR</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-center text-sm text-base-content/60">
|
||||
Already have an account?
|
||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
68
src/pages/uploads/[...path].ts
Normal file
68
src/pages/uploads/[...path].ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
564
src/pdf/generateInvoicePDF.ts
Normal file
564
src/pdf/generateInvoicePDF.ts
Normal 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
Reference in New Issue
Block a user