Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
76d3e0cd41
|
|||
|
42492be284
|
|||
|
cd6ececa27
|
|||
|
be5dafe539
|
|||
|
6233380682
|
|||
|
e99e042eea
|
|||
|
705358d44c
|
|||
|
44de064d68
|
|||
|
5f7b36582c
|
|||
|
25c9d77599
|
|||
|
3e17e58c9a
|
|||
|
e5c5d68739
|
|||
|
c7d880e09d
|
|||
|
4666bc42cf
|
|||
|
1c70626f5a
|
|||
|
caf763aa1e
|
|||
|
12d59bb42f
|
|||
|
c39865031a
|
|||
|
abbf39f160
|
|||
|
e2949a28ef
|
|||
|
8b91ec7a71
|
|||
|
815c08dd50
|
|||
|
55eb03165e
|
|||
|
a4071d6e40
|
|||
|
fff0e14a4b
|
|||
|
ad7dc18780
|
|||
|
de5b1063b7
|
|||
|
82b45fdfe4
|
|||
|
b5ac2e0608
|
|||
|
6bed4b4709
|
|||
|
54cac49b70
|
|||
|
effc6ac37e
|
|||
|
df82a02f41
|
|||
|
8a3932a013
|
@@ -2,3 +2,5 @@ DATA_DIR=./data
|
|||||||
ROOT_DIR=./data
|
ROOT_DIR=./data
|
||||||
APP_PORT=4321
|
APP_PORT=4321
|
||||||
IMAGE=git.atri.dad/atash/chronus:latest
|
IMAGE=git.atri.dad/atash/chronus:latest
|
||||||
|
JWT_SECRET=some-secret
|
||||||
|
ORIGIN=https://chronus.example.com
|
||||||
|
|||||||
@@ -12,20 +12,20 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ secrets.REPO_HOST }}
|
registry: ${{ secrets.REPO_HOST }}
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.DEPLOY_TOKEN }}
|
password: ${{ secrets.DEPLOY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
@@ -33,3 +33,6 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
|
||||||
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
|
||||||
|
provenance: false
|
||||||
|
cache-from: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:buildcache,mode=max
|
||||||
29
Dockerfile
@@ -1,35 +1,28 @@
|
|||||||
FROM node:lts-alpine AS base
|
FROM oven/bun:1.3.9-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN npm i -g pnpm
|
|
||||||
|
|
||||||
FROM base AS prod-deps
|
FROM base AS prod-deps
|
||||||
WORKDIR /app
|
COPY package.json bun.lock ./
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
|
||||||
COPY package.json pnpm-lock.yaml ./
|
bun install --production --frozen-lockfile || bun install --production
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
|
||||||
|
|
||||||
FROM base AS build-deps
|
FROM base AS builder
|
||||||
WORKDIR /app
|
COPY package.json bun.lock ./
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN --mount=type=cache,id=bun,target=/root/.bun/install/cache \
|
||||||
COPY package.json pnpm-lock.yaml ./
|
bun install --frozen-lockfile || bun install
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
FROM build-deps AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build
|
RUN bun run build
|
||||||
|
|
||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/drizzle ./drizzle
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/scripts ./scripts
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
EXPOSE 4321
|
EXPOSE 4321
|
||||||
|
|
||||||
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"]
|
CMD ["bun", "run", "./dist/server/entry.mjs"]
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import vue from "@astrojs/vue";
|
import vue from "@astrojs/vue";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import icon from "astro-icon";
|
|
||||||
|
|
||||||
import node from "@astrojs/node";
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
output: "server",
|
output: "server",
|
||||||
integrations: [vue(), icon()],
|
integrations: [vue()],
|
||||||
|
security: {
|
||||||
|
checkOrigin: false,
|
||||||
|
csp: process.env.NODE_ENV === "production",
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
|
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
mode: "standalone",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ services:
|
|||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- PORT=4321
|
- PORT=4321
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- ORIGIN=${ORIGIN}
|
||||||
volumes:
|
volumes:
|
||||||
- ${ROOT_DIR}:/app/data
|
- ${ROOT_DIR}:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -10,24 +10,23 @@ CREATE TABLE `api_tokens` (
|
|||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `api_tokens_token_unique` ON `api_tokens` (`token`);--> statement-breakpoint
|
CREATE UNIQUE INDEX `api_tokens_token_unique` ON `api_tokens` (`token`);--> statement-breakpoint
|
||||||
CREATE TABLE `categories` (
|
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`organization_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`color` text,
|
|
||||||
`created_at` integer,
|
|
||||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE `clients` (
|
CREATE TABLE `clients` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`organization_id` text NOT NULL,
|
`organization_id` text NOT NULL,
|
||||||
`name` text NOT NULL,
|
`name` text NOT NULL,
|
||||||
`email` text,
|
`email` text,
|
||||||
|
`phone` text,
|
||||||
|
`street` text,
|
||||||
|
`city` text,
|
||||||
|
`state` text,
|
||||||
|
`zip` text,
|
||||||
|
`country` text,
|
||||||
`created_at` integer,
|
`created_at` integer,
|
||||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`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
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
|
||||||
CREATE TABLE `invoice_items` (
|
CREATE TABLE `invoice_items` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`invoice_id` text NOT NULL,
|
`invoice_id` text NOT NULL,
|
||||||
@@ -38,6 +37,7 @@ CREATE TABLE `invoice_items` (
|
|||||||
FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
|
||||||
CREATE TABLE `invoices` (
|
CREATE TABLE `invoices` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`organization_id` text NOT NULL,
|
`organization_id` text NOT NULL,
|
||||||
@@ -50,6 +50,9 @@ CREATE TABLE `invoices` (
|
|||||||
`notes` text,
|
`notes` text,
|
||||||
`currency` text DEFAULT 'USD' NOT NULL,
|
`currency` text DEFAULT 'USD' NOT NULL,
|
||||||
`subtotal` integer DEFAULT 0 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_rate` real DEFAULT 0,
|
||||||
`tax_amount` integer DEFAULT 0 NOT NULL,
|
`tax_amount` integer DEFAULT 0 NOT NULL,
|
||||||
`total` integer DEFAULT 0 NOT NULL,
|
`total` integer DEFAULT 0 NOT NULL,
|
||||||
@@ -58,6 +61,8 @@ CREATE TABLE `invoices` (
|
|||||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `members` (
|
||||||
`user_id` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
`organization_id` text NOT NULL,
|
`organization_id` text NOT NULL,
|
||||||
@@ -68,6 +73,8 @@ CREATE TABLE `members` (
|
|||||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `organizations` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`name` text NOT NULL,
|
`name` text NOT NULL,
|
||||||
@@ -77,9 +84,33 @@ CREATE TABLE `organizations` (
|
|||||||
`state` text,
|
`state` text,
|
||||||
`zip` text,
|
`zip` text,
|
||||||
`country` text,
|
`country` text,
|
||||||
|
`default_tax_rate` real DEFAULT 0,
|
||||||
|
`default_currency` text DEFAULT 'USD',
|
||||||
`created_at` integer
|
`created_at` integer
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `sessions` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`user_id` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
@@ -87,6 +118,7 @@ CREATE TABLE `sessions` (
|
|||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
|
||||||
CREATE TABLE `site_settings` (
|
CREATE TABLE `site_settings` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`key` text NOT NULL,
|
`key` text NOT NULL,
|
||||||
@@ -100,27 +132,33 @@ CREATE TABLE `tags` (
|
|||||||
`organization_id` text NOT NULL,
|
`organization_id` text NOT NULL,
|
||||||
`name` text NOT NULL,
|
`name` text NOT NULL,
|
||||||
`color` text,
|
`color` text,
|
||||||
|
`rate` integer DEFAULT 0,
|
||||||
`created_at` integer,
|
`created_at` integer,
|
||||||
FOREIGN KEY (`organization_id`) REFERENCES `organizations`(`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
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
|
||||||
CREATE TABLE `time_entries` (
|
CREATE TABLE `time_entries` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`user_id` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
`organization_id` text NOT NULL,
|
`organization_id` text NOT NULL,
|
||||||
`client_id` text NOT NULL,
|
`client_id` text NOT NULL,
|
||||||
`category_id` text NOT NULL,
|
|
||||||
`start_time` integer NOT NULL,
|
`start_time` integer NOT NULL,
|
||||||
`end_time` integer,
|
`end_time` integer,
|
||||||
`description` text,
|
`description` text,
|
||||||
|
`invoice_id` text,
|
||||||
`is_manual` integer DEFAULT false,
|
`is_manual` integer DEFAULT false,
|
||||||
`created_at` integer,
|
`created_at` integer,
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
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 (`organization_id`) REFERENCES `organizations`(`id`) ON UPDATE no action ON DELETE no action,
|
||||||
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action,
|
FOREIGN KEY (`client_id`) REFERENCES `clients`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
FOREIGN KEY (`category_id`) REFERENCES `categories`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `time_entry_tags` (
|
||||||
`time_entry_id` text NOT NULL,
|
`time_entry_id` text NOT NULL,
|
||||||
`tag_id` text NOT NULL,
|
`tag_id` text NOT NULL,
|
||||||
@@ -129,6 +167,8 @@ CREATE TABLE `time_entry_tags` (
|
|||||||
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE no action
|
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `users` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`email` text NOT NULL,
|
`email` text NOT NULL,
|
||||||
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`);
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
ALTER TABLE `clients` ADD `phone` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `clients` ADD `street` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `clients` ADD `city` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `clients` ADD `state` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `clients` ADD `zip` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `clients` ADD `country` text;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `categories_organization_id_idx` ON `categories` (`organization_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoices_organization_id_idx` ON `invoices` (`organization_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `time_entries_user_id_idx` ON `time_entries` (`user_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `time_entries_organization_id_idx` ON `time_entries` (`organization_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `time_entries_client_id_idx` ON `time_entries` (`client_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `time_entries_start_time_idx` ON `time_entries` (`start_time`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `time_entry_tags_time_entry_id_idx` ON `time_entry_tags` (`time_entry_id`);--> statement-breakpoint
|
|
||||||
CREATE INDEX `time_entry_tags_tag_id_idx` ON `time_entry_tags` (`tag_id`);
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE `invoices` ADD `discount_value` real DEFAULT 0;--> statement-breakpoint
|
|
||||||
ALTER TABLE `invoices` ADD `discount_type` text DEFAULT 'percentage';--> statement-breakpoint
|
|
||||||
ALTER TABLE `invoices` ADD `discount_amount` integer DEFAULT 0;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
CREATE TABLE `passkey_challenges` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`challenge` text NOT NULL,
|
|
||||||
`user_id` text,
|
|
||||||
`expires_at` integer NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `passkey_challenges_challenge_unique` ON `passkey_challenges` (`challenge`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `passkeys` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`user_id` text NOT NULL,
|
|
||||||
`public_key` text NOT NULL,
|
|
||||||
`counter` integer NOT NULL,
|
|
||||||
`device_type` text NOT NULL,
|
|
||||||
`backed_up` integer NOT NULL,
|
|
||||||
`transports` text,
|
|
||||||
`last_used_at` integer,
|
|
||||||
`created_at` integer,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `passkeys_user_id_idx` ON `passkeys` (`user_id`);
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
|
"id": "8343b003-264b-444a-9782-07d736dd3407",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"api_tokens": {
|
"api_tokens": {
|
||||||
@@ -65,6 +65,13 @@
|
|||||||
"token"
|
"token"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"api_tokens_user_id_idx": {
|
||||||
|
"name": "api_tokens_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
@@ -86,65 +93,6 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"categories": {
|
|
||||||
"name": "categories",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"organization_id": {
|
|
||||||
"name": "organization_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"color": {
|
|
||||||
"name": "color",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {
|
|
||||||
"categories_organization_id_organizations_id_fk": {
|
|
||||||
"name": "categories_organization_id_organizations_id_fk",
|
|
||||||
"tableFrom": "categories",
|
|
||||||
"tableTo": "organizations",
|
|
||||||
"columnsFrom": [
|
|
||||||
"organization_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
},
|
|
||||||
"clients": {
|
"clients": {
|
||||||
"name": "clients",
|
"name": "clients",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -176,6 +124,48 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "phone",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"street": {
|
||||||
|
"name": "street",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"name": "city",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"zip": {
|
||||||
|
"name": "zip",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"name": "country",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -184,7 +174,15 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"clients_organization_id_idx": {
|
||||||
|
"name": "clients_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"clients_organization_id_organizations_id_fk": {
|
"clients_organization_id_organizations_id_fk": {
|
||||||
"name": "clients_organization_id_organizations_id_fk",
|
"name": "clients_organization_id_organizations_id_fk",
|
||||||
@@ -253,7 +251,15 @@
|
|||||||
"default": 0
|
"default": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"invoice_items_invoice_id_idx": {
|
||||||
|
"name": "invoice_items_invoice_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"invoice_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"invoice_items_invoice_id_invoices_id_fk": {
|
"invoice_items_invoice_id_invoices_id_fk": {
|
||||||
"name": "invoice_items_invoice_id_invoices_id_fk",
|
"name": "invoice_items_invoice_id_invoices_id_fk",
|
||||||
@@ -357,6 +363,30 @@
|
|||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": 0
|
"default": 0
|
||||||
},
|
},
|
||||||
|
"discount_value": {
|
||||||
|
"name": "discount_value",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"discount_type": {
|
||||||
|
"name": "discount_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'percentage'"
|
||||||
|
},
|
||||||
|
"discount_amount": {
|
||||||
|
"name": "discount_amount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"tax_rate": {
|
"tax_rate": {
|
||||||
"name": "tax_rate",
|
"name": "tax_rate",
|
||||||
"type": "real",
|
"type": "real",
|
||||||
@@ -389,7 +419,22 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"invoices_organization_id_idx": {
|
||||||
|
"name": "invoices_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"invoices_client_id_idx": {
|
||||||
|
"name": "invoices_client_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"client_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"invoices_organization_id_organizations_id_fk": {
|
"invoices_organization_id_organizations_id_fk": {
|
||||||
"name": "invoices_organization_id_organizations_id_fk",
|
"name": "invoices_organization_id_organizations_id_fk",
|
||||||
@@ -455,7 +500,22 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"members_user_id_idx": {
|
||||||
|
"name": "members_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"members_organization_id_idx": {
|
||||||
|
"name": "members_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"members_user_id_users_id_fk": {
|
"members_user_id_users_id_fk": {
|
||||||
"name": "members_user_id_users_id_fk",
|
"name": "members_user_id_users_id_fk",
|
||||||
@@ -555,6 +615,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"default_tax_rate": {
|
||||||
|
"name": "default_tax_rate",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"default_currency": {
|
||||||
|
"name": "default_currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'USD'"
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -569,6 +645,147 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"passkey_challenges": {
|
||||||
|
"name": "passkey_challenges",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"challenge": {
|
||||||
|
"name": "challenge",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"passkey_challenges_challenge_unique": {
|
||||||
|
"name": "passkey_challenges_challenge_unique",
|
||||||
|
"columns": [
|
||||||
|
"challenge"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"passkeys": {
|
||||||
|
"name": "passkeys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"counter": {
|
||||||
|
"name": "counter",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_type": {
|
||||||
|
"name": "device_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"backed_up": {
|
||||||
|
"name": "backed_up",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"transports": {
|
||||||
|
"name": "transports",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"passkeys_user_id_idx": {
|
||||||
|
"name": "passkeys_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"passkeys_user_id_users_id_fk": {
|
||||||
|
"name": "passkeys_user_id_users_id_fk",
|
||||||
|
"tableFrom": "passkeys",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"name": "sessions",
|
"name": "sessions",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -594,7 +811,15 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"sessions_user_id_users_id_fk": {
|
"sessions_user_id_users_id_fk": {
|
||||||
"name": "sessions_user_id_users_id_fk",
|
"name": "sessions_user_id_users_id_fk",
|
||||||
@@ -691,6 +916,14 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"rate": {
|
||||||
|
"name": "rate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -699,7 +932,15 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"tags_organization_id_idx": {
|
||||||
|
"name": "tags_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"tags_organization_id_organizations_id_fk": {
|
"tags_organization_id_organizations_id_fk": {
|
||||||
"name": "tags_organization_id_organizations_id_fk",
|
"name": "tags_organization_id_organizations_id_fk",
|
||||||
@@ -750,13 +991,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"category_id": {
|
|
||||||
"name": "category_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"start_time": {
|
"start_time": {
|
||||||
"name": "start_time",
|
"name": "start_time",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -778,6 +1012,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"invoice_id": {
|
||||||
|
"name": "invoice_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"is_manual": {
|
"is_manual": {
|
||||||
"name": "is_manual",
|
"name": "is_manual",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -794,7 +1035,43 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"time_entries_user_id_idx": {
|
||||||
|
"name": "time_entries_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_organization_id_idx": {
|
||||||
|
"name": "time_entries_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_client_id_idx": {
|
||||||
|
"name": "time_entries_client_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"client_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_start_time_idx": {
|
||||||
|
"name": "time_entries_start_time_idx",
|
||||||
|
"columns": [
|
||||||
|
"start_time"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_invoice_id_idx": {
|
||||||
|
"name": "time_entries_invoice_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"invoice_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"time_entries_user_id_users_id_fk": {
|
"time_entries_user_id_users_id_fk": {
|
||||||
"name": "time_entries_user_id_users_id_fk",
|
"name": "time_entries_user_id_users_id_fk",
|
||||||
@@ -834,19 +1111,6 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
|
||||||
"time_entries_category_id_categories_id_fk": {
|
|
||||||
"name": "time_entries_category_id_categories_id_fk",
|
|
||||||
"tableFrom": "time_entries",
|
|
||||||
"tableTo": "categories",
|
|
||||||
"columnsFrom": [
|
|
||||||
"category_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
@@ -871,7 +1135,22 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"time_entry_tags_time_entry_id_idx": {
|
||||||
|
"name": "time_entry_tags_time_entry_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"time_entry_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entry_tags_tag_id_idx": {
|
||||||
|
"name": "time_entry_tags_tag_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"time_entry_tags_time_entry_id_time_entries_id_fk": {
|
"time_entry_tags_time_entry_id_time_entries_id_fk": {
|
||||||
"name": "time_entry_tags_time_entry_id_time_entries_id_fk",
|
"name": "time_entry_tags_time_entry_id_time_entries_id_fk",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "5483c77e-e742-4fbd-8494-d6f9c6c9e28a",
|
"id": "837a4e18-b319-465d-9e30-2614b4850fb5",
|
||||||
"prevId": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
|
"prevId": "8343b003-264b-444a-9782-07d736dd3407",
|
||||||
"tables": {
|
"tables": {
|
||||||
"api_tokens": {
|
"api_tokens": {
|
||||||
"name": "api_tokens",
|
"name": "api_tokens",
|
||||||
@@ -65,6 +65,13 @@
|
|||||||
"token"
|
"token"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"api_tokens_user_id_idx": {
|
||||||
|
"name": "api_tokens_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
@@ -86,65 +93,6 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"categories": {
|
|
||||||
"name": "categories",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"organization_id": {
|
|
||||||
"name": "organization_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"color": {
|
|
||||||
"name": "color",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "integer",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {
|
|
||||||
"categories_organization_id_organizations_id_fk": {
|
|
||||||
"name": "categories_organization_id_organizations_id_fk",
|
|
||||||
"tableFrom": "categories",
|
|
||||||
"tableTo": "organizations",
|
|
||||||
"columnsFrom": [
|
|
||||||
"organization_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
},
|
|
||||||
"clients": {
|
"clients": {
|
||||||
"name": "clients",
|
"name": "clients",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -226,7 +174,15 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"clients_organization_id_idx": {
|
||||||
|
"name": "clients_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"clients_organization_id_organizations_id_fk": {
|
"clients_organization_id_organizations_id_fk": {
|
||||||
"name": "clients_organization_id_organizations_id_fk",
|
"name": "clients_organization_id_organizations_id_fk",
|
||||||
@@ -295,7 +251,15 @@
|
|||||||
"default": 0
|
"default": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"invoice_items_invoice_id_idx": {
|
||||||
|
"name": "invoice_items_invoice_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"invoice_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"invoice_items_invoice_id_invoices_id_fk": {
|
"invoice_items_invoice_id_invoices_id_fk": {
|
||||||
"name": "invoice_items_invoice_id_invoices_id_fk",
|
"name": "invoice_items_invoice_id_invoices_id_fk",
|
||||||
@@ -399,6 +363,30 @@
|
|||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": 0
|
"default": 0
|
||||||
},
|
},
|
||||||
|
"discount_value": {
|
||||||
|
"name": "discount_value",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"discount_type": {
|
||||||
|
"name": "discount_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'percentage'"
|
||||||
|
},
|
||||||
|
"discount_amount": {
|
||||||
|
"name": "discount_amount",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"tax_rate": {
|
"tax_rate": {
|
||||||
"name": "tax_rate",
|
"name": "tax_rate",
|
||||||
"type": "real",
|
"type": "real",
|
||||||
@@ -431,7 +419,22 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"invoices_organization_id_idx": {
|
||||||
|
"name": "invoices_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"invoices_client_id_idx": {
|
||||||
|
"name": "invoices_client_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"client_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"invoices_organization_id_organizations_id_fk": {
|
"invoices_organization_id_organizations_id_fk": {
|
||||||
"name": "invoices_organization_id_organizations_id_fk",
|
"name": "invoices_organization_id_organizations_id_fk",
|
||||||
@@ -497,7 +500,22 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"members_user_id_idx": {
|
||||||
|
"name": "members_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"members_organization_id_idx": {
|
||||||
|
"name": "members_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"members_user_id_users_id_fk": {
|
"members_user_id_users_id_fk": {
|
||||||
"name": "members_user_id_users_id_fk",
|
"name": "members_user_id_users_id_fk",
|
||||||
@@ -597,6 +615,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"default_tax_rate": {
|
||||||
|
"name": "default_tax_rate",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"default_currency": {
|
||||||
|
"name": "default_currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'USD'"
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -611,6 +645,147 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
"passkey_challenges": {
|
||||||
|
"name": "passkey_challenges",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"challenge": {
|
||||||
|
"name": "challenge",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"passkey_challenges_challenge_unique": {
|
||||||
|
"name": "passkey_challenges_challenge_unique",
|
||||||
|
"columns": [
|
||||||
|
"challenge"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"passkeys": {
|
||||||
|
"name": "passkeys",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"counter": {
|
||||||
|
"name": "counter",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"device_type": {
|
||||||
|
"name": "device_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"backed_up": {
|
||||||
|
"name": "backed_up",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"transports": {
|
||||||
|
"name": "transports",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"passkeys_user_id_idx": {
|
||||||
|
"name": "passkeys_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"passkeys_user_id_users_id_fk": {
|
||||||
|
"name": "passkeys_user_id_users_id_fk",
|
||||||
|
"tableFrom": "passkeys",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
"sessions": {
|
"sessions": {
|
||||||
"name": "sessions",
|
"name": "sessions",
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -636,7 +811,15 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"sessions_user_id_idx": {
|
||||||
|
"name": "sessions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"sessions_user_id_users_id_fk": {
|
"sessions_user_id_users_id_fk": {
|
||||||
"name": "sessions_user_id_users_id_fk",
|
"name": "sessions_user_id_users_id_fk",
|
||||||
@@ -733,6 +916,14 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"rate": {
|
||||||
|
"name": "rate",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -741,7 +932,15 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"tags_organization_id_idx": {
|
||||||
|
"name": "tags_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"tags_organization_id_organizations_id_fk": {
|
"tags_organization_id_organizations_id_fk": {
|
||||||
"name": "tags_organization_id_organizations_id_fk",
|
"name": "tags_organization_id_organizations_id_fk",
|
||||||
@@ -792,11 +991,11 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"category_id": {
|
"tag_id": {
|
||||||
"name": "category_id",
|
"name": "tag_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"start_time": {
|
"start_time": {
|
||||||
@@ -820,6 +1019,13 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"invoice_id": {
|
||||||
|
"name": "invoice_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"is_manual": {
|
"is_manual": {
|
||||||
"name": "is_manual",
|
"name": "is_manual",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -836,7 +1042,50 @@
|
|||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {
|
||||||
|
"time_entries_user_id_idx": {
|
||||||
|
"name": "time_entries_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_organization_id_idx": {
|
||||||
|
"name": "time_entries_organization_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"organization_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_client_id_idx": {
|
||||||
|
"name": "time_entries_client_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"client_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_tag_id_idx": {
|
||||||
|
"name": "time_entries_tag_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"tag_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_start_time_idx": {
|
||||||
|
"name": "time_entries_start_time_idx",
|
||||||
|
"columns": [
|
||||||
|
"start_time"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"time_entries_invoice_id_idx": {
|
||||||
|
"name": "time_entries_invoice_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"invoice_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"time_entries_user_id_users_id_fk": {
|
"time_entries_user_id_users_id_fk": {
|
||||||
"name": "time_entries_user_id_users_id_fk",
|
"name": "time_entries_user_id_users_id_fk",
|
||||||
@@ -877,60 +1126,9 @@
|
|||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
"time_entries_category_id_categories_id_fk": {
|
"time_entries_tag_id_tags_id_fk": {
|
||||||
"name": "time_entries_category_id_categories_id_fk",
|
"name": "time_entries_tag_id_tags_id_fk",
|
||||||
"tableFrom": "time_entries",
|
"tableFrom": "time_entries",
|
||||||
"tableTo": "categories",
|
|
||||||
"columnsFrom": [
|
|
||||||
"category_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"checkConstraints": {}
|
|
||||||
},
|
|
||||||
"time_entry_tags": {
|
|
||||||
"name": "time_entry_tags",
|
|
||||||
"columns": {
|
|
||||||
"time_entry_id": {
|
|
||||||
"name": "time_entry_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"tag_id": {
|
|
||||||
"name": "tag_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"autoincrement": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {
|
|
||||||
"time_entry_tags_time_entry_id_time_entries_id_fk": {
|
|
||||||
"name": "time_entry_tags_time_entry_id_time_entries_id_fk",
|
|
||||||
"tableFrom": "time_entry_tags",
|
|
||||||
"tableTo": "time_entries",
|
|
||||||
"columnsFrom": [
|
|
||||||
"time_entry_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
},
|
|
||||||
"time_entry_tags_tag_id_tags_id_fk": {
|
|
||||||
"name": "time_entry_tags_tag_id_tags_id_fk",
|
|
||||||
"tableFrom": "time_entry_tags",
|
|
||||||
"tableTo": "tags",
|
"tableTo": "tags",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"tag_id"
|
"tag_id"
|
||||||
@@ -942,15 +1140,7 @@
|
|||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {},
|
||||||
"time_entry_tags_time_entry_id_tag_id_pk": {
|
|
||||||
"columns": [
|
|
||||||
"time_entry_id",
|
|
||||||
"tag_id"
|
|
||||||
],
|
|
||||||
"name": "time_entry_tags_time_entry_id_tag_id_pk"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,36 +5,15 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1768688193284,
|
"when": 1768934194146,
|
||||||
"tag": "0000_motionless_king_cobra",
|
"tag": "0000_lazy_rictor",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 1,
|
"idx": 1,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1768690333269,
|
"when": 1768935234392,
|
||||||
"tag": "0001_lazy_roughhouse",
|
"tag": "0001_demonic_red_skull",
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 2,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1768773436601,
|
|
||||||
"tag": "0002_chilly_cyclops",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 3,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1768842088321,
|
|
||||||
"tag": "0003_amusing_wendigo",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 4,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1768876902359,
|
|
||||||
"tag": "0004_happy_namorita",
|
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
45
flake.nix
@@ -5,7 +5,8 @@
|
|||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs }:
|
outputs =
|
||||||
|
{ nixpkgs }:
|
||||||
let
|
let
|
||||||
allSystems = [
|
allSystems = [
|
||||||
"x86_64-linux"
|
"x86_64-linux"
|
||||||
@@ -14,25 +15,33 @@
|
|||||||
"aarch64-darwin"
|
"aarch64-darwin"
|
||||||
];
|
];
|
||||||
|
|
||||||
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
|
forAllSystems =
|
||||||
pkgs = import nixpkgs { inherit system; };
|
f:
|
||||||
});
|
nixpkgs.lib.genAttrs allSystems (
|
||||||
|
system:
|
||||||
|
f {
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
}
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
devShells = forAllSystems ({ pkgs }: {
|
devShells = forAllSystems (
|
||||||
default = pkgs.mkShell {
|
{ pkgs }:
|
||||||
packages = with pkgs; [
|
{
|
||||||
nodejs_24
|
default = pkgs.mkShell {
|
||||||
nodePackages.pnpm
|
packages = with pkgs; [
|
||||||
sqlite
|
nodejs_24
|
||||||
];
|
nodePackages.pnpm
|
||||||
|
sqlite
|
||||||
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
echo "Chronus dev shell"
|
echo "Chronus dev shell"
|
||||||
echo "Node version: $(node --version)"
|
echo "Node version: $(node --version)"
|
||||||
echo "pnpm version: $(pnpm --version)"
|
echo "pnpm version: $(pnpm --version)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
30
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.2.0",
|
"version": "2.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
@@ -12,32 +12,30 @@
|
|||||||
"migrate": "node scripts/migrate.js"
|
"migrate": "node scripts/migrate.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "0.9.6",
|
||||||
"@astrojs/node": "^9.5.2",
|
"@astrojs/node": "9.5.4",
|
||||||
"@astrojs/vue": "^5.1.4",
|
"@astrojs/vue": "5.1.4",
|
||||||
"@ceereals/vue-pdf": "^0.2.1",
|
"@ceereals/vue-pdf": "^0.2.1",
|
||||||
"@iconify/vue": "^5.0.0",
|
|
||||||
"@libsql/client": "^0.17.0",
|
"@libsql/client": "^0.17.0",
|
||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.3",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"astro": "^5.16.11",
|
"astro": "5.18.0",
|
||||||
"astro-icon": "^1.1.5",
|
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.19",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.3.1",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.29",
|
||||||
"vue-chartjs": "^5.3.3"
|
"vue-chartjs": "^5.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@catppuccin/daisyui": "^2.1.1",
|
|
||||||
"@iconify-json/heroicons": "^1.2.3",
|
|
||||||
"@react-pdf/types": "^2.9.2",
|
"@react-pdf/types": "^2.9.2",
|
||||||
"drizzle-kit": "0.31.8"
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"drizzle-kit": "0.31.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7719
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 732 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/logo.webp
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 76 KiB |
12
src/components/AutoSubmit.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
function onChange(e: Event) {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
el.closest('form')?.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span @change="onChange">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -9,7 +9,7 @@ const initial = name ? name.charAt(0).toUpperCase() : '?';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div class:list={["avatar placeholder", className]}>
|
<div class:list={["avatar placeholder", className]}>
|
||||||
<div class="bg-primary text-primary-content w-10 rounded-full flex items-center justify-center">
|
<div class="bg-base-300 text-primary w-9 h-9 rounded-full flex items-center justify-center">
|
||||||
<span class="text-lg font-semibold">{initial}</span>
|
<span class="text-sm font-semibold">{initial}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<template>
|
||||||
<div style="position: relative; height: 100%; width: 100%;">
|
<div style="position: relative; height: 100%; width: 100%">
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from "vue";
|
||||||
import { Bar } from 'vue-chartjs';
|
import { Bar } from "vue-chartjs";
|
||||||
import {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
BarElement,
|
BarElement,
|
||||||
@@ -14,10 +14,18 @@ import {
|
|||||||
LinearScale,
|
LinearScale,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
BarController
|
BarController,
|
||||||
} from 'chart.js';
|
type ChartOptions,
|
||||||
|
} from "chart.js";
|
||||||
|
|
||||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
ChartJS.register(
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
BarController,
|
||||||
|
);
|
||||||
|
|
||||||
interface ClientData {
|
interface ClientData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,57 +37,61 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartData = computed(() => ({
|
||||||
labels: props.clients.map(c => c.name),
|
labels: props.clients.map((c) => c.name),
|
||||||
datasets: [{
|
datasets: [
|
||||||
label: 'Time Tracked',
|
{
|
||||||
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
|
label: "Time Tracked",
|
||||||
backgroundColor: '#6366f1',
|
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||||
borderColor: '#4f46e5',
|
backgroundColor: "#6366f1",
|
||||||
borderWidth: 1,
|
borderColor: "#4f46e5",
|
||||||
}]
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const chartOptions = {
|
const chartOptions: ChartOptions<"bar"> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#e2e8f0',
|
color: "#e2e8f0",
|
||||||
callback: function(value: number) {
|
callback: function (value: string | number) {
|
||||||
const hours = Math.floor(value / 60);
|
const numValue =
|
||||||
const mins = value % 60;
|
typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
const hours = Math.floor(numValue / 60);
|
||||||
|
const mins = Math.round(numValue % 60);
|
||||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: '#334155'
|
color: "#334155",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#e2e8f0'
|
color: "#e2e8f0",
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
display: false
|
display: false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: false,
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context: any) {
|
label: function (context) {
|
||||||
const minutes = Math.round(context.raw);
|
const minutes = Math.round(context.raw as number);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
return ` ${hours}h ${mins}m`;
|
return ` ${hours}h ${mins}m`;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
16
src/components/ColorDot.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
color: string;
|
||||||
|
as?: string;
|
||||||
|
class?: string;
|
||||||
|
borderColor?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as || 'span'"
|
||||||
|
:class="$props.class"
|
||||||
|
:style="borderColor ? { borderColor: color } : { backgroundColor: color }"
|
||||||
|
><slot /></component>
|
||||||
|
</template>
|
||||||
26
src/components/ConfirmForm.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
message: string;
|
||||||
|
action: string;
|
||||||
|
method?: string;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function onSubmit(e: Event) {
|
||||||
|
if (!confirm((e.currentTarget as HTMLFormElement).dataset.message!)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
:method="method || 'POST'"
|
||||||
|
:action="action"
|
||||||
|
:class="$props.class"
|
||||||
|
:data-message="message"
|
||||||
|
@submit="onSubmit"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
27
src/components/Icon.astro
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import { icons, type IconName } from "../config/icons";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: IconName;
|
||||||
|
class?: string;
|
||||||
|
"class:list"?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, class: className, "class:list": classList } = Astro.props;
|
||||||
|
const svg = icons[name];
|
||||||
|
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error(`Icon "${name}" not found in icon registry`);
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
fill="none"
|
||||||
|
class:list={[className, classList]}
|
||||||
|
aria-hidden="true"
|
||||||
|
set:html={svg}
|
||||||
|
/>
|
||||||
30
src/components/Icon.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { icons, type IconName } from "../config/icons";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: IconName;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const svg = computed(() => {
|
||||||
|
const icon = icons[props.name];
|
||||||
|
if (!icon) {
|
||||||
|
throw new Error(`Icon "${props.name}" not found in icon registry`);
|
||||||
|
}
|
||||||
|
return icon;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
fill="none"
|
||||||
|
:class="props.class"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-html="svg"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import Icon from "./Icon.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
clients: { id: string; name: string }[];
|
clients: { id: string; name: string }[];
|
||||||
categories: { id: string; name: string; color: string | null }[];
|
|
||||||
tags: { id: string; name: string; color: string | null }[];
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -13,8 +13,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const description = ref("");
|
const description = ref("");
|
||||||
const selectedClientId = ref("");
|
const selectedClientId = ref("");
|
||||||
const selectedCategoryId = ref("");
|
const selectedTagId = ref<string | null>(null);
|
||||||
const selectedTags = ref<string[]>([]);
|
|
||||||
const startDate = ref("");
|
const startDate = ref("");
|
||||||
const startTime = ref("");
|
const startTime = ref("");
|
||||||
const endDate = ref("");
|
const endDate = ref("");
|
||||||
@@ -23,17 +22,15 @@ const isSubmitting = ref(false);
|
|||||||
const error = ref("");
|
const error = ref("");
|
||||||
const success = ref(false);
|
const success = ref(false);
|
||||||
|
|
||||||
// Set default dates to today
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
startDate.value = today;
|
startDate.value = today;
|
||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
|
|
||||||
function toggleTag(tagId: string) {
|
function toggleTag(tagId: string) {
|
||||||
const index = selectedTags.value.indexOf(tagId);
|
if (selectedTagId.value === tagId) {
|
||||||
if (index > -1) {
|
selectedTagId.value = null;
|
||||||
selectedTags.value.splice(index, 1);
|
|
||||||
} else {
|
} else {
|
||||||
selectedTags.value.push(tagId);
|
selectedTagId.value = tagId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,10 +51,6 @@ function validateForm(): string | null {
|
|||||||
return "Please select a client";
|
return "Please select a client";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedCategoryId.value) {
|
|
||||||
return "Please select a category";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!startDate.value || !startTime.value) {
|
if (!startDate.value || !startTime.value) {
|
||||||
return "Please enter start date and time";
|
return "Please enter start date and time";
|
||||||
}
|
}
|
||||||
@@ -102,10 +95,9 @@ async function submitManualEntry() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
description: description.value,
|
description: description.value,
|
||||||
clientId: selectedClientId.value,
|
clientId: selectedClientId.value,
|
||||||
categoryId: selectedCategoryId.value,
|
|
||||||
startTime: startDateTime,
|
startTime: startDateTime,
|
||||||
endTime: endDateTime,
|
endTime: endDateTime,
|
||||||
tags: selectedTags.value,
|
tagId: selectedTagId.value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,22 +106,18 @@ async function submitManualEntry() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
success.value = true;
|
success.value = true;
|
||||||
|
|
||||||
// Calculate duration for success message
|
|
||||||
const start = new Date(startDateTime);
|
const start = new Date(startDateTime);
|
||||||
const end = new Date(endDateTime);
|
const end = new Date(endDateTime);
|
||||||
const duration = formatDuration(start, end);
|
const duration = formatDuration(start, end);
|
||||||
|
|
||||||
// Reset form
|
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedCategoryId.value = "";
|
selectedTagId.value = null;
|
||||||
selectedTags.value = [];
|
|
||||||
startDate.value = today;
|
startDate.value = today;
|
||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
startTime.value = "";
|
startTime.value = "";
|
||||||
endTime.value = "";
|
endTime.value = "";
|
||||||
|
|
||||||
// Emit event and reload after a short delay
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
emit("entryCreated");
|
emit("entryCreated");
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -148,8 +136,7 @@ async function submitManualEntry() {
|
|||||||
function clearForm() {
|
function clearForm() {
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedCategoryId.value = "";
|
selectedTagId.value = null;
|
||||||
selectedTags.value = [];
|
|
||||||
startDate.value = today;
|
startDate.value = today;
|
||||||
endDate.value = today;
|
endDate.value = today;
|
||||||
startTime.value = "";
|
startTime.value = "";
|
||||||
@@ -161,7 +148,7 @@ function clearForm() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200"
|
class="card bg-base-200 backdrop-blur-sm shadow-lg border border-base-content/20 hover:border-base-content/20 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<div class="card-body gap-6">
|
<div class="card-body gap-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
@@ -178,109 +165,58 @@ function clearForm() {
|
|||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
<div v-if="success" class="alert alert-success">
|
<div v-if="success" class="alert alert-success">
|
||||||
<svg
|
<Icon name="check-circle" class="stroke-current shrink-0 h-6 w-6" />
|
||||||
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>
|
<span>Manual time entry created successfully!</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<div v-if="error" class="alert alert-error">
|
<div v-if="error" class="alert alert-error">
|
||||||
<svg
|
<Icon name="x-circle" class="stroke-current shrink-0 h-6 w-6" />
|
||||||
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>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client and Category Row -->
|
<!-- Client Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="form-control">
|
||||||
<div class="form-control">
|
<label class="label pb-2 font-medium" for="manual-client">
|
||||||
<label class="label pb-2">
|
Client <span class="label-text-alt text-error">*</span>
|
||||||
<span class="label-text font-medium">Client</span>
|
</label>
|
||||||
<span class="label-text-alt text-error">*</span>
|
<select
|
||||||
</label>
|
id="manual-client"
|
||||||
<select
|
v-model="selectedClientId"
|
||||||
v-model="selectedClientId"
|
class="select select-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
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"
|
||||||
:disabled="isSubmitting"
|
>
|
||||||
>
|
<option value="">Select a client...</option>
|
||||||
<option value="">Select a client...</option>
|
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||||
<option
|
{{ client.name }}
|
||||||
v-for="client in clients"
|
</option>
|
||||||
:key="client.id"
|
</select>
|
||||||
:value="client.id"
|
|
||||||
>
|
|
||||||
{{ client.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label pb-2">
|
|
||||||
<span class="label-text font-medium">Category</span>
|
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedCategoryId"
|
|
||||||
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
>
|
|
||||||
<option value="">Select a category...</option>
|
|
||||||
<option
|
|
||||||
v-for="category in categories"
|
|
||||||
:key="category.id"
|
|
||||||
:value="category.id"
|
|
||||||
>
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Start Date and Time -->
|
<!-- Start Date and Time -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="manual-start-date">
|
||||||
<span class="label-text font-medium">Start Date</span>
|
Start Date <span class="label-text-alt text-error">*</span>
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="manual-start-date"
|
||||||
v-model="startDate"
|
v-model="startDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="manual-start-time">
|
||||||
<span class="label-text font-medium">Start Time</span>
|
Start Time <span class="label-text-alt text-error">*</span>
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="manual-start-time"
|
||||||
v-model="startTime"
|
v-model="startTime"
|
||||||
type="time"
|
type="time"
|
||||||
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,27 +225,27 @@ function clearForm() {
|
|||||||
<!-- End Date and Time -->
|
<!-- End Date and Time -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="manual-end-date">
|
||||||
<span class="label-text font-medium">End Date</span>
|
End Date <span class="label-text-alt text-error">*</span>
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="manual-end-date"
|
||||||
v-model="endDate"
|
v-model="endDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="manual-end-time">
|
||||||
<span class="label-text font-medium">End Time</span>
|
End Time <span class="label-text-alt text-error">*</span>
|
||||||
<span class="label-text-alt text-error">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="manual-end-time"
|
||||||
v-model="endTime"
|
v-model="endTime"
|
||||||
type="time"
|
type="time"
|
||||||
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,23 +253,22 @@ function clearForm() {
|
|||||||
|
|
||||||
<!-- Description Row -->
|
<!-- Description Row -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="manual-description">
|
||||||
<span class="label-text font-medium">Description</span>
|
Description
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="manual-description"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="What did you work on?"
|
placeholder="What did you work on?"
|
||||||
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags Section -->
|
<!-- Tags Section -->
|
||||||
<div v-if="tags.length > 0" class="form-control">
|
<div v-if="tags.length > 0" class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="manual-tags"> Tags </label>
|
||||||
<span class="label-text font-medium">Tags</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
@@ -341,9 +276,9 @@ function clearForm() {
|
|||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
selectedTags.includes(tag.id)
|
selectedTagId === tag.id
|
||||||
? 'badge-primary shadow-lg shadow-primary/20'
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
: 'badge-outline hover:bg-base-300/50',
|
: 'badge-outline hover:bg-base-300',
|
||||||
]"
|
]"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -58,9 +58,11 @@ const chartOptions: ChartOptions<"bar"> = {
|
|||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: {
|
ticks: {
|
||||||
color: "#e2e8f0",
|
color: "#e2e8f0",
|
||||||
callback: function (value: any) {
|
callback: function (value: string | number) {
|
||||||
const hours = Math.floor(value / 60);
|
const numValue =
|
||||||
const mins = value % 60;
|
typeof value === "string" ? parseFloat(value) : value;
|
||||||
|
const hours = Math.floor(numValue / 60);
|
||||||
|
const mins = Math.round(numValue % 60);
|
||||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -83,8 +85,8 @@ const chartOptions: ChartOptions<"bar"> = {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function (context: any) {
|
label: function (context) {
|
||||||
const minutes = Math.round(context.raw);
|
const minutes = Math.round(context.raw as number);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const mins = minutes % 60;
|
const mins = minutes % 60;
|
||||||
return ` ${hours}h ${mins}m`;
|
return ` ${hours}h ${mins}m`;
|
||||||
|
|||||||
34
src/components/ModalButton.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
modalId: string;
|
||||||
|
action?: 'open' | 'close';
|
||||||
|
class?: string;
|
||||||
|
title?: string;
|
||||||
|
type?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
const btn = e.currentTarget as HTMLElement;
|
||||||
|
const id = btn.dataset.modalId!;
|
||||||
|
const act = btn.dataset.action || 'open';
|
||||||
|
const modal = document.getElementById(id) as HTMLDialogElement | null;
|
||||||
|
if (act === 'close') {
|
||||||
|
modal?.close();
|
||||||
|
} else {
|
||||||
|
modal?.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:type="(type as any) || 'button'"
|
||||||
|
:class="$props.class"
|
||||||
|
:title="$props.title"
|
||||||
|
:data-modal-id="modalId"
|
||||||
|
:data-action="action || 'open'"
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
29
src/components/StatCard.astro
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import Icon from './Icon.astro';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
valueClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, value, description, icon, color = 'text-primary', valueClass } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="card card-border bg-base-100">
|
||||||
|
<div class="card-body p-4 gap-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wider text-base-content/60">{title}</span>
|
||||||
|
{icon && (
|
||||||
|
<div class:list={[color, "opacity-70"]}>
|
||||||
|
<Icon name={icon} class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class:list={["text-2xl font-bold", color, valueClass]}>{value}</div>
|
||||||
|
{description && <div class="text-xs text-base-content/60">{description}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
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",
|
||||||
|
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>
|
||||||
31
src/components/ThemeToggle.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import Icon from "./Icon.vue";
|
||||||
|
|
||||||
|
const theme = ref("sunset");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const stored = localStorage.getItem("theme");
|
||||||
|
if (stored) {
|
||||||
|
theme.value = stored;
|
||||||
|
document.documentElement.setAttribute("data-theme", stored);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const newTheme = theme.value === "sunset" ? "winter" : "sunset";
|
||||||
|
theme.value = newTheme;
|
||||||
|
document.documentElement.setAttribute("data-theme", newTheme);
|
||||||
|
localStorage.setItem("theme", newTheme);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
@click="toggleTheme"
|
||||||
|
class="btn btn-ghost btn-circle"
|
||||||
|
aria-label="Toggle Theme"
|
||||||
|
>
|
||||||
|
<Icon :name="theme === 'sunset' ? 'moon' : 'sun'" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from "vue";
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
import { Icon } from "@iconify/vue";
|
import Icon from "./Icon.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
initialRunningEntry: {
|
initialRunningEntry: {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
categoryId: string;
|
tagId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
clients: { id: string; name: string }[];
|
clients: { id: string; name: string }[];
|
||||||
categories: { id: string; name: string; color: string | null }[];
|
|
||||||
tags: { id: string; name: string; color: string | null }[];
|
tags: { id: string; name: string; color: string | null }[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -19,8 +18,7 @@ const startTime = ref<number | null>(null);
|
|||||||
const elapsedTime = ref(0);
|
const elapsedTime = ref(0);
|
||||||
const description = ref("");
|
const description = ref("");
|
||||||
const selectedClientId = ref("");
|
const selectedClientId = ref("");
|
||||||
const selectedCategoryId = ref("");
|
const selectedTagId = ref<string | null>(null);
|
||||||
const selectedTags = ref<string[]>([]);
|
|
||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
function formatTime(ms: number) {
|
function formatTime(ms: number) {
|
||||||
@@ -31,7 +29,6 @@ function formatTime(ms: number) {
|
|||||||
|
|
||||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
// Calculate rounded version
|
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
const roundedHours = Math.floor(totalMinutes / 60);
|
const roundedHours = Math.floor(totalMinutes / 60);
|
||||||
const roundedMinutes = totalMinutes % 60;
|
const roundedMinutes = totalMinutes % 60;
|
||||||
@@ -50,11 +47,10 @@ function formatTime(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleTag(tagId: string) {
|
function toggleTag(tagId: string) {
|
||||||
const index = selectedTags.value.indexOf(tagId);
|
if (selectedTagId.value === tagId) {
|
||||||
if (index > -1) {
|
selectedTagId.value = null;
|
||||||
selectedTags.value.splice(index, 1);
|
|
||||||
} else {
|
} else {
|
||||||
selectedTags.value.push(tagId);
|
selectedTagId.value = tagId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +60,7 @@ onMounted(() => {
|
|||||||
startTime.value = props.initialRunningEntry.startTime;
|
startTime.value = props.initialRunningEntry.startTime;
|
||||||
description.value = props.initialRunningEntry.description || "";
|
description.value = props.initialRunningEntry.description || "";
|
||||||
selectedClientId.value = props.initialRunningEntry.clientId;
|
selectedClientId.value = props.initialRunningEntry.clientId;
|
||||||
selectedCategoryId.value = props.initialRunningEntry.categoryId;
|
selectedTagId.value = props.initialRunningEntry.tagId || null;
|
||||||
elapsedTime.value = Date.now() - startTime.value;
|
elapsedTime.value = Date.now() - startTime.value;
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
elapsedTime.value = Date.now() - startTime.value!;
|
elapsedTime.value = Date.now() - startTime.value!;
|
||||||
@@ -82,19 +78,13 @@ async function startTimer() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedCategoryId.value) {
|
|
||||||
alert("Please select a category");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("/api/time-entries/start", {
|
const res = await fetch("/api/time-entries/start", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
description: description.value,
|
description: description.value,
|
||||||
clientId: selectedClientId.value,
|
clientId: selectedClientId.value,
|
||||||
categoryId: selectedCategoryId.value,
|
tagId: selectedTagId.value,
|
||||||
tags: selectedTags.value,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,8 +110,7 @@ async function stopTimer() {
|
|||||||
startTime.value = null;
|
startTime.value = null;
|
||||||
description.value = "";
|
description.value = "";
|
||||||
selectedClientId.value = "";
|
selectedClientId.value = "";
|
||||||
selectedCategoryId.value = "";
|
selectedTagId.value = null;
|
||||||
selectedTags.value = [];
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,71 +118,45 @@ async function stopTimer() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="card bg-base-200/50 backdrop-blur-sm shadow-lg border border-base-300/50 mb-6 hover:border-base-300 transition-all duration-200"
|
class="card bg-base-200 backdrop-blur-sm shadow-lg border border-base-content/20 mb-6 hover:border-base-content/20 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<div class="card-body gap-6">
|
<div class="card-body gap-6">
|
||||||
<!-- Client and Description Row -->
|
<!-- Client Row -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="form-control">
|
||||||
<div class="form-control">
|
<label class="label pb-2 font-medium" for="timer-client">
|
||||||
<label class="label pb-2">
|
Client
|
||||||
<span class="label-text font-medium">Client</span>
|
</label>
|
||||||
</label>
|
<select
|
||||||
<select
|
id="timer-client"
|
||||||
v-model="selectedClientId"
|
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"
|
class="select select-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isRunning"
|
:disabled="isRunning"
|
||||||
>
|
>
|
||||||
<option value="">Select a client...</option>
|
<option value="">Select a client...</option>
|
||||||
<option
|
<option v-for="client in clients" :key="client.id" :value="client.id">
|
||||||
v-for="client in clients"
|
{{ client.name }}
|
||||||
:key="client.id"
|
</option>
|
||||||
:value="client.id"
|
</select>
|
||||||
>
|
|
||||||
{{ client.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label pb-2">
|
|
||||||
<span class="label-text font-medium">Category</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="selectedCategoryId"
|
|
||||||
class="select select-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
|
||||||
:disabled="isRunning"
|
|
||||||
>
|
|
||||||
<option value="">Select a category...</option>
|
|
||||||
<option
|
|
||||||
v-for="category in categories"
|
|
||||||
:key="category.id"
|
|
||||||
:value="category.id"
|
|
||||||
>
|
|
||||||
{{ category.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description Row -->
|
<!-- Description Row -->
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="timer-description">
|
||||||
<span class="label-text font-medium">Description</span>
|
Description
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="timer-description"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="What are you working on?"
|
placeholder="What are you working on?"
|
||||||
class="input input-bordered w-full bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
class="input input-bordered w-full bg-base-300 hover:bg-base-300 focus:bg-base-300 border-base-content/20 focus:border-primary transition-colors"
|
||||||
:disabled="isRunning"
|
:disabled="isRunning"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags Section -->
|
<!-- Tags Section -->
|
||||||
<div v-if="tags.length > 0" class="form-control">
|
<div v-if="tags.length > 0" class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="timer-tags"> Tags </label>
|
||||||
<span class="label-text font-medium">Tags</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
@@ -201,9 +164,9 @@ async function stopTimer() {
|
|||||||
@click="toggleTag(tag.id)"
|
@click="toggleTag(tag.id)"
|
||||||
:class="[
|
:class="[
|
||||||
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
'badge badge-lg cursor-pointer transition-all hover:scale-105',
|
||||||
selectedTags.includes(tag.id)
|
selectedTagId === tag.id
|
||||||
? 'badge-primary shadow-lg shadow-primary/20'
|
? 'badge-primary shadow-lg shadow-primary/20'
|
||||||
: 'badge-outline hover:bg-base-300/50',
|
: 'badge-outline hover:bg-base-300',
|
||||||
]"
|
]"
|
||||||
:disabled="isRunning"
|
:disabled="isRunning"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -225,7 +188,7 @@ async function stopTimer() {
|
|||||||
@click="startTimer"
|
@click="startTimer"
|
||||||
class="btn btn-primary btn-lg min-w-40 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
|
class="btn btn-primary btn-lg min-w-40 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:play" class="w-5 h-5" />
|
<Icon name="play" class="w-5 h-5" />
|
||||||
Start Timer
|
Start Timer
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -233,7 +196,7 @@ async function stopTimer() {
|
|||||||
@click="stopTimer"
|
@click="stopTimer"
|
||||||
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
|
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:stop" class="w-5 h-5" />
|
<Icon name="stop" class="w-5 h-5" />
|
||||||
Stop Timer
|
Stop Timer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
72
src/components/auth/PasskeyLogin.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Icon from "../Icon.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 name="finger-print" class="w-5 h-5 mr-2" />
|
||||||
|
Sign in with Passkey
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="error" role="alert" class="alert alert-error mt-4">
|
||||||
|
<Icon name="exclamation-circle" class="w-6 h-6" />
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted } from "vue";
|
||||||
import { Icon } from '@iconify/vue';
|
import Icon from "../Icon.vue";
|
||||||
|
|
||||||
interface ApiToken {
|
interface ApiToken {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,12 +16,17 @@ const props = defineProps<{
|
|||||||
const tokens = ref<ApiToken[]>(props.initialTokens);
|
const tokens = ref<ApiToken[]>(props.initialTokens);
|
||||||
const createModalOpen = ref(false);
|
const createModalOpen = ref(false);
|
||||||
const showTokenModalOpen = ref(false);
|
const showTokenModalOpen = ref(false);
|
||||||
const newTokenName = ref('');
|
const newTokenName = ref("");
|
||||||
const newTokenValue = ref('');
|
const newTokenValue = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const isMounted = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isMounted.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
function formatDate(dateString: string | null) {
|
function formatDate(dateString: string | null) {
|
||||||
if (!dateString) return 'Never';
|
if (!dateString) return "Never";
|
||||||
return new Date(dateString).toLocaleDateString();
|
return new Date(dateString).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,10 +35,10 @@ async function createToken() {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/tokens', {
|
const response = await fetch("/api/user/tokens", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name: newTokenName.value }),
|
body: JSON.stringify({ name: newTokenName.value }),
|
||||||
});
|
});
|
||||||
@@ -43,47 +48,50 @@ async function createToken() {
|
|||||||
|
|
||||||
const { token, ...tokenMeta } = data;
|
const { token, ...tokenMeta } = data;
|
||||||
|
|
||||||
// Add to beginning of list
|
|
||||||
tokens.value.unshift({
|
tokens.value.unshift({
|
||||||
id: tokenMeta.id,
|
id: tokenMeta.id,
|
||||||
name: tokenMeta.name,
|
name: tokenMeta.name,
|
||||||
lastUsedAt: tokenMeta.lastUsedAt,
|
lastUsedAt: tokenMeta.lastUsedAt,
|
||||||
createdAt: tokenMeta.createdAt
|
createdAt: tokenMeta.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
newTokenValue.value = token;
|
newTokenValue.value = token;
|
||||||
createModalOpen.value = false;
|
createModalOpen.value = false;
|
||||||
showTokenModalOpen.value = true;
|
showTokenModalOpen.value = true;
|
||||||
newTokenName.value = '';
|
newTokenName.value = "";
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to create token');
|
alert("Failed to create token");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating token:', error);
|
console.error("Error creating token:", error);
|
||||||
alert('An error occurred');
|
alert("An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteToken(id: string) {
|
async function deleteToken(id: string) {
|
||||||
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Are you sure you want to revoke this token? Any applications using it will stop working.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/user/tokens/${id}`, {
|
const response = await fetch(`/api/user/tokens/${id}`, {
|
||||||
method: 'DELETE'
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
tokens.value = tokens.value.filter(t => t.id !== id);
|
tokens.value = tokens.value.filter((t) => t.id !== id);
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to delete token');
|
alert("Failed to delete token");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting token:', error);
|
console.error("Error deleting token:", error);
|
||||||
alert('An error occurred');
|
alert("An error occurred");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,21 +101,27 @@ function copyToken() {
|
|||||||
|
|
||||||
function closeShowTokenModal() {
|
function closeShowTokenModal() {
|
||||||
showTokenModalOpen.value = false;
|
showTokenModalOpen.value = false;
|
||||||
newTokenValue.value = '';
|
newTokenValue.value = "";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
|
||||||
<div class="card-body p-4 sm:p-6">
|
<div class="card-body p-4 sm:p-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="card-title text-lg sm:text-xl">
|
<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" />
|
<Icon
|
||||||
|
name="code-bracket-square"
|
||||||
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
|
/>
|
||||||
API Tokens
|
API Tokens
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-primary btn-sm" @click="createModalOpen = true">
|
<button
|
||||||
<Icon icon="heroicons:plus" class="w-4 h-4" />
|
class="btn btn-primary btn-sm"
|
||||||
|
@click="createModalOpen = true"
|
||||||
|
>
|
||||||
|
<Icon name="plus" class="w-4 h-4" />
|
||||||
Create Token
|
Create Token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,17 +145,23 @@ function closeShowTokenModal() {
|
|||||||
<tr v-else v-for="token in tokens" :key="token.id">
|
<tr v-else v-for="token in tokens" :key="token.id">
|
||||||
<td class="font-medium">{{ token.name }}</td>
|
<td class="font-medium">{{ token.name }}</td>
|
||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
{{ formatDate(token.lastUsedAt) }}
|
<span v-if="isMounted">{{
|
||||||
|
formatDate(token.lastUsedAt)
|
||||||
|
}}</span>
|
||||||
|
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
{{ formatDate(token.createdAt) }}
|
<span v-if="isMounted">{{
|
||||||
|
formatDate(token.createdAt)
|
||||||
|
}}</span>
|
||||||
|
<span v-else>{{ token.createdAt }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
@click="deleteToken(token.id)"
|
@click="deleteToken(token.id)"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:trash" class="w-4 h-4" />
|
<Icon name="trash" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -162,11 +182,12 @@ function closeShowTokenModal() {
|
|||||||
|
|
||||||
<form @submit.prevent="createToken" class="space-y-4">
|
<form @submit.prevent="createToken" class="space-y-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2 font-medium" for="token-name">
|
||||||
<span class="label-text font-medium">Token Name</span>
|
Token Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="token-name"
|
||||||
v-model="newTokenName"
|
v-model="newTokenName"
|
||||||
placeholder="e.g. CI/CD Pipeline"
|
placeholder="e.g. CI/CD Pipeline"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
@@ -175,15 +196,24 @@ function closeShowTokenModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="createModalOpen = false">Cancel</button>
|
<button type="button" class="btn" @click="createModalOpen = false">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
Generate Token
|
Generate Token
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop" @click="createModalOpen = false">
|
<form
|
||||||
|
method="dialog"
|
||||||
|
class="modal-backdrop"
|
||||||
|
@click="createModalOpen = false"
|
||||||
|
>
|
||||||
<button>close</button>
|
<button>close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
@@ -192,30 +222,35 @@ function closeShowTokenModal() {
|
|||||||
<dialog class="modal" :class="{ 'modal-open': showTokenModalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': showTokenModalOpen }">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
||||||
<Icon icon="heroicons:check-circle" class="w-6 h-6" />
|
<Icon name="check-circle" class="w-6 h-6" />
|
||||||
Token Created
|
Token Created
|
||||||
</h3>
|
</h3>
|
||||||
<p class="py-4">
|
<p class="py-4">
|
||||||
Make sure to copy your personal access token now. You won't be able to see it again!
|
Make sure to copy your personal access token now. You won't be able to
|
||||||
|
see it again!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
|
<div
|
||||||
|
class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group"
|
||||||
|
>
|
||||||
<span>{{ newTokenValue }}</span>
|
<span>{{ newTokenValue }}</span>
|
||||||
<button
|
<button
|
||||||
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
@click="copyToken"
|
@click="copyToken"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:clipboard" class="w-4 h-4" />
|
<Icon name="clipboard" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button class="btn btn-primary" @click="closeShowTokenModal">Done</button>
|
<button class="btn btn-primary" @click="closeShowTokenModal">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
|
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">
|
||||||
<button>close</button>
|
<button>close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, onMounted } from "vue";
|
||||||
import { Icon } from '@iconify/vue';
|
import Icon from "../Icon.vue";
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from "@simplewebauthn/browser";
|
||||||
|
|
||||||
interface Passkey {
|
interface Passkey {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,98 +17,100 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const passkeys = ref<Passkey[]>(props.initialPasskeys);
|
const passkeys = ref<Passkey[]>(props.initialPasskeys);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const isMounted = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isMounted.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
function formatDate(dateString: string | null) {
|
function formatDate(dateString: string | null) {
|
||||||
if (!dateString) return 'N/A';
|
if (!dateString) return "N/A";
|
||||||
return new Date(dateString).toLocaleDateString();
|
return new Date(dateString).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerPasskey() {
|
async function registerPasskey() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 1. Get options from server
|
|
||||||
const resp = await fetch("/api/auth/passkey/register/start");
|
const resp = await fetch("/api/auth/passkey/register/start");
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
throw new Error("Failed to start registration");
|
throw new Error("Failed to start registration");
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = await resp.json();
|
const options = await resp.json();
|
||||||
|
|
||||||
// 2. Browser handles interaction
|
|
||||||
let attResp;
|
let attResp;
|
||||||
try {
|
try {
|
||||||
attResp = await startRegistration(options);
|
attResp = await startRegistration({ optionsJSON: options });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((error as any).name === 'NotAllowedError') {
|
if ((error as any).name === "NotAllowedError") {
|
||||||
// User cancelled or timed out
|
return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
console.error(error);
|
console.error(error);
|
||||||
alert('Failed to register passkey: ' + (error as any).message);
|
alert("Failed to register passkey: " + (error as any).message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify with server
|
const verificationResp = await fetch("/api/auth/passkey/register/finish", {
|
||||||
const verificationResp = await fetch(
|
method: "POST",
|
||||||
"/api/auth/passkey/register/finish",
|
headers: { "Content-Type": "application/json" },
|
||||||
{
|
body: JSON.stringify(attResp),
|
||||||
method: "POST",
|
});
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(attResp),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const verificationJSON = await verificationResp.json();
|
const verificationJSON = await verificationResp.json();
|
||||||
if (verificationJSON.verified) {
|
if (verificationJSON.verified) {
|
||||||
// Reload to show the new passkey since the API doesn't return the created object
|
|
||||||
// Ideally we would return the object and append it to 'passkeys' to avoid reload
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} else {
|
} else {
|
||||||
alert("Passkey registration failed");
|
alert("Passkey registration failed");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error registering passkey:', error);
|
console.error("Error registering passkey:", error);
|
||||||
alert('An error occurred');
|
alert("An error occurred");
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePasskey(id: string) {
|
async function deletePasskey(id: string) {
|
||||||
if (!confirm('Are you sure you want to remove this passkey?')) {
|
if (!confirm("Are you sure you want to remove this passkey?")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
|
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
|
||||||
method: 'DELETE'
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Optimistically remove from list
|
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
|
||||||
passkeys.value = passkeys.value.filter(pk => pk.id !== id);
|
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to delete passkey');
|
alert("Failed to delete passkey");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting passkey:', error);
|
console.error("Error deleting passkey:", error);
|
||||||
alert('An error occurred');
|
alert("An error occurred");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
|
||||||
<div class="card-body p-4 sm:p-6">
|
<div class="card-body p-4 sm:p-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="card-title text-lg sm:text-xl">
|
<h2 class="card-title text-lg sm:text-xl">
|
||||||
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
|
<Icon name="finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
Passkeys
|
Passkeys
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-primary btn-sm" @click="registerPasskey" :disabled="loading">
|
<button
|
||||||
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
|
class="btn btn-primary btn-sm"
|
||||||
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
|
@click="registerPasskey"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="loading loading-spinner loading-xs"
|
||||||
|
></span>
|
||||||
|
<Icon v-else name="plus" class="w-4 h-4" />
|
||||||
Add Passkey
|
Add Passkey
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,21 +133,31 @@ async function deletePasskey(id: string) {
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-else v-for="pk in passkeys" :key="pk.id">
|
<tr v-else v-for="pk in passkeys" :key="pk.id">
|
||||||
<td class="font-medium">
|
<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>
|
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>
|
||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : 'Never' }}
|
<span v-if="isMounted">
|
||||||
|
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm">
|
<td class="text-sm">
|
||||||
{{ formatDate(pk.createdAt) }}
|
<span v-if="isMounted">{{ formatDate(pk.createdAt) }}</span>
|
||||||
|
<span v-else>{{ pk.createdAt || "N/A" }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
@click="deletePasskey(pk.id)"
|
@click="deletePasskey(pk.id)"
|
||||||
>
|
>
|
||||||
<Icon icon="heroicons:trash" class="w-4 h-4" />
|
<Icon name="trash" class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from "vue";
|
||||||
import { Icon } from '@iconify/vue';
|
import Icon from "../Icon.vue";
|
||||||
|
|
||||||
const currentPassword = ref('');
|
const currentPassword = ref("");
|
||||||
const newPassword = ref('');
|
const newPassword = ref("");
|
||||||
const confirmPassword = ref('');
|
const confirmPassword = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
if (newPassword.value !== confirmPassword.value) {
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
message.value = { type: 'error', text: 'New passwords do not match' };
|
message.value = { type: "error", text: "New passwords do not match" };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.value.length < 8) {
|
if (newPassword.value.length < 8) {
|
||||||
message.value = { type: 'error', text: 'Password must be at least 8 characters' };
|
message.value = {
|
||||||
|
type: "error",
|
||||||
|
text: "Password must be at least 8 characters",
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,10 +26,10 @@ async function changePassword() {
|
|||||||
message.value = null;
|
message.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/change-password', {
|
const response = await fetch("/api/user/change-password", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
currentPassword: currentPassword.value,
|
currentPassword: currentPassword.value,
|
||||||
@@ -36,22 +39,26 @@ async function changePassword() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
message.value = { type: 'success', text: 'Password changed successfully!' };
|
message.value = {
|
||||||
// Reset form
|
type: "success",
|
||||||
currentPassword.value = '';
|
text: "Password changed successfully!",
|
||||||
newPassword.value = '';
|
};
|
||||||
confirmPassword.value = '';
|
currentPassword.value = "";
|
||||||
|
newPassword.value = "";
|
||||||
|
confirmPassword.value = "";
|
||||||
|
|
||||||
// Hide success message after 3 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
message.value = null;
|
message.value = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
message.value = { type: 'error', text: data.error || 'Failed to change password' };
|
message.value = {
|
||||||
|
type: "error",
|
||||||
|
text: data.error || "Failed to change password",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = { type: 'error', text: 'An error occurred' };
|
message.value = { type: "error", text: "An error occurred" };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -61,25 +68,42 @@ async function changePassword() {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Success/Error Message Display -->
|
<!-- Success/Error Message Display -->
|
||||||
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
<div
|
||||||
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
v-if="message"
|
||||||
|
:class="[
|
||||||
|
'alert mb-6',
|
||||||
|
message.type === 'success' ? 'alert-success' : 'alert-error',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="
|
||||||
|
message.type === 'success'
|
||||||
|
? 'check-circle'
|
||||||
|
: 'exclamation-circle'
|
||||||
|
"
|
||||||
|
class="w-6 h-6 shrink-0"
|
||||||
|
/>
|
||||||
<span>{{ message.text }}</span>
|
<span>{{ message.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
|
||||||
<div class="card-body p-4 sm:p-6">
|
<div class="card-body p-4 sm:p-6">
|
||||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
<Icon icon="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
<Icon name="key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
Change Password
|
Change Password
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form @submit.prevent="changePassword" class="space-y-5">
|
<form @submit.prevent="changePassword" class="space-y-5">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
class="label pb-2 font-medium text-sm sm:text-base"
|
||||||
|
for="current-password"
|
||||||
|
>
|
||||||
|
Current Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
id="current-password"
|
||||||
v-model="currentPassword"
|
v-model="currentPassword"
|
||||||
placeholder="Enter current password"
|
placeholder="Enter current password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
@@ -88,11 +112,15 @@ async function changePassword() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label
|
||||||
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
class="label pb-2 font-medium text-sm sm:text-base"
|
||||||
|
for="new-password"
|
||||||
|
>
|
||||||
|
New Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
id="new-password"
|
||||||
v-model="newPassword"
|
v-model="newPassword"
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
@@ -100,16 +128,23 @@ async function changePassword() {
|
|||||||
minlength="8"
|
minlength="8"
|
||||||
/>
|
/>
|
||||||
<div class="label pt-2">
|
<div class="label pt-2">
|
||||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
<span
|
||||||
|
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
|
||||||
|
>Minimum 8 characters</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
class="label pb-2 font-medium text-sm sm:text-base"
|
||||||
|
for="confirm-password"
|
||||||
|
>
|
||||||
|
Confirm New Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
id="confirm-password"
|
||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
@@ -119,9 +154,16 @@ async function changePassword() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
<button
|
||||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
type="submit"
|
||||||
<Icon v-else icon="heroicons:lock-closed" class="w-5 h-5" />
|
class="btn btn-primary w-full sm:w-auto"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
|
<Icon v-else name="lock-closed" class="w-5 h-5" />
|
||||||
Update Password
|
Update Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from "vue";
|
||||||
import { Icon } from '@iconify/vue';
|
import Icon from "../Icon.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
user: {
|
user: {
|
||||||
@@ -12,33 +12,38 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const name = ref(props.user.name);
|
const name = ref(props.user.name);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
async function updateProfile() {
|
async function updateProfile() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
message.value = null;
|
message.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/update-profile', {
|
const response = await fetch("/api/user/update-profile", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name: name.value }),
|
body: JSON.stringify({ name: name.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
message.value = { type: 'success', text: 'Profile updated successfully!' };
|
message.value = {
|
||||||
// Hide success message after 3 seconds
|
type: "success",
|
||||||
|
text: "Profile updated successfully!",
|
||||||
|
};
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
message.value = null;
|
message.value = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
} else {
|
} else {
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
message.value = { type: 'error', text: data.error || 'Failed to update profile' };
|
message.value = {
|
||||||
|
type: "error",
|
||||||
|
text: data.error || "Failed to update profile",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = { type: 'error', text: 'An error occurred' };
|
message.value = { type: "error", text: "An error occurred" };
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -48,25 +53,42 @@ async function updateProfile() {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Success/Error Message Display -->
|
<!-- Success/Error Message Display -->
|
||||||
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
<div
|
||||||
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
v-if="message"
|
||||||
|
:class="[
|
||||||
|
'alert mb-6',
|
||||||
|
message.type === 'success' ? 'alert-success' : 'alert-error',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="
|
||||||
|
message.type === 'success'
|
||||||
|
? 'check-circle'
|
||||||
|
: 'exclamation-circle'
|
||||||
|
"
|
||||||
|
class="w-6 h-6 shrink-0"
|
||||||
|
/>
|
||||||
<span>{{ message.text }}</span>
|
<span>{{ message.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card bg-base-100 shadow-xl border border-base-content/20 mb-6">
|
||||||
<div class="card-body p-4 sm:p-6">
|
<div class="card-body p-4 sm:p-6">
|
||||||
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
<h2 class="card-title mb-6 text-lg sm:text-xl">
|
||||||
<Icon icon="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
<Icon name="user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
Profile Information
|
Profile Information
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form @submit.prevent="updateProfile" class="space-y-5">
|
<form @submit.prevent="updateProfile" class="space-y-5">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
class="label pb-2 font-medium text-sm sm:text-base"
|
||||||
|
for="profile-name"
|
||||||
|
>
|
||||||
|
Full Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
id="profile-name"
|
||||||
v-model="name"
|
v-model="name"
|
||||||
placeholder="Your full name"
|
placeholder="Your full name"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
@@ -75,24 +97,38 @@ async function updateProfile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
class="label pb-2 font-medium text-sm sm:text-base"
|
||||||
|
for="profile-email"
|
||||||
|
>
|
||||||
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
|
id="profile-email"
|
||||||
:value="props.user.email"
|
:value="props.user.email"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<div class="label pt-2">
|
<div class="label pt-2">
|
||||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
<span
|
||||||
|
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
|
||||||
|
>Email cannot be changed</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
<button
|
||||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
type="submit"
|
||||||
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
|
class="btn btn-primary w-full sm:w-auto"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
|
<Icon v-else name="check" class="w-5 h-5" />
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
62
src/config/icons.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export const icons = {
|
||||||
|
"arrow-down-tray": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"/>`,
|
||||||
|
"arrow-left": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/>`,
|
||||||
|
"arrow-right": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/>`,
|
||||||
|
"arrow-right-on-rectangle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"/>`,
|
||||||
|
"banknotes": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 18.75a60 60 0 0 1 15.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 0 1 3 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 0 0-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 0 1-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 0 0 3 15h-.75M15 10.5a3 3 0 1 1-6 0a3 3 0 0 1 6 0m3 0h.008v.008H18zm-12 0h.008v.008H6z"/>`,
|
||||||
|
"bars-3": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>`,
|
||||||
|
"bolt": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75L12 13.5z"/>`,
|
||||||
|
"building-office": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"/>`,
|
||||||
|
"building-office-2": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008zm0 3h.008v.008h-.008zm0 3h.008v.008h-.008z"/>`,
|
||||||
|
"calendar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"/>`,
|
||||||
|
"chart-bar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875zm6.75-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125zm6.75-4.5c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125z"/>`,
|
||||||
|
"chart-pie": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M10.5 6a7.5 7.5 0 1 0 7.5 7.5h-7.5z"/><path d="M13.5 10.5H21A7.5 7.5 0 0 0 13.5 3z"/></g>`,
|
||||||
|
"check": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m4.5 12.75l6 6l9-13.5"/>`,
|
||||||
|
"check-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12.75L11.25 15L15 9.75M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
|
||||||
|
"chevron-left": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 19.5L8.25 12l7.5-7.5"/>`,
|
||||||
|
"chevron-right": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m8.25 4.5l7.5 7.5l-7.5 7.5"/>`,
|
||||||
|
"clipboard": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0q.083.292.084.612v0a.75.75 0 0 1-.75.75H9a.75.75 0 0 1-.75-.75v0q.002-.32.084-.612m7.332 0q.969.073 1.927.184c1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48 48 0 0 1 1.927-.184"/>`,
|
||||||
|
"clipboard-document-list": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48 48 0 0 0-1.123-.08m-5.801 0q-.099.316-.1.664c0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75a2.3 2.3 0 0 0-.1-.664m-5.8 0A2.25 2.25 0 0 1 13.5 2.25H15a2.25 2.25 0 0 1 2.15 1.586m-5.8 0q-.563.035-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125zM6.75 12h.008v.008H6.75zm0 3h.008v.008H6.75zm0 3h.008v.008H6.75z"/>`,
|
||||||
|
"clock": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
|
||||||
|
"code-bracket-square": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25"/>`,
|
||||||
|
"cog-6-tooth": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87q.11.06.22.127c.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a8 8 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a7 7 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a7 7 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a7 7 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124q.108-.066.22-.128c.332-.183.582-.495.644-.869z"/><path d="M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/></g>`,
|
||||||
|
"currency-dollar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0s1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659c-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
|
||||||
|
"document-currency-dollar": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v7.5m2.25-6.466a9 9 0 0 0-3.461-.203c-.536.072-.974.478-1.021 1.017a5 5 0 0 0-.018.402c0 .464.336.844.775.994l2.95 1.012c.44.15.775.53.775.994q0 .204-.018.402c-.047.539-.485.945-1.021 1.017a9.1 9.1 0 0 1-3.461-.203M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9"/>`,
|
||||||
|
"document-duplicate": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9 9 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9 9 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"/>`,
|
||||||
|
"document-text": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9"/>`,
|
||||||
|
"ellipsis-horizontal": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 12a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0m6 0a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0m6 0a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0"/>`,
|
||||||
|
"ellipsis-vertical": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.75a.75.75 0 1 1 0-1.5a.75.75 0 0 1 0 1.5m0 6a.75.75 0 1 1 0-1.5a.75.75 0 0 1 0 1.5m0 6a.75.75 0 1 1 0-1.5a.75.75 0 0 1 0 1.5"/>`,
|
||||||
|
"envelope": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/>`,
|
||||||
|
"exclamation-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0a9 9 0 0 1 18 0m-9 3.75h.008v.008H12z"/>`,
|
||||||
|
"exclamation-triangle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0zM12 15.75h.007v.008H12z"/>`,
|
||||||
|
"eye": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M2.036 12.322a1 1 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178c.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178"/><path d="M15 12a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/></g>`,
|
||||||
|
"finger-print": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.269M5.742 6.364A7.47 7.47 0 0 0 4.5 10.5a7.46 7.46 0 0 1-1.15 3.993m1.989 3.559A11.2 11.2 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0q0 .79-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.6 9.75m6.633-4.596a18.7 18.7 0 0 1-2.485 5.33"/>`,
|
||||||
|
"home": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m2.25 12l8.955-8.955a1.124 1.124 0 0 1 1.59 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/>`,
|
||||||
|
"inbox": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162q0-.338-.1-.661l-2.41-7.839a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.3 2.3 0 0 0-.1.661"/>`,
|
||||||
|
"information-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m11.25 11.25l.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0m-9-3.75h.008v.008H12z"/>`,
|
||||||
|
"key": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25"/>`,
|
||||||
|
"list-bullet": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0M3.75 12h.007v.008H3.75zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0m-.375 5.25h.007v.008H3.75zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0"/>`,
|
||||||
|
"lock-closed": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25"/>`,
|
||||||
|
"magnifying-glass": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607"/>`,
|
||||||
|
"map-pin": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M15 10.5a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/><path d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0"/></g>`,
|
||||||
|
"moon": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21.752 15.002A9.7 9.7 0 0 1 18 15.75A9.75 9.75 0 0 1 8.25 6c0-1.33.266-2.597.748-3.752A9.75 9.75 0 0 0 3 11.25A9.75 9.75 0 0 0 12.75 21a9.75 9.75 0 0 0 9.002-5.998"/>`,
|
||||||
|
"paper-airplane": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 12L3.269 3.125A59.8 59.8 0 0 1 21.486 12a59.8 59.8 0 0 1-18.217 8.875zm0 0h7.5"/>`,
|
||||||
|
"pencil": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8l.8-2.685a4.5 4.5 0 0 1 1.13-1.897zm0 0L19.5 7.125"/>`,
|
||||||
|
"pencil-square": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/>`,
|
||||||
|
"phone": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.04 12.04 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5z"/>`,
|
||||||
|
"photo": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m2.25 15.75l5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5m10.5-11.25h.008v.008h-.008zm.375 0a.375.375 0 1 1-.75 0a.375.375 0 0 1 .75 0"/>`,
|
||||||
|
"play": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986z"/>`,
|
||||||
|
"play-circle": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/><path d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327z"/></g>`,
|
||||||
|
"plus": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.5v15m7.5-7.5h-15"/>`,
|
||||||
|
"stop": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.25 7.5A2.25 2.25 0 0 1 7.5 5.25h9a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25z"/>`,
|
||||||
|
"sun": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0a3.75 3.75 0 0 1 7.5 0"/>`,
|
||||||
|
"tag": `<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.1 18.1 0 0 0 5.224-5.223c.54-.827.368-1.908-.33-2.607l-9.583-9.58A2.25 2.25 0 0 0 9.568 3"/><path d="M6 6h.008v.008H6z"/></g>`,
|
||||||
|
"trash": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21q.512.078 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48 48 0 0 0-3.478-.397m-12 .562q.51-.088 1.022-.165m0 0a48 48 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a52 52 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a49 49 0 0 0-7.5 0"/>`,
|
||||||
|
"user-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.982 18.725A7.49 7.49 0 0 0 12 15.75a7.49 7.49 0 0 0-5.982 2.975m11.964 0a9 9 0 1 0-11.963 0m11.962 0A8.97 8.97 0 0 1 12 21a8.97 8.97 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0a3 3 0 0 1 6 0"/>`,
|
||||||
|
"user-group": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 18.72a9.1 9.1 0 0 0 3.741-.479q.01-.12.01-.241a3 3 0 0 0-4.692-2.478m.94 3.197l.001.031q0 .337-.037.666A11.94 11.94 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6 6 0 0 1 6 18.719m12 0a5.97 5.97 0 0 0-.941-3.197m0 0A6 6 0 0 0 12 12.75a6 6 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72a9 9 0 0 0 3.74.477m.94-3.197a5.97 5.97 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0a3 3 0 0 1 6 0m6 3a2.25 2.25 0 1 1-4.5 0a2.25 2.25 0 0 1 4.5 0m-13.5 0a2.25 2.25 0 1 1-4.5 0a2.25 2.25 0 0 1 4.5 0"/>`,
|
||||||
|
"users": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 19.128a9.4 9.4 0 0 0 2.625.372a9.3 9.3 0 0 0 4.121-.952q.004-.086.004-.173a4.125 4.125 0 0 0-7.536-2.32M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.3 12.3 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0a3.375 3.375 0 0 1 6.75 0m8.25 2.25a2.625 2.625 0 1 1-5.25 0a2.625 2.625 0 0 1 5.25 0"/>`,
|
||||||
|
"x-circle": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 1 1-18 0a9 9 0 0 1 18 0"/>`,
|
||||||
|
"x-mark": `<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12"/>`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type IconName = keyof typeof icons;
|
||||||
@@ -4,7 +4,6 @@ import * as schema from "./schema";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
// Define the database type based on the schema
|
|
||||||
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
let _db: Database | null = null;
|
let _db: Database | null = null;
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const organizations = sqliteTable("organizations", {
|
|||||||
state: text("state"),
|
state: text("state"),
|
||||||
zip: text("zip"),
|
zip: text("zip"),
|
||||||
country: text("country"),
|
country: text("country"),
|
||||||
|
defaultTaxRate: real("default_tax_rate").default(0),
|
||||||
|
defaultCurrency: text("default_currency").default("USD"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -95,8 +97,8 @@ export const clients = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const categories = sqliteTable(
|
export const tags = sqliteTable(
|
||||||
"categories",
|
"tags",
|
||||||
{
|
{
|
||||||
id: text("id")
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
@@ -104,6 +106,7 @@ export const categories = sqliteTable(
|
|||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text("organization_id").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
|
rate: integer("rate").default(0),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -113,7 +116,7 @@ export const categories = sqliteTable(
|
|||||||
columns: [table.organizationId],
|
columns: [table.organizationId],
|
||||||
foreignColumns: [organizations.id],
|
foreignColumns: [organizations.id],
|
||||||
}),
|
}),
|
||||||
organizationIdIdx: index("categories_organization_id_idx").on(
|
organizationIdIdx: index("tags_organization_id_idx").on(
|
||||||
table.organizationId,
|
table.organizationId,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -128,10 +131,11 @@ export const timeEntries = sqliteTable(
|
|||||||
userId: text("user_id").notNull(),
|
userId: text("user_id").notNull(),
|
||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text("organization_id").notNull(),
|
||||||
clientId: text("client_id").notNull(),
|
clientId: text("client_id").notNull(),
|
||||||
categoryId: text("category_id").notNull(),
|
tagId: text("tag_id"),
|
||||||
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
||||||
endTime: integer("end_time", { mode: "timestamp" }),
|
endTime: integer("end_time", { mode: "timestamp" }),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
|
invoiceId: text("invoice_id"),
|
||||||
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
isManual: integer("is_manual", { mode: "boolean" }).default(false),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
@@ -150,63 +154,18 @@ export const timeEntries = sqliteTable(
|
|||||||
columns: [table.clientId],
|
columns: [table.clientId],
|
||||||
foreignColumns: [clients.id],
|
foreignColumns: [clients.id],
|
||||||
}),
|
}),
|
||||||
categoryFk: foreignKey({
|
tagFk: foreignKey({
|
||||||
columns: [table.categoryId],
|
columns: [table.tagId],
|
||||||
foreignColumns: [categories.id],
|
foreignColumns: [tags.id],
|
||||||
}),
|
}),
|
||||||
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
||||||
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
||||||
table.organizationId,
|
table.organizationId,
|
||||||
),
|
),
|
||||||
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
|
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),
|
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
|
||||||
}),
|
invoiceIdIdx: index("time_entries_invoice_id_idx").on(table.invoiceId),
|
||||||
);
|
|
||||||
|
|
||||||
export const tags = sqliteTable(
|
|
||||||
"tags",
|
|
||||||
{
|
|
||||||
id: text("id")
|
|
||||||
.primaryKey()
|
|
||||||
.$defaultFn(() => nanoid()),
|
|
||||||
organizationId: text("organization_id").notNull(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
color: text("color"),
|
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
|
||||||
() => new Date(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
(table: any) => ({
|
|
||||||
orgFk: foreignKey({
|
|
||||||
columns: [table.organizationId],
|
|
||||||
foreignColumns: [organizations.id],
|
|
||||||
}),
|
|
||||||
organizationIdIdx: index("tags_organization_id_idx").on(
|
|
||||||
table.organizationId,
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const timeEntryTags = sqliteTable(
|
|
||||||
"time_entry_tags",
|
|
||||||
{
|
|
||||||
timeEntryId: text("time_entry_id").notNull(),
|
|
||||||
tagId: text("tag_id").notNull(),
|
|
||||||
},
|
|
||||||
(table: any) => ({
|
|
||||||
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
|
|
||||||
timeEntryFk: foreignKey({
|
|
||||||
columns: [table.timeEntryId],
|
|
||||||
foreignColumns: [timeEntries.id],
|
|
||||||
}),
|
|
||||||
tagFk: foreignKey({
|
|
||||||
columns: [table.tagId],
|
|
||||||
foreignColumns: [tags.id],
|
|
||||||
}),
|
|
||||||
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
|
|
||||||
table.timeEntryId,
|
|
||||||
),
|
|
||||||
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -270,19 +229,19 @@ export const invoices = sqliteTable(
|
|||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text("organization_id").notNull(),
|
||||||
clientId: text("client_id").notNull(),
|
clientId: text("client_id").notNull(),
|
||||||
number: text("number").notNull(),
|
number: text("number").notNull(),
|
||||||
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote'
|
type: text("type").notNull().default("invoice"),
|
||||||
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined'
|
status: text("status").notNull().default("draft"),
|
||||||
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
|
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
|
||||||
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
|
dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
currency: text("currency").default("USD").notNull(),
|
currency: text("currency").default("USD").notNull(),
|
||||||
subtotal: integer("subtotal").notNull().default(0), // in cents
|
subtotal: integer("subtotal").notNull().default(0),
|
||||||
discountValue: real("discount_value").default(0),
|
discountValue: real("discount_value").default(0),
|
||||||
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
|
discountType: text("discount_type").default("percentage"),
|
||||||
discountAmount: integer("discount_amount").default(0), // in cents
|
discountAmount: integer("discount_amount").default(0),
|
||||||
taxRate: real("tax_rate").default(0), // percentage
|
taxRate: real("tax_rate").default(0),
|
||||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
taxAmount: integer("tax_amount").notNull().default(0),
|
||||||
total: integer("total").notNull().default(0), // in cents
|
total: integer("total").notNull().default(0),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
),
|
),
|
||||||
@@ -312,8 +271,8 @@ export const invoiceItems = sqliteTable(
|
|||||||
invoiceId: text("invoice_id").notNull(),
|
invoiceId: text("invoice_id").notNull(),
|
||||||
description: text("description").notNull(),
|
description: text("description").notNull(),
|
||||||
quantity: real("quantity").notNull().default(1),
|
quantity: real("quantity").notNull().default(1),
|
||||||
unitPrice: integer("unit_price").notNull().default(0), // in cents
|
unitPrice: integer("unit_price").notNull().default(0),
|
||||||
amount: integer("amount").notNull().default(0), // in cents
|
amount: integer("amount").notNull().default(0),
|
||||||
},
|
},
|
||||||
(table: any) => ({
|
(table: any) => ({
|
||||||
invoiceFk: foreignKey({
|
invoiceFk: foreignKey({
|
||||||
@@ -327,13 +286,13 @@ export const invoiceItems = sqliteTable(
|
|||||||
export const passkeys = sqliteTable(
|
export const passkeys = sqliteTable(
|
||||||
"passkeys",
|
"passkeys",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(), // The Credential ID
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id").notNull(),
|
userId: text("user_id").notNull(),
|
||||||
publicKey: text("public_key").notNull(), // Base64 encoded public key
|
publicKey: text("public_key").notNull(),
|
||||||
counter: integer("counter").notNull(),
|
counter: integer("counter").notNull(),
|
||||||
deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice'
|
deviceType: text("device_type").notNull(),
|
||||||
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
|
||||||
transports: text("transports"), // JSON stringified array
|
transports: text("transports"),
|
||||||
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
() => new Date(),
|
() => new Date(),
|
||||||
|
|||||||
1
src/env.d.ts
vendored
@@ -14,7 +14,6 @@ interface ImportMeta {
|
|||||||
declare namespace App {
|
declare namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user: import("./db/schema").User | null;
|
user: import("./db/schema").User | null;
|
||||||
session: import("./db/schema").Session | null;
|
|
||||||
scopes: string[] | null;
|
scopes: string[] | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import { Icon } from 'astro-icon/components';
|
import Icon from '../components/Icon.astro';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { members, organizations } from '../db/schema';
|
import { members, organizations } from '../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import Avatar from '../components/Avatar.astro';
|
import Avatar from '../components/Avatar.astro';
|
||||||
import { ClientRouter } from "astro:transitions";
|
import ThemeToggle from '../components/ThemeToggle.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -18,7 +18,6 @@ if (!user) {
|
|||||||
return Astro.redirect('/login');
|
return Astro.redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's team memberships
|
|
||||||
const userMemberships = await db.select({
|
const userMemberships = await db.select({
|
||||||
membership: members,
|
membership: members,
|
||||||
organization: organizations,
|
organization: organizations,
|
||||||
@@ -28,13 +27,25 @@ const userMemberships = await db.select({
|
|||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Get current team from cookie or use first membership
|
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
|
||||||
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/dashboard', label: 'Dashboard', icon: 'home', exact: true },
|
||||||
|
{ href: '/dashboard/tracker', label: 'Time Tracker', icon: 'clock' },
|
||||||
|
{ href: '/dashboard/invoices', label: 'Invoices & Quotes', icon: 'document-currency-dollar' },
|
||||||
|
{ href: '/dashboard/reports', label: 'Reports', icon: 'chart-bar' },
|
||||||
|
{ href: '/dashboard/clients', label: 'Clients', icon: 'building-office' },
|
||||||
|
{ href: '/dashboard/team', label: 'Team', icon: 'user-group' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isActive(item: { href: string; exact?: boolean }) {
|
||||||
|
if (item.exact) return Astro.url.pathname === item.href;
|
||||||
|
return Astro.url.pathname.startsWith(item.href);
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="description" content="Chronus Dashboard" />
|
<meta name="description" content="Chronus Dashboard" />
|
||||||
@@ -42,154 +53,157 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<ClientRouter />
|
<script is:inline>
|
||||||
|
const theme = localStorage.getItem('theme') || 'sunset';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-base-100 h-screen flex flex-col overflow-hidden">
|
<body class="bg-base-100 h-screen flex flex-col overflow-hidden">
|
||||||
<div class="drawer lg:drawer-open flex-1 overflow-auto">
|
<div class="drawer lg:drawer-open flex-1 overflow-auto">
|
||||||
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
<input id="my-drawer-2" type="checkbox" class="drawer-toggle" />
|
||||||
<div class="drawer-content flex flex-col h-full overflow-auto">
|
<div class="drawer-content flex flex-col h-full overflow-auto">
|
||||||
<!-- Navbar -->
|
<!-- Mobile Navbar -->
|
||||||
<div class="navbar bg-base-200/50 backdrop-blur-sm sticky top-0 z-50 lg:hidden border-b border-base-300/50">
|
<div class="navbar bg-base-100 sticky top-0 z-50 lg:hidden border-b border-base-content/20">
|
||||||
<div class="flex-none lg:hidden">
|
<div class="flex-none">
|
||||||
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost">
|
<label for="my-drawer-2" aria-label="open sidebar" class="btn btn-square btn-ghost btn-sm">
|
||||||
<Icon name="heroicons:bars-3" class="w-6 h-6" />
|
<Icon name="bars-3" class="w-5 h-5" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 px-2 flex items-center gap-2">
|
<div class="flex-1 px-2 flex items-center gap-2">
|
||||||
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
<img src="/logo.webp" alt="Chronus" class="h-7 w-7" />
|
||||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
<span class="text-lg font-bold text-primary">Chronus</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none">
|
||||||
|
<ThemeToggle client:load />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Page content here -->
|
<!-- Page content -->
|
||||||
<main class="p-6 md:p-8">
|
<main class="flex-1 p-4 sm:p-6 lg:p-8">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drawer-side z-50">
|
<div class="drawer-side z-50">
|
||||||
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||||
<ul class="menu bg-base-200/95 backdrop-blur-sm min-h-full w-80 p-4 border-r border-base-300/30">
|
<aside class="bg-base-200 min-h-full w-72 flex flex-col border-r border-base-content/20">
|
||||||
<!-- Sidebar content here -->
|
<!-- Logo -->
|
||||||
<li class="mb-6">
|
<div class="px-5 pt-5 pb-3">
|
||||||
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
|
<a href="/dashboard" class="flex items-center gap-2.5 group">
|
||||||
<img src="/logo.webp" alt="Chronus" class="h-10 w-10" />
|
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||||
Chronus
|
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</div>
|
||||||
|
|
||||||
{/* Team Switcher */}
|
<!-- Team Switcher -->
|
||||||
{userMemberships.length > 0 && (
|
{userMemberships.length > 0 && (
|
||||||
<li class="mb-4">
|
<div class="px-4 pb-2">
|
||||||
<div class="form-control">
|
<select
|
||||||
<select
|
class="select select-sm w-full bg-base-300 border-base-content/20 focus:border-primary focus:outline-none text-sm font-medium"
|
||||||
class="select select-bordered w-full font-semibold bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary focus:outline-none focus:outline-offset-0 transition-all duration-200 hover:border-primary/40 focus:ring-3 focus:ring-primary/15 [&>option]:bg-base-300 [&>option]:text-base-content [&>option]:p-2"
|
id="team-switcher"
|
||||||
id="team-switcher"
|
aria-label="Switch team"
|
||||||
onchange="document.cookie = 'currentTeamId=' + this.value + '; path=/'; window.location.reload();"
|
>
|
||||||
>
|
{userMemberships.map(({ membership, organization }) => (
|
||||||
{userMemberships.map(({ membership, organization }) => (
|
<option
|
||||||
<option
|
value={organization.id}
|
||||||
value={organization.id}
|
selected={organization.id === currentTeamId}
|
||||||
selected={organization.id === currentTeamId}
|
>
|
||||||
>
|
{organization.name}
|
||||||
{organization.name}
|
</option>
|
||||||
</option>
|
))}
|
||||||
))}
|
</select>
|
||||||
</select>
|
</div>
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userMemberships.length === 0 && (
|
{userMemberships.length === 0 && (
|
||||||
<li class="mb-4">
|
<div class="px-4 pb-2">
|
||||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm btn-block">
|
||||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
<Icon name="plus" class="w-4 h-4" />
|
||||||
Create Team
|
Create Team
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class="divider my-2"></div>
|
<div class="divider my-1 mx-4"></div>
|
||||||
|
|
||||||
<li><a href="/dashboard" class:list={[
|
<!-- Navigation -->
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
<nav class="flex-1 px-3">
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname === "/dashboard" }
|
<ul class="menu menu-sm gap-0.5 p-0">
|
||||||
]}>
|
{navItems.map(item => (
|
||||||
<Icon name="heroicons:home" class="w-5 h-5" />
|
<li>
|
||||||
Dashboard
|
<a href={item.href} class:list={[
|
||||||
</a></li>
|
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
|
||||||
<li><a href="/dashboard/tracker" class:list={[
|
isActive(item)
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
? "bg-primary text-primary-content"
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/tracker") }
|
: "text-base-content/70 hover:text-base-content hover:bg-base-300"
|
||||||
]}>
|
]}>
|
||||||
<Icon name="heroicons:clock" class="w-5 h-5" />
|
<Icon name={item.icon} class="w-[18px] h-[18px]" />
|
||||||
Time Tracker
|
{item.label}
|
||||||
</a></li>
|
</a>
|
||||||
<li><a href="/dashboard/invoices" class:list={[
|
</li>
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
))}
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/invoices") }
|
</ul>
|
||||||
]}>
|
|
||||||
<Icon name="heroicons:document-currency-dollar" class="w-5 h-5" />
|
|
||||||
Invoices & Quotes
|
|
||||||
</a></li>
|
|
||||||
<li><a href="/dashboard/reports" class:list={[
|
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/reports") }
|
|
||||||
]}>
|
|
||||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
|
||||||
Reports
|
|
||||||
</a></li>
|
|
||||||
<li><a href="/dashboard/clients" class:list={[
|
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/clients") }
|
|
||||||
]}>
|
|
||||||
<Icon name="heroicons:building-office" class="w-5 h-5" />
|
|
||||||
Clients
|
|
||||||
</a></li>
|
|
||||||
<li><a href="/dashboard/team" class:list={[
|
|
||||||
"hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/dashboard/team") }
|
|
||||||
]}>
|
|
||||||
<Icon name="heroicons:user-group" class="w-5 h-5" />
|
|
||||||
Team
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
{user.isSiteAdmin && (
|
{user.isSiteAdmin && (
|
||||||
<>
|
<>
|
||||||
<div class="divider my-2"></div>
|
<div class="divider my-1"></div>
|
||||||
<li><a href="/admin" class:list={[
|
<ul class="menu menu-sm p-0">
|
||||||
"font-semibold hover:bg-base-300/50 rounded-lg transition-colors active:bg-base-300/50!",
|
<li>
|
||||||
{ "bg-primary/10 text-primary relative before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:w-0.75 before:h-[70%] before:bg-primary before:rounded-r-full": Astro.url.pathname.startsWith("/admin") }
|
<a href="/admin" class:list={[
|
||||||
]}>
|
"rounded-lg gap-3 px-3 py-2.5 font-medium text-sm",
|
||||||
<Icon name="heroicons:cog-6-tooth" class="w-5 h-5" />
|
Astro.url.pathname.startsWith("/admin")
|
||||||
Site Admin
|
? "bg-primary text-primary-content"
|
||||||
</a></li>
|
: "text-base-content/70 hover:text-base-content hover:bg-base-300"
|
||||||
</>
|
]}>
|
||||||
)}
|
<Icon name="cog-6-tooth" class="w-[18px] h-[18px]" />
|
||||||
|
Site Admin
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="divider my-2"></div>
|
<!-- Bottom Section -->
|
||||||
|
<div class="mt-auto border-t border-base-content/20">
|
||||||
|
<div class="p-3">
|
||||||
|
<a href="/dashboard/settings" class="flex items-center gap-3 rounded-lg p-2.5 hover:bg-base-300 group">
|
||||||
|
<Avatar name={user.name} />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-sm truncate">{user.name}</div>
|
||||||
|
<div class="text-xs text-base-content/60 truncate">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
<Icon name="chevron-right" class="w-4 h-4 text-base-content/50 group-hover:text-base-content/70" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<li>
|
<div class="flex items-center justify-between px-5 pb-2">
|
||||||
<a href="/dashboard/settings" class="flex items-center gap-3 bg-base-300/30 hover:bg-base-300/60 rounded-lg p-3 transition-colors">
|
<span class="text-xs text-base-content/60 font-medium">Theme</span>
|
||||||
<Avatar name={user.name} />
|
<ThemeToggle client:load />
|
||||||
<div class="flex-1 min-w-0">
|
</div>
|
||||||
<div class="font-semibold text-sm truncate">{user.name}</div>
|
|
||||||
<div class="text-xs text-base-content/50 truncate">{user.email}</div>
|
|
||||||
</div>
|
|
||||||
<Icon name="heroicons:chevron-right" class="w-4 h-4 opacity-40" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
<div class="px-3 pb-3">
|
||||||
<form action="/api/auth/logout" method="POST" class="contents">
|
<button id="logout-btn" type="button" class="btn btn-ghost btn-sm btn-block justify-start gap-2 text-base-content/60 hover:text-error hover:bg-base-300 font-medium">
|
||||||
<button type="submit" class="flex w-full items-center gap-2 py-2 px-4 text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
|
<Icon name="arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
|
||||||
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</aside>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
const teamSwitcher = document.getElementById('team-switcher') as HTMLSelectElement | null;
|
||||||
|
teamSwitcher?.addEventListener('change', () => {
|
||||||
|
document.cookie = 'currentTeamId=' + teamSwitcher.value + '; path=/';
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutBtn = document.getElementById('logout-btn');
|
||||||
|
logoutBtn?.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
import { ClientRouter } from "astro:transitions";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -10,7 +9,7 @@ const { title } = Astro.props;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="description" content="Chronus Time Tracking" />
|
<meta name="description" content="Chronus Time Tracking" />
|
||||||
@@ -18,7 +17,10 @@ const { title } = Astro.props;
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<ClientRouter />
|
<script is:inline>
|
||||||
|
const theme = localStorage.getItem('theme') || 'sunset';
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
|
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
|
||||||
<div class="flex-1 flex flex-col">
|
<div class="flex-1 flex flex-col">
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last used at
|
|
||||||
await db
|
await db
|
||||||
.update(apiTokens)
|
.update(apiTokens)
|
||||||
.set({ lastUsedAt: new Date() })
|
.set({ lastUsedAt: new Date() })
|
||||||
|
|||||||
100
src/lib/auth.ts
@@ -1,50 +1,74 @@
|
|||||||
import { db } from '../db';
|
import { db } from "../db";
|
||||||
import { users, sessions } from '../db/schema';
|
import { users } from "../db/schema";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from 'nanoid';
|
import jwt from "jsonwebtoken";
|
||||||
|
import type { AstroCookies } from "astro";
|
||||||
|
|
||||||
const SESSION_DURATION = 1000 * 60 * 60 * 24 * 30; // 30 days
|
const JWT_SECRET =
|
||||||
|
process.env.JWT_SECRET || "chronus-dev-secret-change-in-production";
|
||||||
|
const JWT_EXPIRES_IN = "30d";
|
||||||
|
|
||||||
export async function createSession(userId: string) {
|
interface JwtPayload {
|
||||||
const sessionId = nanoid();
|
userId: string;
|
||||||
const expiresAt = new Date(Date.now() + SESSION_DURATION);
|
}
|
||||||
|
|
||||||
await db.insert(sessions).values({
|
export function createToken(userId: string): string {
|
||||||
id: sessionId,
|
return jwt.sign({ userId } satisfies JwtPayload, JWT_SECRET, {
|
||||||
userId,
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
expiresAt,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { sessionId, expiresAt };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateSession(sessionId: string) {
|
export function verifyToken(token: string): JwtPayload | null {
|
||||||
const result = await db.select({
|
try {
|
||||||
user: users,
|
return jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
session: sessions
|
} catch {
|
||||||
})
|
|
||||||
.from(sessions)
|
|
||||||
.innerJoin(users, eq(sessions.userId, users.id))
|
|
||||||
.where(eq(sessions.id, sessionId))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { session, user } = result;
|
|
||||||
|
|
||||||
if (Date.now() >= session.expiresAt.getTime()) {
|
|
||||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { session, user };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateSession(sessionId: string) {
|
export function setAuthCookie(cookies: AstroCookies, userId: string) {
|
||||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
const token = createToken(userId);
|
||||||
|
cookies.set("auth_token", token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
secure: import.meta.env.PROD,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAuthCookie(cookies: AstroCookies) {
|
||||||
|
cookies.delete("auth_token", { path: "/" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserFromToken(token: string) {
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, payload.userId))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
return user ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public origin and hostname from the ORIGIN environment variable.
|
||||||
|
* This is required for WebAuthn/passkey rpID to match the browser's origin.
|
||||||
|
*/
|
||||||
|
export function getOrigin(): { hostname: string; origin: string } {
|
||||||
|
const origin = process.env.ORIGIN;
|
||||||
|
if (!origin) {
|
||||||
|
throw new Error("ORIGIN environment variable is not set");
|
||||||
|
}
|
||||||
|
const url = new URL(origin);
|
||||||
|
return {
|
||||||
|
hostname: url.hostname,
|
||||||
|
origin: url.origin,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hashPassword(password: string) {
|
export async function hashPassword(password: string) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||||
*/
|
*/
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
// Calculate rounded version for easy reading
|
|
||||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
@@ -26,3 +25,16 @@ export function formatTimeRange(start: Date, end: Date | null): string {
|
|||||||
const ms = end.getTime() - start.getTime();
|
const ms = end.getTime() - start.getTime();
|
||||||
return formatDuration(ms);
|
return formatDuration(ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a cent-based amount as a currency string.
|
||||||
|
* @param amount - Amount in cents (e.g. 1500 = $15.00)
|
||||||
|
* @param currency - ISO 4217 currency code (default: 'USD')
|
||||||
|
* @returns Formatted currency string like "$15.00"
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency: string = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount / 100);
|
||||||
|
}
|
||||||
|
|||||||
24
src/lib/getCurrentTeam.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,63 +1,66 @@
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { clients, categories, tags as tagsTable } from "../db/schema";
|
import { clients, tags as tagsTable } from "../db/schema";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
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({
|
export async function validateTimeEntryResources({
|
||||||
organizationId,
|
organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
categoryId,
|
tagId,
|
||||||
tagIds,
|
|
||||||
}: {
|
}: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
categoryId: string;
|
tagId?: string | null;
|
||||||
tagIds?: string[];
|
|
||||||
}) {
|
}) {
|
||||||
const [client, category] = await Promise.all([
|
const client = await db
|
||||||
db
|
.select()
|
||||||
.select()
|
.from(clients)
|
||||||
.from(clients)
|
.where(
|
||||||
.where(
|
and(eq(clients.id, clientId), eq(clients.organizationId, organizationId)),
|
||||||
and(
|
)
|
||||||
eq(clients.id, clientId),
|
.get();
|
||||||
eq(clients.organizationId, organizationId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get(),
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(categories)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(categories.id, categoryId),
|
|
||||||
eq(categories.organizationId, organizationId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return { valid: false, error: "Invalid client" };
|
return { valid: false, error: "Invalid client" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!category) {
|
if (tagId) {
|
||||||
return { valid: false, error: "Invalid category" };
|
const validTag = await db
|
||||||
}
|
|
||||||
|
|
||||||
if (tagIds && tagIds.length > 0) {
|
|
||||||
const validTags = await db
|
|
||||||
.select()
|
.select()
|
||||||
.from(tagsTable)
|
.from(tagsTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(tagsTable.id, tagIds),
|
eq(tagsTable.id, tagId),
|
||||||
eq(tagsTable.organizationId, organizationId),
|
eq(tagsTable.organizationId, organizationId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all();
|
.get();
|
||||||
|
|
||||||
if (validTags.length !== tagIds.length) {
|
if (!validTag) {
|
||||||
return { valid: false, error: "Invalid tags" };
|
return { valid: false, error: "Invalid tag" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,3 +84,9 @@ export function validateTimeRange(
|
|||||||
|
|
||||||
return { valid: true, startDate, endDate };
|
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,8 +1,12 @@
|
|||||||
import { defineMiddleware } from "astro/middleware";
|
import { defineMiddleware } from "astro/middleware";
|
||||||
import { validateSession } from "./lib/auth";
|
import { getUserFromToken } from "./lib/auth";
|
||||||
import { validateApiToken } from "./lib/api-auth";
|
import { validateApiToken } from "./lib/api-auth";
|
||||||
|
|
||||||
|
const PUBLIC_ROUTES = ["/", "/login", "/signup"];
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async (context, next) => {
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
|
const { pathname } = context.url;
|
||||||
|
|
||||||
const authHeader = context.request.headers.get("Authorization");
|
const authHeader = context.request.headers.get("Authorization");
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
@@ -10,32 +14,26 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
context.locals.user = result.user;
|
context.locals.user = result.user;
|
||||||
context.locals.session = null;
|
|
||||||
context.locals.scopes = result.scopes;
|
context.locals.scopes = result.scopes;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = context.cookies.get("session_id")?.value;
|
const token = context.cookies.get("auth_token")?.value;
|
||||||
|
|
||||||
if (!sessionId) {
|
if (token) {
|
||||||
context.locals.user = null;
|
const user = await getUserFromToken(token);
|
||||||
context.locals.session = null;
|
context.locals.user = user;
|
||||||
context.locals.scopes = null;
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await validateSession(sessionId);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
context.locals.user = result.user;
|
|
||||||
context.locals.session = result.session;
|
|
||||||
context.locals.scopes = null;
|
|
||||||
} else {
|
} else {
|
||||||
context.locals.user = null;
|
context.locals.user = null;
|
||||||
context.locals.session = null;
|
}
|
||||||
context.locals.scopes = null;
|
context.locals.scopes = null;
|
||||||
context.cookies.delete("session_id");
|
|
||||||
|
const isPublic =
|
||||||
|
PUBLIC_ROUTES.includes(pathname) || pathname.startsWith("/api/");
|
||||||
|
|
||||||
|
if (!isPublic && !context.locals.user) {
|
||||||
|
return context.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import Avatar from '../../components/Avatar.astro';
|
import Avatar from '../../components/Avatar.astro';
|
||||||
|
import StatCard from '../../components/StatCard.astro';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { siteSettings, users } from '../../db/schema';
|
import { siteSettings, users } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
@@ -21,52 +22,52 @@ const allUsers = await db.select().from(users).all();
|
|||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Site Admin - Chronus">
|
<DashboardLayout title="Site Admin - Chronus">
|
||||||
<h1 class="text-3xl font-bold mb-6">Site Administration</h1>
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-extrabold tracking-tight">Site Administration</h1>
|
||||||
|
<p class="text-base-content/60 text-sm mt-1">Manage users and site settings</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
|
||||||
<!-- Statistics -->
|
<StatCard
|
||||||
<div class="stats shadow border border-base-200">
|
title="Total Users"
|
||||||
<div class="stat">
|
value={String(allUsers.length)}
|
||||||
<div class="stat-title">Total Users</div>
|
description="Registered accounts"
|
||||||
<div class="stat-value">{allUsers.length}</div>
|
icon="users"
|
||||||
</div>
|
color="text-primary"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
<div class="card card-border bg-base-100 mb-6">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<h2 class="card-title mb-4">Site Settings</h2>
|
<h2 class="text-sm font-semibold flex items-center gap-2 mb-4">Site Settings</h2>
|
||||||
|
|
||||||
<form method="POST" action="/api/admin/settings">
|
<form method="POST" action="/api/admin/settings">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label cursor-pointer">
|
<legend class="fieldset-legend text-xs">Allow New Registrations</legend>
|
||||||
<span class="label-text flex-1 min-w-0 pr-4">
|
<input
|
||||||
<div class="font-semibold">Allow New Registrations</div>
|
type="checkbox"
|
||||||
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
|
name="registration_enabled"
|
||||||
</span>
|
class="toggle toggle-primary shrink-0"
|
||||||
<input
|
checked={registrationEnabled}
|
||||||
type="checkbox"
|
/>
|
||||||
name="registration_enabled"
|
</fieldset>
|
||||||
class="toggle toggle-primary"
|
|
||||||
checked={registrationEnabled}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-6">
|
<div class="flex justify-end mt-4">
|
||||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
<button type="submit" class="btn btn-primary btn-sm">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users List -->
|
<!-- Users List -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-0">
|
||||||
<h2 class="card-title mb-4">All Users</h2>
|
<div class="px-4 py-3 border-b border-base-content/20">
|
||||||
|
<h2 class="text-sm font-semibold">All Users</h2>
|
||||||
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -77,22 +78,22 @@ const allUsers = await db.select().from(users).all();
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{allUsers.map(u => (
|
{allUsers.map(u => (
|
||||||
<tr>
|
<tr class="hover">
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Avatar name={u.name} />
|
<Avatar name={u.name} />
|
||||||
<div class="font-bold">{u.name}</div>
|
<div class="font-medium">{u.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{u.email}</td>
|
<td class="text-base-content/60">{u.email}</td>
|
||||||
<td>
|
<td>
|
||||||
{u.isSiteAdmin ? (
|
{u.isSiteAdmin ? (
|
||||||
<span class="badge badge-primary">Yes</span>
|
<span class="badge badge-xs badge-primary">Yes</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="badge badge-ghost">No</span>
|
<span class="badge badge-xs badge-ghost">No</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
|
<td class="text-base-content/60">{u.createdAt?.toLocaleDateString() ?? 'N/A'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { users } from "../../../db/schema";
|
import { users } from "../../../db/schema";
|
||||||
import { verifyPassword, createSession } from "../../../lib/auth";
|
import { verifyPassword, setAuthCookie } from "../../../lib/auth";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||||
@@ -23,15 +23,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
return redirect("/login?error=invalid_credentials");
|
return redirect("/login?error=invalid_credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, expiresAt } = await createSession(user.id);
|
setAuthCookie(cookies, user.id);
|
||||||
|
|
||||||
cookies.set("session_id", sessionId, {
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
secure: import.meta.env.PROD,
|
|
||||||
sameSite: "lax",
|
|
||||||
expires: expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return redirect("/dashboard");
|
return redirect("/dashboard");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { invalidateSession } from '../../../lib/auth';
|
import { clearAuthCookie } from '../../../lib/auth';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ cookies, redirect }) => {
|
export const POST: APIRoute = async ({ cookies }) => {
|
||||||
const sessionId = cookies.get('session_id')?.value;
|
clearAuthCookie(cookies);
|
||||||
if (sessionId) {
|
return new Response(null, { status: 200 });
|
||||||
await invalidateSession(sessionId);
|
|
||||||
cookies.delete('session_id', { path: '/' });
|
|
||||||
}
|
|
||||||
return redirect('/login');
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { verifyAuthenticationResponse } from "@simplewebauthn/server";
|
|||||||
import { db } from "../../../../../db";
|
import { db } from "../../../../../db";
|
||||||
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
|
import { users, passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||||
import { eq, and, gt } from "drizzle-orm";
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
import { createSession } from "../../../../../lib/auth";
|
import { setAuthCookie, getOrigin } from "../../../../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@@ -50,11 +50,12 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
let verification;
|
let verification;
|
||||||
try {
|
try {
|
||||||
|
const { origin, hostname } = getOrigin();
|
||||||
verification = await verifyAuthenticationResponse({
|
verification = await verifyAuthenticationResponse({
|
||||||
response: body,
|
response: body,
|
||||||
expectedChallenge: challenge as string,
|
expectedChallenge: challenge as string,
|
||||||
expectedOrigin: new URL(request.url).origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: new URL(request.url).hostname,
|
expectedRPID: hostname,
|
||||||
credential: {
|
credential: {
|
||||||
id: passkey.id,
|
id: passkey.id,
|
||||||
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
|
publicKey: new Uint8Array(Buffer.from(passkey.publicKey, "base64")),
|
||||||
@@ -65,7 +66,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
console.error("Passkey authentication verification failed:", error);
|
||||||
|
return new Response(JSON.stringify({ error: "Verification failed" }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,15 +83,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|||||||
})
|
})
|
||||||
.where(eq(passkeys.id, passkey.id));
|
.where(eq(passkeys.id, passkey.id));
|
||||||
|
|
||||||
const { sessionId, expiresAt } = await createSession(user.id);
|
setAuthCookie(cookies, user.id);
|
||||||
|
|
||||||
cookies.set("session_id", sessionId, {
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
secure: import.meta.env.PROD,
|
|
||||||
sameSite: "lax",
|
|
||||||
expires: expiresAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(passkeyChallenges)
|
.delete(passkeyChallenges)
|
||||||
|
|||||||
@@ -2,10 +2,18 @@ import type { APIRoute } from "astro";
|
|||||||
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||||
import { db } from "../../../../../db";
|
import { db } from "../../../../../db";
|
||||||
import { passkeyChallenges } from "../../../../../db/schema";
|
import { passkeyChallenges } from "../../../../../db/schema";
|
||||||
|
import { lte } from "drizzle-orm";
|
||||||
|
import { getOrigin } from "../../../../../lib/auth";
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
await db
|
||||||
|
.delete(passkeyChallenges)
|
||||||
|
.where(lte(passkeyChallenges.expiresAt, new Date()));
|
||||||
|
|
||||||
|
const { hostname } = getOrigin();
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID: new URL(request.url).hostname,
|
rpID: hostname,
|
||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { verifyRegistrationResponse } from "@simplewebauthn/server";
|
|||||||
import { db } from "../../../../../db";
|
import { db } from "../../../../../db";
|
||||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||||
import { eq, and, gt } from "drizzle-orm";
|
import { eq, and, gt } from "drizzle-orm";
|
||||||
|
import { getOrigin } from "../../../../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -41,14 +42,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
let verification;
|
let verification;
|
||||||
try {
|
try {
|
||||||
|
const { origin, hostname } = getOrigin();
|
||||||
verification = await verifyRegistrationResponse({
|
verification = await verifyRegistrationResponse({
|
||||||
response: body,
|
response: body,
|
||||||
expectedChallenge: challenge,
|
expectedChallenge: challenge,
|
||||||
expectedOrigin: new URL(request.url).origin,
|
expectedOrigin: origin,
|
||||||
expectedRPID: new URL(request.url).hostname,
|
expectedRPID: hostname,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
console.error("Passkey registration verification failed:", error);
|
||||||
|
return new Response(JSON.stringify({ error: "Verification failed" }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { APIRoute } from "astro";
|
|||||||
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||||
import { db } from "../../../../../db";
|
import { db } from "../../../../../db";
|
||||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, lte } from "drizzle-orm";
|
||||||
|
import { getOrigin } from "../../../../../lib/auth";
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request, locals }) => {
|
export const GET: APIRoute = async ({ request, locals }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -13,14 +14,19 @@ export const GET: APIRoute = async ({ request, locals }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's existing passkeys to prevent registering the same authenticator twice
|
await db
|
||||||
|
.delete(passkeyChallenges)
|
||||||
|
.where(lte(passkeyChallenges.expiresAt, new Date()));
|
||||||
|
|
||||||
const userPasskeys = await db.query.passkeys.findMany({
|
const userPasskeys = await db.query.passkeys.findMany({
|
||||||
where: eq(passkeys.userId, user.id),
|
where: eq(passkeys.userId, user.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { hostname } = getOrigin();
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
rpName: "Chronus",
|
rpName: "Chronus",
|
||||||
rpID: new URL(request.url).hostname,
|
rpID: hostname,
|
||||||
userName: user.email,
|
userName: user.email,
|
||||||
attestationType: "none",
|
attestationType: "none",
|
||||||
excludeCredentials: userPasskeys.map((passkey) => ({
|
excludeCredentials: userPasskeys.map((passkey) => ({
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
members,
|
members,
|
||||||
siteSettings,
|
siteSettings,
|
||||||
} from "../../../db/schema";
|
} from "../../../db/schema";
|
||||||
import { hashPassword, createSession } from "../../../lib/auth";
|
import { hashPassword, setAuthCookie } from "../../../lib/auth";
|
||||||
|
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation";
|
||||||
import { eq, count, sql } from "drizzle-orm";
|
import { eq, count, sql } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -37,6 +38,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
return redirect("/signup?error=missing_fields");
|
return redirect("/signup?error=missing_fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return redirect("/signup?error=invalid_email");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > MAX_LENGTHS.name) {
|
||||||
|
return redirect("/signup?error=name_too_long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > MAX_LENGTHS.password) {
|
||||||
|
return redirect("/signup?error=password_too_long");
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return redirect("/signup?error=password_too_short");
|
return redirect("/signup?error=password_too_short");
|
||||||
}
|
}
|
||||||
@@ -47,7 +60,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
.where(eq(users.email, email))
|
.where(eq(users.email, email))
|
||||||
.get();
|
.get();
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return redirect("/signup?error=user_exists");
|
return redirect("/login?registered=true");
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
@@ -73,15 +86,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
role: "owner",
|
role: "owner",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { sessionId, expiresAt } = await createSession(userId);
|
setAuthCookie(cookies, userId);
|
||||||
|
|
||||||
cookies.set("session_id", sessionId, {
|
|
||||||
path: "/",
|
|
||||||
httpOnly: true,
|
|
||||||
secure: import.meta.env.PROD,
|
|
||||||
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 type { APIRoute } from "astro";
|
||||||
import { db } from "../../../../db";
|
import { db } from "../../../../db";
|
||||||
import {
|
import { clients, members, timeEntries } from "../../../../db/schema";
|
||||||
clients,
|
import { eq, and } from "drizzle-orm";
|
||||||
members,
|
|
||||||
timeEntries,
|
|
||||||
timeEntryTags,
|
|
||||||
} from "../../../../db/schema";
|
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -57,23 +52,19 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
|||||||
return new Response("Not authorized", { status: 403 });
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientEntries = await db
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
.select({ id: timeEntries.id })
|
if (!isAdminOrOwner) {
|
||||||
.from(timeEntries)
|
if (locals.scopes) {
|
||||||
.where(eq(timeEntries.clientId, id))
|
return new Response(
|
||||||
.all();
|
JSON.stringify({ error: "Only owners and admins can delete clients" }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||||
const entryIds = clientEntries.map((e) => e.id);
|
);
|
||||||
|
}
|
||||||
if (entryIds.length > 0) {
|
return new Response("Only owners and admins can delete clients", { status: 403 });
|
||||||
await db
|
|
||||||
.delete(timeEntryTags)
|
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||||
|
|
||||||
await db.delete(clients).where(eq(clients.id, id)).run();
|
await db.delete(clients).where(eq(clients.id, id)).run();
|
||||||
|
|
||||||
if (locals.scopes) {
|
if (locals.scopes) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
|
|||||||
import { db } from "../../../../db";
|
import { db } from "../../../../db";
|
||||||
import { clients, members } from "../../../../db/schema";
|
import { clients, members } from "../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -49,6 +50,25 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
return new Response("Client name is required", { status: 400 });
|
return new Response("Client name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||||
|
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||||
|
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||||
|
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||||
|
if (lengthError) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: lengthError }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await db
|
const client = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -87,6 +107,17 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
return new Response("Not authorized", { status: 403 });
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Only owners and admins can update clients" }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("Only owners and admins can update clients", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../../../db";
|
|||||||
import { clients, members } from "../../../db/schema";
|
import { clients, members } from "../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -45,6 +46,25 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response("Name is required", { status: 400 });
|
return new Response("Name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||||
|
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||||
|
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||||
|
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||||
|
if (lengthError) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: lengthError }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const userOrg = await db
|
const userOrg = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -55,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response("No organization found", { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Only owners and admins can create clients" }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("Only owners and admins can create clients", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
|
|
||||||
await db.insert(clients).values({
|
await db.insert(clients).values({
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
return new Response("Invoice ID required", { status: 400 });
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -47,8 +45,12 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
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 {
|
try {
|
||||||
// Generate next invoice number
|
|
||||||
const lastInvoice = await db
|
const lastInvoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -74,11 +76,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert quote to invoice:
|
|
||||||
// 1. Change type to 'invoice'
|
|
||||||
// 2. Set status to 'draft' (so user can review before sending)
|
|
||||||
// 3. Update number to next invoice sequence
|
|
||||||
// 4. Update issue date to today
|
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { renderToStream } from "@ceereals/vue-pdf";
|
||||||
import { db } from "../../../../db";
|
import { db } from "../../../../db";
|
||||||
import {
|
import {
|
||||||
invoices,
|
invoices,
|
||||||
@@ -8,101 +9,108 @@ import {
|
|||||||
members,
|
members,
|
||||||
} from "../../../../db/schema";
|
} from "../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { renderToStream } from "@ceereals/vue-pdf";
|
|
||||||
import { createInvoiceDocument } from "../../../../pdf/generateInvoicePDF";
|
import { createInvoiceDocument } from "../../../../pdf/generateInvoicePDF";
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params, locals }) => {
|
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 {
|
try {
|
||||||
const { id } = params;
|
const document = createInvoiceDocument({
|
||||||
const user = locals.user;
|
invoice: {
|
||||||
|
...invoice,
|
||||||
|
notes: invoice.notes || null,
|
||||||
|
discountValue: invoice.discountValue ?? null,
|
||||||
|
discountType: invoice.discountType ?? null,
|
||||||
|
discountAmount: invoice.discountAmount ?? null,
|
||||||
|
taxRate: invoice.taxRate ?? null,
|
||||||
|
},
|
||||||
|
items,
|
||||||
|
client: {
|
||||||
|
name: client?.name || "Deleted Client",
|
||||||
|
email: client?.email || null,
|
||||||
|
street: client?.street || null,
|
||||||
|
city: client?.city || null,
|
||||||
|
state: client?.state || null,
|
||||||
|
zip: client?.zip || null,
|
||||||
|
country: client?.country || null,
|
||||||
|
},
|
||||||
|
organization: {
|
||||||
|
name: organization.name,
|
||||||
|
street: organization.street || null,
|
||||||
|
city: organization.city || null,
|
||||||
|
state: organization.state || null,
|
||||||
|
zip: organization.zip || null,
|
||||||
|
country: organization.country || null,
|
||||||
|
logoUrl: organization.logoUrl || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !id) {
|
const stream = await renderToStream(document);
|
||||||
return new Response("Unauthorized", { status: 401 });
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(chunk as Uint8Array);
|
||||||
}
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
// Fetch invoice with related data
|
return new Response(buffer, {
|
||||||
const invoiceResult = await db
|
headers: {
|
||||||
.select({
|
"Content-Type": "application/pdf",
|
||||||
invoice: invoices,
|
"Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.pdf"`,
|
||||||
client: clients,
|
},
|
||||||
organization: organizations,
|
});
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
|
||||||
.innerJoin(organizations, eq(invoices.organizationId, organizations.id))
|
|
||||||
.where(eq(invoices.id, id))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!invoiceResult) {
|
|
||||||
return new Response("Invoice not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { invoice, client, organization } = invoiceResult;
|
|
||||||
|
|
||||||
// Verify access
|
|
||||||
const membership = await db
|
|
||||||
.select()
|
|
||||||
.from(members)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(members.userId, user.id),
|
|
||||||
eq(members.organizationId, invoice.organizationId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
return new Response("Forbidden", { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch items
|
|
||||||
const items = await db
|
|
||||||
.select()
|
|
||||||
.from(invoiceItems)
|
|
||||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
return new Response("Client not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate PDF using Vue PDF
|
|
||||||
// Suppress verbose logging from PDF renderer
|
|
||||||
const originalConsoleLog = console.log;
|
|
||||||
const originalConsoleWarn = console.warn;
|
|
||||||
console.log = () => {};
|
|
||||||
console.warn = () => {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pdfDocument = createInvoiceDocument({
|
|
||||||
invoice,
|
|
||||||
items,
|
|
||||||
client,
|
|
||||||
organization,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = await renderToStream(pdfDocument);
|
|
||||||
|
|
||||||
// Restore console.log
|
|
||||||
console.log = originalConsoleLog;
|
|
||||||
console.warn = originalConsoleWarn;
|
|
||||||
|
|
||||||
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
|
|
||||||
|
|
||||||
return new Response(stream as any, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/pdf",
|
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (pdfError) {
|
|
||||||
// Restore console.log on error
|
|
||||||
console.log = originalConsoleLog;
|
|
||||||
console.warn = originalConsoleWarn;
|
|
||||||
throw pdfError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating PDF:", error);
|
console.error("Error generating PDF:", error);
|
||||||
return new Response("Error generating PDF", { status: 500 });
|
return new Response("Failed to generate PDF", { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
274
src/pages/api/invoices/[id]/import-time.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import {
|
||||||
|
invoices,
|
||||||
|
invoiceItems,
|
||||||
|
timeEntries,
|
||||||
|
members,
|
||||||
|
tags,
|
||||||
|
} from "../../../../db/schema";
|
||||||
|
import {
|
||||||
|
eq,
|
||||||
|
and,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
isNull,
|
||||||
|
isNotNull,
|
||||||
|
inArray,
|
||||||
|
sql,
|
||||||
|
desc,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return new Response("Unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
if (!id) {
|
||||||
|
return new Response("Invoice ID is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const startDateStr = formData.get("startDate") as string;
|
||||||
|
const endDateStr = formData.get("endDate") as string;
|
||||||
|
const groupByDay = formData.get("groupByDay") === "on";
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
return new Response("Start date and end date are required", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(startDateStr);
|
||||||
|
const endDate = new Date(endDateStr);
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const invoice = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
return new Response("Invoice not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await db
|
||||||
|
.select()
|
||||||
|
.from(members)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(members.userId, user.id),
|
||||||
|
eq(members.organizationId, invoice.organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
return new Response("Not authorized", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status !== "draft") {
|
||||||
|
return new Response("Can only import time into draft invoices", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await db
|
||||||
|
.select({
|
||||||
|
entry: timeEntries,
|
||||||
|
tag: tags,
|
||||||
|
})
|
||||||
|
.from(timeEntries)
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(timeEntries.organizationId, invoice.organizationId),
|
||||||
|
eq(timeEntries.clientId, invoice.clientId),
|
||||||
|
isNull(timeEntries.invoiceId),
|
||||||
|
isNotNull(timeEntries.endTime),
|
||||||
|
gte(timeEntries.startTime, startDate),
|
||||||
|
lte(timeEntries.startTime, endDate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(timeEntries.startTime));
|
||||||
|
|
||||||
|
const processedEntries = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
entry: typeof timeEntries.$inferSelect;
|
||||||
|
rates: number[];
|
||||||
|
tagNames: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const { entry, tag } of entries) {
|
||||||
|
if (!processedEntries.has(entry.id)) {
|
||||||
|
processedEntries.set(entry.id, {
|
||||||
|
entry,
|
||||||
|
rates: [],
|
||||||
|
tagNames: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const current = processedEntries.get(entry.id)!;
|
||||||
|
if (tag) {
|
||||||
|
if (tag.rate && tag.rate > 0) {
|
||||||
|
current.rates.push(tag.rate);
|
||||||
|
}
|
||||||
|
current.tagNames.push(tag.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItems: {
|
||||||
|
id: string;
|
||||||
|
invoiceId: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
amount: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const entryIdsToUpdate: string[] = [];
|
||||||
|
|
||||||
|
if (groupByDay) {
|
||||||
|
// Group by YYYY-MM-DD
|
||||||
|
const days = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
date: string;
|
||||||
|
totalDuration: number; // milliseconds
|
||||||
|
totalAmount: number; // cents
|
||||||
|
entries: string[]; // ids
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const { entry, rates } of processedEntries.values()) {
|
||||||
|
if (!entry.endTime) continue;
|
||||||
|
const dateKey = entry.startTime.toISOString().split("T")[0];
|
||||||
|
const duration = entry.endTime.getTime() - entry.startTime.getTime();
|
||||||
|
const hours = duration / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
// Determine rate: max of tags, or 0
|
||||||
|
const rate = rates.length > 0 ? Math.max(...rates) : 0;
|
||||||
|
const amount = Math.round(hours * rate);
|
||||||
|
|
||||||
|
if (!days.has(dateKey)) {
|
||||||
|
days.set(dateKey, {
|
||||||
|
date: dateKey,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
entries: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = days.get(dateKey)!;
|
||||||
|
day.totalDuration += duration;
|
||||||
|
day.totalAmount += amount;
|
||||||
|
day.entries.push(entry.id);
|
||||||
|
entryIdsToUpdate.push(entry.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const day of days.values()) {
|
||||||
|
const hours = day.totalDuration / (1000 * 60 * 60);
|
||||||
|
const unitPrice = hours > 0 ? Math.round(day.totalAmount / hours) : 0;
|
||||||
|
|
||||||
|
newItems.push({
|
||||||
|
id: nanoid(),
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
description: `Time entries for ${day.date} (${day.entries.length} entries)`,
|
||||||
|
quantity: parseFloat(hours.toFixed(2)),
|
||||||
|
unitPrice,
|
||||||
|
amount: day.totalAmount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Individual items
|
||||||
|
for (const { entry, rates, tagNames } of processedEntries.values()) {
|
||||||
|
if (!entry.endTime) continue;
|
||||||
|
const duration = entry.endTime.getTime() - entry.startTime.getTime();
|
||||||
|
const hours = duration / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
const rate = rates.length > 0 ? Math.max(...rates) : 0;
|
||||||
|
const amount = Math.round(hours * rate);
|
||||||
|
|
||||||
|
let description = entry.description || "Time Entry";
|
||||||
|
const dateStr = entry.startTime.toLocaleDateString();
|
||||||
|
description = `[${dateStr}] ${description}`;
|
||||||
|
|
||||||
|
if (tagNames.length > 0) {
|
||||||
|
description += ` (${tagNames.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
newItems.push({
|
||||||
|
id: nanoid(),
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
description,
|
||||||
|
quantity: parseFloat(hours.toFixed(2)),
|
||||||
|
unitPrice: rate,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
entryIdsToUpdate.push(entry.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newItems.length === 0) {
|
||||||
|
return redirect(`/dashboard/invoices/${id}?error=no-entries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(invoiceItems).values(newItems);
|
||||||
|
|
||||||
|
if (entryIdsToUpdate.length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(timeEntries)
|
||||||
|
.set({ invoiceId: invoice.id })
|
||||||
|
.where(inArray(timeEntries.id, entryIdsToUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
const allItems = await tx
|
||||||
|
.select()
|
||||||
|
.from(invoiceItems)
|
||||||
|
.where(eq(invoiceItems.invoiceId, invoice.id));
|
||||||
|
|
||||||
|
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
|
||||||
|
let discountAmount = 0;
|
||||||
|
if (invoice.discountType === "percentage") {
|
||||||
|
discountAmount = Math.round(
|
||||||
|
subtotal * ((invoice.discountValue || 0) / 100),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||||
|
if (invoice.discountValue && invoice.discountValue > 0) {
|
||||||
|
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
||||||
|
const taxAmount = Math.round(
|
||||||
|
taxableAmount * ((invoice.taxRate || 0) / 100),
|
||||||
|
);
|
||||||
|
const total = subtotal - discountAmount + taxAmount;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
subtotal,
|
||||||
|
discountAmount,
|
||||||
|
taxAmount,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoice.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(`/dashboard/invoices/${id}?success=imported`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error importing time entries:", error);
|
||||||
|
return new Response("Failed to import time entries", { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { db } from "../../../../../db";
|
|||||||
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({
|
export const POST: APIRoute = async ({
|
||||||
request,
|
request,
|
||||||
@@ -61,10 +62,14 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Missing required fields", { status: 400 });
|
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 quantity = parseFloat(quantityStr);
|
||||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||||
|
|
||||||
// Convert to cents
|
|
||||||
const unitPrice = Math.round(unitPriceMajor * 100);
|
const unitPrice = Math.round(unitPriceMajor * 100);
|
||||||
const amount = Math.round(quantity * unitPrice);
|
const amount = Math.round(quantity * unitPrice);
|
||||||
|
|
||||||
@@ -77,7 +82,6 @@ export const POST: APIRoute = async ({
|
|||||||
amount,
|
amount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update invoice totals
|
|
||||||
await recalculateInvoiceTotals(invoiceId);
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice ID required", { status: 400 });
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence and check status
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow editing if draft
|
|
||||||
if (invoice.status !== "draft") {
|
if (invoice.status !== "draft") {
|
||||||
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
return new Response("Cannot edit a finalized invoice", { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -59,7 +56,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Item ID required", { status: 400 });
|
return new Response("Item ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify item belongs to invoice
|
|
||||||
const item = await db
|
const item = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoiceItems)
|
.from(invoiceItems)
|
||||||
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
|
|||||||
try {
|
try {
|
||||||
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
|
||||||
|
|
||||||
// Update invoice totals
|
|
||||||
await recalculateInvoiceTotals(invoiceId);
|
await recalculateInvoiceTotals(invoiceId);
|
||||||
|
|
||||||
return redirect(`/dashboard/invoices/${invoiceId}`);
|
return redirect(`/dashboard/invoices/${invoiceId}`);
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invalid status", { status: 400 });
|
return new Response("Invalid status", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence and check ownership
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -46,7 +45,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -62,6 +60,13 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
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 {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice ID required", { status: 400 });
|
return new Response("Invoice ID required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch invoice to verify existence
|
|
||||||
const invoice = await db
|
const invoice = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Invoice not found", { status: 404 });
|
return new Response("Invoice not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify membership
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../../../../db";
|
|||||||
import { invoices, members } from "../../../../db/schema";
|
import { invoices, members } from "../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -56,6 +57,14 @@ export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
|||||||
return new Response("Missing required fields", { status: 400 });
|
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 {
|
try {
|
||||||
const issueDate = new Date(issueDateStr);
|
const issueDate = new Date(issueDateStr);
|
||||||
const dueDate = new Date(dueDateStr);
|
const dueDate = new Date(dueDateStr);
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export const POST: APIRoute = async ({ request, redirect, locals }) => {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
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 {
|
try {
|
||||||
// Delete invoice items first (manual cascade)
|
// Delete invoice items first (manual cascade)
|
||||||
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { organizations, members } from "../../../db/schema";
|
import { organizations, members } from "../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -19,6 +20,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
const state = formData.get("state") as string | null;
|
const state = formData.get("state") as string | null;
|
||||||
const zip = formData.get("zip") as string | null;
|
const zip = formData.get("zip") as string | null;
|
||||||
const country = formData.get("country") 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;
|
const logo = formData.get("logo") as File | null;
|
||||||
|
|
||||||
if (!organizationId || !name || name.trim().length === 0) {
|
if (!organizationId || !name || name.trim().length === 0) {
|
||||||
@@ -27,6 +30,18 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||||
|
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Country", country, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Currency", defaultCurrency, MAX_LENGTHS.currency);
|
||||||
|
if (lengthError) {
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify user is admin/owner of this organization
|
// Verify user is admin/owner of this organization
|
||||||
const membership = await db
|
const membership = await db
|
||||||
@@ -65,7 +80,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = logo.name.split(".").pop() || "png";
|
const rawExt = (logo.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z]/g, "");
|
||||||
|
const allowedExtensions = ["png", "jpg", "jpeg"];
|
||||||
|
const ext = allowedExtensions.includes(rawExt) ? rawExt : "png";
|
||||||
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
||||||
const dataDir = process.env.DATA_DIR
|
const dataDir = process.env.DATA_DIR
|
||||||
? process.env.DATA_DIR
|
? process.env.DATA_DIR
|
||||||
@@ -96,6 +113,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
state: state?.trim() || null,
|
state: state?.trim() || null,
|
||||||
zip: zip?.trim() || null,
|
zip: zip?.trim() || null,
|
||||||
country: country?.trim() || null,
|
country: country?.trim() || null,
|
||||||
|
defaultTaxRate: defaultTaxRate ? parseFloat(defaultTaxRate) : 0,
|
||||||
|
defaultCurrency: defaultCurrency || "USD",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (logoUrl) {
|
if (logoUrl) {
|
||||||
|
|||||||
@@ -1,72 +1,89 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, users, clients, categories } from '../../../db/schema';
|
import { timeEntries, members, users, clients, tags } from "../../../db/schema";
|
||||||
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
import { eq, and, gte, lte, desc } from "drizzle-orm";
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current team from cookie
|
// Get current team from cookie
|
||||||
const currentTeamId = cookies.get('currentTeamId')?.value;
|
const currentTeamId = cookies.get("currentTeamId")?.value;
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
const userMemberships = await db
|
||||||
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
if (userMemberships.length === 0) {
|
if (userMemberships.length === 0) {
|
||||||
return new Response('No organization found', { status: 404 });
|
return new Response("No organization found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
// Use current team or fallback to first membership
|
||||||
const userMembership = currentTeamId
|
const userMembership = currentTeamId
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
|
||||||
|
userMemberships[0]
|
||||||
: userMemberships[0];
|
: userMemberships[0];
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const selectedMemberId = url.searchParams.get('member') || '';
|
const selectedMemberId = url.searchParams.get("member") || "";
|
||||||
const selectedCategoryId = url.searchParams.get('category') || '';
|
const selectedClientId = url.searchParams.get("client") || "";
|
||||||
const selectedClientId = url.searchParams.get('client') || '';
|
const timeRange = url.searchParams.get("range") || "week";
|
||||||
const timeRange = url.searchParams.get('range') || 'week';
|
const customFrom = url.searchParams.get("from");
|
||||||
const customFrom = url.searchParams.get('from');
|
const customTo = url.searchParams.get("to");
|
||||||
const customTo = url.searchParams.get('to');
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let startDate = new Date();
|
let startDate = new Date();
|
||||||
let endDate = new Date();
|
let endDate = new Date();
|
||||||
|
|
||||||
switch (timeRange) {
|
switch (timeRange) {
|
||||||
case 'today':
|
case "today":
|
||||||
startDate.setHours(0, 0, 0, 0);
|
startDate.setHours(0, 0, 0, 0);
|
||||||
endDate.setHours(23, 59, 59, 999);
|
endDate.setHours(23, 59, 59, 999);
|
||||||
break;
|
break;
|
||||||
case 'week':
|
case "week":
|
||||||
startDate.setDate(now.getDate() - 7);
|
startDate.setDate(now.getDate() - 7);
|
||||||
break;
|
break;
|
||||||
case 'month':
|
case "month":
|
||||||
startDate.setMonth(now.getMonth() - 1);
|
startDate.setMonth(now.getMonth() - 1);
|
||||||
break;
|
break;
|
||||||
case 'mtd':
|
case "mtd":
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
break;
|
break;
|
||||||
case 'ytd':
|
case "ytd":
|
||||||
startDate = new Date(now.getFullYear(), 0, 1);
|
startDate = new Date(now.getFullYear(), 0, 1);
|
||||||
break;
|
break;
|
||||||
case 'last-month':
|
case "last-month":
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
endDate = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||||
break;
|
break;
|
||||||
case 'custom':
|
case "custom":
|
||||||
if (customFrom) {
|
if (customFrom) {
|
||||||
const parts = customFrom.split('-');
|
const parts = customFrom.split("-");
|
||||||
startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 0, 0, 0, 0);
|
startDate = new Date(
|
||||||
|
parseInt(parts[0]),
|
||||||
|
parseInt(parts[1]) - 1,
|
||||||
|
parseInt(parts[2]),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (customTo) {
|
if (customTo) {
|
||||||
const parts = customTo.split('-');
|
const parts = customTo.split("-");
|
||||||
endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]), 23, 59, 59, 999);
|
endDate = new Date(
|
||||||
|
parseInt(parts[0]),
|
||||||
|
parseInt(parts[1]) - 1,
|
||||||
|
parseInt(parts[2]),
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -81,31 +98,44 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
conditions.push(eq(timeEntries.userId, selectedMemberId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedCategoryId) {
|
|
||||||
conditions.push(eq(timeEntries.categoryId, selectedCategoryId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedClientId) {
|
if (selectedClientId) {
|
||||||
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
conditions.push(eq(timeEntries.clientId, selectedClientId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = await db.select({
|
const entries = await db
|
||||||
entry: timeEntries,
|
.select({
|
||||||
user: users,
|
entry: timeEntries,
|
||||||
client: clients,
|
user: users,
|
||||||
category: categories,
|
client: clients,
|
||||||
})
|
tag: tags,
|
||||||
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(users, eq(timeEntries.userId, users.id))
|
.innerJoin(users, eq(timeEntries.userId, users.id))
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
// Generate CSV
|
// Generate CSV
|
||||||
const headers = ['Date', 'Start Time', 'End Time', 'Duration (h)', 'Member', 'Client', 'Category', 'Description'];
|
const headers = [
|
||||||
const rows = entries.map(e => {
|
"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 start = e.entry.startTime;
|
||||||
const end = e.entry.endTime;
|
const end = e.entry.endTime;
|
||||||
|
|
||||||
@@ -114,24 +144,26 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
|
duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60); // Hours
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tagsStr = e.tag?.name || "";
|
||||||
|
|
||||||
return [
|
return [
|
||||||
start.toLocaleDateString(),
|
start.toLocaleDateString(),
|
||||||
start.toLocaleTimeString(),
|
start.toLocaleTimeString(),
|
||||||
end ? end.toLocaleTimeString() : '',
|
end ? end.toLocaleTimeString() : "",
|
||||||
end ? duration.toFixed(2) : 'Running',
|
end ? duration.toFixed(2) : "Running",
|
||||||
`"${(e.user.name || '').replace(/"/g, '""')}"`,
|
`"${sanitizeCell((e.user.name || "").replace(/"/g, '""'))}"`,
|
||||||
`"${(e.client.name || '').replace(/"/g, '""')}"`,
|
`"${sanitizeCell((e.client.name || "").replace(/"/g, '""'))}"`,
|
||||||
`"${(e.category.name || '').replace(/"/g, '""')}"`,
|
`"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
|
||||||
`"${(e.entry.description || '').replace(/"/g, '""')}"`
|
`"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
|
||||||
].join(',');
|
].join(",");
|
||||||
});
|
});
|
||||||
|
|
||||||
const csvContent = [headers.join(','), ...rows].join('\n');
|
const csvContent = [headers.join(","), ...rows].join("\n");
|
||||||
|
|
||||||
return new Response(csvContent, {
|
return new Response(csvContent, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/csv',
|
"Content-Type": "text/csv",
|
||||||
'Content-Disposition': `attachment; filename="time-entries-${startDate.toISOString().split('T')[0]}-to-${endDate.toISOString().split('T')[0]}.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
@@ -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
@@ -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
@@ -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 { db } from '../../../db';
|
||||||
import { users, members } from '../../../db/schema';
|
import { users, members } from '../../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { isValidEmail } from '../../../lib/validation';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -26,6 +27,10 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response('Email is required', { status: 400 });
|
return new Response('Email is required', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return new Response('Invalid email format', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!['member', 'admin'].includes(role)) {
|
if (!['member', 'admin'].includes(role)) {
|
||||||
return new Response('Invalid role', { status: 400 });
|
return new Response('Invalid role', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
import { timeEntries, members } from "../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import {
|
||||||
validateTimeEntryResources,
|
validateTimeEntryResources,
|
||||||
validateTimeRange,
|
validateTimeRange,
|
||||||
|
MAX_LENGTHS,
|
||||||
} from "../../../lib/validation";
|
} from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
@@ -17,7 +18,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { description, clientId, categoryId, startTime, endTime, tags } = body;
|
const { description, clientId, startTime, endTime, tagId } = body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@@ -27,11 +28,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!categoryId) {
|
if (description && description.length > MAX_LENGTHS.description) {
|
||||||
return new Response(JSON.stringify({ error: "Category is required" }), {
|
return new Response(
|
||||||
status: 400,
|
JSON.stringify({ error: `Description must be ${MAX_LENGTHS.description} characters or fewer` }),
|
||||||
headers: { "Content-Type": "application/json" },
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!startTime) {
|
if (!startTime) {
|
||||||
@@ -81,8 +82,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const resourceValidation = await validateTimeEntryResources({
|
const resourceValidation = await validateTimeEntryResources({
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
categoryId,
|
tagId: tagId || null,
|
||||||
tagIds: Array.isArray(tags) ? tags : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resourceValidation.valid) {
|
if (!resourceValidation.valid) {
|
||||||
@@ -101,23 +101,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
userId: locals.user.id,
|
userId: locals.user.id,
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
categoryId,
|
tagId: tagId || null,
|
||||||
startTime: startDate,
|
startTime: startDate,
|
||||||
endTime: endDate,
|
endTime: endDate,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
isManual: true,
|
isManual: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert tags if provided
|
|
||||||
if (tags && Array.isArray(tags) && tags.length > 0) {
|
|
||||||
await db.insert(timeEntryTags).values(
|
|
||||||
tags.map((tagId: string) => ({
|
|
||||||
timeEntryId: id,
|
|
||||||
tagId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
import { timeEntries, members } from "../../../db/schema";
|
||||||
import { eq, and, isNull } from "drizzle-orm";
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { validateTimeEntryResources } from "../../../lib/validation";
|
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||||
@@ -11,15 +11,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const description = body.description || "";
|
const description = body.description || "";
|
||||||
const clientId = body.clientId;
|
const clientId = body.clientId;
|
||||||
const categoryId = body.categoryId;
|
const tagId = body.tagId || null;
|
||||||
const tags = body.tags || [];
|
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
return new Response("Client is required", { status: 400 });
|
return new Response("Client is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!categoryId) {
|
if (description && description.length > MAX_LENGTHS.description) {
|
||||||
return new Response("Category is required", { status: 400 });
|
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const runningEntry = await db
|
const runningEntry = await db
|
||||||
@@ -47,8 +46,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const validation = await validateTimeEntryResources({
|
const validation = await validateTimeEntryResources({
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
categoryId,
|
tagId,
|
||||||
tagIds: tags,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
@@ -63,20 +61,11 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
userId: locals.user.id,
|
userId: locals.user.id,
|
||||||
organizationId: member.organizationId,
|
organizationId: member.organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
categoryId,
|
tagId,
|
||||||
startTime,
|
startTime,
|
||||||
description,
|
description,
|
||||||
isManual: false,
|
isManual: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tags.length > 0) {
|
|
||||||
await db.insert(timeEntryTags).values(
|
|
||||||
tags.map((tagId: string) => ({
|
|
||||||
timeEntryId: id,
|
|
||||||
tagId,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
|
return new Response(JSON.stringify({ id, startTime }), { status: 200 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { db } from "../../../db";
|
|||||||
import { users } from "../../../db/schema";
|
import { users } from "../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { MAX_LENGTHS } from "../../../lib/validation";
|
||||||
|
import { setAuthCookie } from "../../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
const contentType = request.headers.get("content-type");
|
const contentType = request.headers.get("content-type");
|
||||||
const isJson = contentType?.includes("application/json");
|
const isJson = contentType?.includes("application/json");
|
||||||
@@ -53,6 +55,13 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response(msg, { status: 400 });
|
return new Response(msg, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
|
||||||
|
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
|
||||||
|
if (isJson)
|
||||||
|
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||||
|
return new Response(msg, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current user from database
|
// Get current user from database
|
||||||
const dbUser = await db
|
const dbUser = await db
|
||||||
@@ -90,6 +99,8 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
.where(eq(users.id, user.id))
|
.where(eq(users.id, user.id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
setAuthCookie(cookies, user.id);
|
||||||
|
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { clients, members } from '../../db/schema';
|
import { clients } from '../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const organizationId = userMembership.organizationId;
|
const organizationId = userMembership.organizationId;
|
||||||
|
|
||||||
@@ -32,20 +21,20 @@ const allClients = await db.select()
|
|||||||
|
|
||||||
<DashboardLayout title="Clients - Chronus">
|
<DashboardLayout title="Clients - Chronus">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-3xl font-bold">Clients</h1>
|
<h1 class="text-2xl font-extrabold tracking-tight">Clients</h1>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a>
|
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Client</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{allClients.map(client => (
|
{allClients.map(client => (
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-4 gap-1">
|
||||||
<h2 class="card-title">{client.name}</h2>
|
<h2 class="font-semibold">{client.name}</h2>
|
||||||
{client.email && <p class="text-sm text-gray-500">{client.email}</p>}
|
{client.email && <p class="text-sm text-base-content/60">{client.email}</p>}
|
||||||
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
<p class="text-xs text-base-content/60">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p>
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-3">
|
||||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-sm btn-ghost">View</a>
|
<a href={`/dashboard/clients/${client.id}`} class="btn btn-xs btn-ghost">View</a>
|
||||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-sm btn-primary">Edit</a>
|
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-xs btn-primary">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,9 +42,9 @@ const allClients = await db.select()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{allClients.length === 0 && (
|
{allClients.length === 0 && (
|
||||||
<div class="text-center py-12">
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p class="text-gray-500 mb-4">No clients yet</p>
|
<p class="text-base-content/60 text-sm mb-4">No clients yet</p>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-primary">Add Your First Client</a>
|
<a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Your First Client</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import Icon from '../../../../components/Icon.astro';
|
||||||
|
import ModalButton from '../../../../components/ModalButton.vue';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { clients, members } from '../../../../db/schema';
|
import { clients } from '../../../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
@@ -11,20 +13,8 @@ if (!user) return Astro.redirect('/login');
|
|||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
if (!id) return Astro.redirect('/dashboard/clients');
|
if (!id) return Astro.redirect('/dashboard/clients');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const client = await db.select()
|
const client = await db.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -40,145 +30,129 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
|||||||
<DashboardLayout title={`Edit ${client.name} - Chronus`}>
|
<DashboardLayout title={`Edit ${client.name} - Chronus`}>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">
|
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-xs">
|
||||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
<Icon name="arrow-left" class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-3xl font-bold">Edit Client</h1>
|
<h1 class="text-2xl font-extrabold tracking-tight">Edit Client</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action={`/api/clients/${client.id}/update`} class="card bg-base-100 shadow-xl border border-base-200">
|
<form method="POST" action={`/api/clients/${client.id}/update`} class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="name">
|
<legend class="fieldset-legend text-xs">Client Name</legend>
|
||||||
<span class="label-text">Client Name</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
value={client.name}
|
value={client.name}
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="email">
|
<legend class="fieldset-legend text-xs">Email (optional)</legend>
|
||||||
<span class="label-text">Email (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={client.email || ''}
|
value={client.email || ''}
|
||||||
placeholder="jason.borne@cia.com"
|
placeholder="jason.borne@cia.com"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="phone">
|
<legend class="fieldset-legend text-xs">Phone (optional)</legend>
|
||||||
<span class="label-text">Phone (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
id="phone"
|
id="phone"
|
||||||
name="phone"
|
name="phone"
|
||||||
value={client.phone || ''}
|
value={client.phone || ''}
|
||||||
placeholder="+1 (780) 420-1337"
|
placeholder="+1 (780) 420-1337"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="divider">Address Details</div>
|
<div class="divider text-xs text-base-content/60">Address Details</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="street">
|
<legend class="fieldset-legend text-xs">Street Address (optional)</legend>
|
||||||
<span class="label-text">Street Address (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="street"
|
id="street"
|
||||||
name="street"
|
name="street"
|
||||||
value={client.street || ''}
|
value={client.street || ''}
|
||||||
placeholder="123 Business Rd"
|
placeholder="123 Business Rd"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="city">
|
<legend class="fieldset-legend text-xs">City (optional)</legend>
|
||||||
<span class="label-text">City (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="city"
|
id="city"
|
||||||
name="city"
|
name="city"
|
||||||
value={client.city || ''}
|
value={client.city || ''}
|
||||||
placeholder="Edmonton"
|
placeholder="Edmonton"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="state">
|
<legend class="fieldset-legend text-xs">State / Province (optional)</legend>
|
||||||
<span class="label-text">State / Province (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="state"
|
id="state"
|
||||||
name="state"
|
name="state"
|
||||||
value={client.state || ''}
|
value={client.state || ''}
|
||||||
placeholder="AB"
|
placeholder="AB"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="zip">
|
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
|
||||||
<span class="label-text">Zip / Postal Code (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="zip"
|
id="zip"
|
||||||
name="zip"
|
name="zip"
|
||||||
value={client.zip || ''}
|
value={client.zip || ''}
|
||||||
placeholder="10001"
|
placeholder="10001"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="country">
|
<legend class="fieldset-legend text-xs">Country (optional)</legend>
|
||||||
<span class="label-text">Country (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="country"
|
id="country"
|
||||||
name="country"
|
name="country"
|
||||||
value={client.country || ''}
|
value={client.country || ''}
|
||||||
placeholder="Canada"
|
placeholder="Canada"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-between mt-6">
|
<div class="flex justify-between items-center mt-4">
|
||||||
<button
|
<ModalButton
|
||||||
type="button"
|
client:load
|
||||||
class="btn btn-error btn-outline"
|
modalId="delete_modal"
|
||||||
onclick={`document.getElementById('delete_modal').showModal()`}
|
class="btn btn-error btn-outline btn-sm"
|
||||||
>
|
>
|
||||||
Delete Client
|
Delete Client
|
||||||
</button>
|
</ModalButton>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost">Cancel</a>
|
<a href={`/dashboard/clients/${client.id}`} class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,17 +162,17 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
|||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<dialog id="delete_modal" class="modal">
|
<dialog id="delete_modal" class="modal">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg text-error">Delete Client?</h3>
|
<h3 class="font-semibold text-base text-error">Delete Client?</h3>
|
||||||
<p class="py-4">
|
<p class="py-4 text-sm">
|
||||||
Are you sure you want to delete <strong>{client.name}</strong>?
|
Are you sure you want to delete <strong>{client.name}</strong>?
|
||||||
This action cannot be undone and will delete all associated time entries.
|
This action cannot be undone and will delete all associated time entries.
|
||||||
</p>
|
</p>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<button class="btn">Cancel</button>
|
<button class="btn btn-sm">Cancel</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action={`/api/clients/${client.id}/delete`}>
|
<form method="POST" action={`/api/clients/${client.id}/delete`}>
|
||||||
<button type="submit" class="btn btn-error">Delete</button>
|
<button type="submit" class="btn btn-error btn-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import Icon from '../../../../components/Icon.astro';
|
||||||
|
import ConfirmForm from '../../../../components/ConfirmForm.vue';
|
||||||
|
import ColorDot from '../../../../components/ColorDot.vue';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { clients, timeEntries, members, categories, users } from '../../../../db/schema';
|
import { clients, timeEntries, tags, users } from '../../../../db/schema';
|
||||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||||
|
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||||
|
import StatCard from '../../../../components/StatCard.astro';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
@@ -12,20 +16,8 @@ if (!user) return Astro.redirect('/login');
|
|||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
if (!id) return Astro.redirect('/dashboard/clients');
|
if (!id) return Astro.redirect('/dashboard/clients');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const client = await db.select()
|
const client = await db.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -40,12 +32,12 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
|||||||
// Get recent activity
|
// Get recent activity
|
||||||
const recentEntries = await db.select({
|
const recentEntries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
category: categories,
|
|
||||||
user: users,
|
user: users,
|
||||||
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.leftJoin(categories, eq(timeEntries.categoryId, categories.id))
|
|
||||||
.leftJoin(users, eq(timeEntries.userId, users.id))
|
.leftJoin(users, eq(timeEntries.userId, users.id))
|
||||||
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(eq(timeEntries.clientId, client.id))
|
.where(eq(timeEntries.clientId, client.id))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(10)
|
.limit(10)
|
||||||
@@ -73,35 +65,35 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
|
|
||||||
<DashboardLayout title={`${client.name} - Clients - Chronus`}>
|
<DashboardLayout title={`${client.name} - Clients - Chronus`}>
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">
|
<a href="/dashboard/clients" class="btn btn-ghost btn-xs">
|
||||||
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
<Icon name="arrow-left" class="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-3xl font-bold">{client.name}</h1>
|
<h1 class="text-2xl font-extrabold tracking-tight">{client.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
|
||||||
<!-- Client Details Card -->
|
<!-- Client Details Card -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 lg:col-span-2">
|
<div class="card card-border bg-base-100 lg:col-span-2">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
<h2 class="text-sm font-semibold mb-3">{client.name}</h2>
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2 mb-4">
|
||||||
{client.email && (
|
{client.email && (
|
||||||
<div class="flex items-center gap-2 text-base-content/70">
|
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||||
<Icon name="heroicons:envelope" class="w-4 h-4" />
|
<Icon name="envelope" class="w-4 h-4" />
|
||||||
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{client.phone && (
|
{client.phone && (
|
||||||
<div class="flex items-center gap-2 text-base-content/70">
|
<div class="flex items-center gap-2 text-base-content/60 text-sm">
|
||||||
<Icon name="heroicons:phone" class="w-4 h-4" />
|
<Icon name="phone" class="w-4 h-4" />
|
||||||
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
|
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(client.street || client.city || client.state || client.zip || client.country) && (
|
{(client.street || client.city || client.state || client.zip || client.country) && (
|
||||||
<div class="flex items-start gap-2 text-base-content/70">
|
<div class="flex items-start gap-2 text-base-content/60">
|
||||||
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
|
<Icon name="map-pin" class="w-4 h-4 mt-0.5" />
|
||||||
<div class="text-sm space-y-0.5">
|
<div class="text-sm space-y-0.5">
|
||||||
{client.street && <div>{client.street}</div>}
|
{client.street && <div>{client.street}</div>}
|
||||||
{(client.city || client.state || client.zip) && (
|
{(client.city || client.state || client.zip) && (
|
||||||
@@ -116,91 +108,90 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-xs">
|
||||||
<Icon name="heroicons:pencil" class="w-4 h-4" />
|
<Icon name="pencil" class="w-3 h-3" />
|
||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<form method="POST" action={`/api/clients/${client.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this client? This will also delete all associated time entries.');">
|
<ConfirmForm client:load message="Are you sure you want to delete this client? This will also delete all associated time entries." action={`/api/clients/${client.id}/delete`}>
|
||||||
<button type="submit" class="btn btn-error btn-outline btn-sm">
|
<button type="submit" class="btn btn-error btn-outline btn-xs">
|
||||||
<Icon name="heroicons:trash" class="w-4 h-4" />
|
<Icon name="trash" class="w-3 h-3" />
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</ConfirmForm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
<div class="stats shadow w-full">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-primary">
|
title="Total Time Tracked"
|
||||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
value={`${totalHours}h ${totalMinutes}m`}
|
||||||
</div>
|
description="Across all projects"
|
||||||
<div class="stat-title">Total Time Tracked</div>
|
icon="clock"
|
||||||
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
|
color="text-primary"
|
||||||
<div class="stat-desc">Across all projects</div>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
|
title="Total Entries"
|
||||||
<div class="stat">
|
value={String(totalEntriesCount)}
|
||||||
<div class="stat-figure text-secondary">
|
description="Recorded entries"
|
||||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
icon="list-bullet"
|
||||||
</div>
|
color="text-secondary"
|
||||||
<div class="stat-title">Total Entries</div>
|
/>
|
||||||
<div class="stat-value text-secondary">{totalEntriesCount}</div>
|
|
||||||
<div class="stat-desc">Recorded entries</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Meta Info Card -->
|
<!-- Meta Info Card -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200 h-fit">
|
<div class="card card-border bg-base-100 h-fit">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<h3 class="card-title text-lg mb-4">Information</h3>
|
<h3 class="text-sm font-semibold mb-3">Information</h3>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm font-medium text-base-content/60">Created</div>
|
<div class="text-xs text-base-content/60">Created</div>
|
||||||
<div>{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
<div class="text-sm">{client.createdAt?.toLocaleDateString() ?? 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-0">
|
||||||
<h2 class="card-title mb-4">Recent Activity</h2>
|
<div class="px-4 py-3 border-b border-base-content/20">
|
||||||
|
<h2 class="text-sm font-semibold">Recent Activity</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
{recentEntries.length > 0 ? (
|
{recentEntries.length > 0 ? (
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Category</th>
|
<th>Tag</th>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Duration</th>
|
<th>Duration</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recentEntries.map(({ entry, category, user: entryUser }) => (
|
{recentEntries.map(({ entry, tag, user: entryUser }) => (
|
||||||
<tr>
|
<tr class="hover">
|
||||||
<td>{entry.description || '-'}</td>
|
<td>{entry.description || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{category ? (
|
{tag ? (
|
||||||
<div class="flex items-center gap-2">
|
<div class="badge badge-xs badge-outline flex items-center gap-1">
|
||||||
<span class="w-2 h-2 rounded-full" style={`background-color: ${category.color}`}></span>
|
{tag.color && (
|
||||||
<span>{category.name}</span>
|
<ColorDot client:load color={tag.color} class="w-2 h-2 rounded-full" />
|
||||||
|
)}
|
||||||
|
<span>{tag.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>{entryUser?.name || 'Unknown'}</td>
|
<td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
|
||||||
<td>{entry.startTime.toLocaleDateString()}</td>
|
<td class="text-base-content/60">{entry.startTime.toLocaleDateString()}</td>
|
||||||
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
<td class="font-mono">{formatTimeRange(entry.startTime, entry.endTime)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -208,14 +199,14 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="text-center py-8 text-base-content/60">
|
<div class="text-center py-8 text-base-content/60 text-sm">
|
||||||
No time entries recorded for this client yet.
|
No time entries recorded for this client yet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentEntries.length > 0 && (
|
{recentEntries.length > 0 && (
|
||||||
<div class="card-actions justify-center mt-4">
|
<div class="flex justify-center py-3 border-t border-base-content/20">
|
||||||
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-sm">
|
<a href={`/dashboard/tracker?client=${client.id}`} class="btn btn-ghost btn-xs">
|
||||||
View All Entries
|
View All Entries
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,124 +7,108 @@ if (!user) return Astro.redirect('/login');
|
|||||||
|
|
||||||
<DashboardLayout title="New Client - Chronus">
|
<DashboardLayout title="New Client - Chronus">
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<h1 class="text-3xl font-bold mb-6">Add New Client</h1>
|
<h1 class="text-2xl font-extrabold tracking-tight mb-6">Add New Client</h1>
|
||||||
|
|
||||||
<form method="POST" action="/api/clients/create" class="card bg-base-100 shadow-xl border border-base-200">
|
<form method="POST" action="/api/clients/create" class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="name">
|
<legend class="fieldset-legend text-xs">Client Name</legend>
|
||||||
<span class="label-text">Client Name</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="email">
|
<legend class="fieldset-legend text-xs">Email (optional)</legend>
|
||||||
<span class="label-text">Email (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="jason.borne@cia.com"
|
placeholder="jason.borne@cia.com"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="phone">
|
<legend class="fieldset-legend text-xs">Phone (optional)</legend>
|
||||||
<span class="label-text">Phone (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
id="phone"
|
id="phone"
|
||||||
name="phone"
|
name="phone"
|
||||||
placeholder="+1 (780) 420-1337"
|
placeholder="+1 (780) 420-1337"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="divider">Address Details</div>
|
<div class="divider text-xs text-base-content/60">Address Details</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="street">
|
<legend class="fieldset-legend text-xs">Street Address (optional)</legend>
|
||||||
<span class="label-text">Street Address (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="street"
|
id="street"
|
||||||
name="street"
|
name="street"
|
||||||
placeholder="123 Business Rd"
|
placeholder="123 Business Rd"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="city">
|
<legend class="fieldset-legend text-xs">City (optional)</legend>
|
||||||
<span class="label-text">City (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="city"
|
id="city"
|
||||||
name="city"
|
name="city"
|
||||||
placeholder="Edmonton"
|
placeholder="Edmonton"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="state">
|
<legend class="fieldset-legend text-xs">State / Province (optional)</legend>
|
||||||
<span class="label-text">State / Province (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="state"
|
id="state"
|
||||||
name="state"
|
name="state"
|
||||||
placeholder="AB"
|
placeholder="AB"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="zip">
|
<legend class="fieldset-legend text-xs">Zip / Postal Code (optional)</legend>
|
||||||
<span class="label-text">Zip / Postal Code (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="zip"
|
id="zip"
|
||||||
name="zip"
|
name="zip"
|
||||||
placeholder="10001"
|
placeholder="10001"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-control">
|
<fieldset class="fieldset">
|
||||||
<label class="label" for="country">
|
<legend class="fieldset-legend text-xs">Country (optional)</legend>
|
||||||
<span class="label-text">Country (optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="country"
|
id="country"
|
||||||
name="country"
|
name="country"
|
||||||
placeholder="Canada"
|
placeholder="Canada"
|
||||||
class="input input-bordered w-full"
|
class="input w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-6">
|
<div class="flex justify-end gap-2 mt-4">
|
||||||
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
<a href="/dashboard/clients" class="btn btn-ghost btn-sm">Cancel</a>
|
||||||
<button type="submit" class="btn btn-primary">Create Client</button>
|
<button type="submit" class="btn btn-primary btn-sm">Create Client</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import Icon from '../../components/Icon.astro';
|
||||||
|
import StatCard from '../../components/StatCard.astro';
|
||||||
|
import ColorDot from '../../components/ColorDot.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { organizations, members, timeEntries, clients, categories } from '../../db/schema';
|
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||||
import { formatDuration } from '../../lib/formatTime';
|
import { formatDuration } from '../../lib/formatTime';
|
||||||
|
|
||||||
@@ -87,11 +89,11 @@ if (currentOrg) {
|
|||||||
stats.recentEntries = await db.select({
|
stats.recentEntries = await db.select({
|
||||||
entry: timeEntries,
|
entry: timeEntries,
|
||||||
client: clients,
|
client: clients,
|
||||||
category: categories,
|
tag: tags,
|
||||||
})
|
})
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
.innerJoin(clients, eq(timeEntries.clientId, clients.id))
|
||||||
.innerJoin(categories, eq(timeEntries.categoryId, categories.id))
|
.leftJoin(tags, eq(timeEntries.tagId, tags.id))
|
||||||
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
.where(eq(timeEntries.organizationId, currentOrg.organizationId))
|
||||||
.orderBy(desc(timeEntries.startTime))
|
.orderBy(desc(timeEntries.startTime))
|
||||||
.limit(5)
|
.limit(5)
|
||||||
@@ -103,28 +105,28 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Dashboard - Chronus">
|
<DashboardLayout title="Dashboard - Chronus">
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-8">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold text-primary mb-2">
|
<h1 class="text-2xl font-extrabold tracking-tight">
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-base-content/60">Welcome back, {user.name}!</p>
|
<p class="text-base-content/60 text-sm mt-1">Welcome back, {user.name}!</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/dashboard/organizations/new" class="btn btn-outline">
|
<a href="/dashboard/organizations/new" class="btn btn-ghost btn-sm">
|
||||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
<Icon name="plus" class="w-4 h-4" />
|
||||||
New Team
|
New Team
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasMembership && (
|
{!hasMembership && (
|
||||||
<div class="alert alert-info mb-8">
|
<div class="alert alert-info mb-6 text-sm">
|
||||||
<Icon name="heroicons:information-circle" class="w-6 h-6" />
|
<Icon name="information-circle" class="w-5 h-5" />
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Welcome to Chronus!</h3>
|
<h3 class="font-bold">Welcome to Chronus!</h3>
|
||||||
<div class="text-sm">You're not part of any team yet. Create one or wait for an invitation.</div>
|
<div class="text-xs">You're not part of any team yet. Create one or wait for an invitation.</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
<a href="/dashboard/organizations/new" class="btn btn-primary btn-sm">
|
||||||
<Icon name="heroicons:plus" class="w-4 h-4" />
|
<Icon name="plus" class="w-4 h-4" />
|
||||||
New Team
|
New Team
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,63 +135,56 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
{hasMembership && (
|
{hasMembership && (
|
||||||
<>
|
<>
|
||||||
<!-- Stats Overview -->
|
<!-- Stats Overview -->
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-primary">
|
title="This Week"
|
||||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
value={formatDuration(stats.totalTimeThisWeek)}
|
||||||
</div>
|
description="Total tracked time"
|
||||||
<div class="stat-title">This Week</div>
|
icon="clock"
|
||||||
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
|
color="text-primary"
|
||||||
<div class="stat-desc">Total tracked time</div>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
|
title="This Month"
|
||||||
<div class="stat">
|
value={formatDuration(stats.totalTimeThisMonth)}
|
||||||
<div class="stat-figure text-secondary">
|
description="Total tracked time"
|
||||||
<Icon name="heroicons:calendar" class="w-8 h-8" />
|
icon="calendar"
|
||||||
</div>
|
color="text-secondary"
|
||||||
<div class="stat-title">This Month</div>
|
/>
|
||||||
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
|
<StatCard
|
||||||
<div class="stat-desc">Total tracked time</div>
|
title="Active Timers"
|
||||||
</div>
|
value={String(stats.activeTimers)}
|
||||||
|
description="Currently running"
|
||||||
<div class="stat">
|
icon="play-circle"
|
||||||
<div class="stat-figure text-accent">
|
color="text-accent"
|
||||||
<Icon name="heroicons:play-circle" class="w-8 h-8" />
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
<div class="stat-title">Active Timers</div>
|
title="Clients"
|
||||||
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
|
value={String(stats.totalClients)}
|
||||||
<div class="stat-desc">Currently running</div>
|
description="Total active"
|
||||||
</div>
|
icon="building-office"
|
||||||
|
color="text-info"
|
||||||
<div class="stat">
|
/>
|
||||||
<div class="stat-figure text-info">
|
|
||||||
<Icon name="heroicons:building-office" class="w-8 h-8" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-title">Clients</div>
|
|
||||||
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
|
|
||||||
<div class="stat-desc">Total active</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 -->
|
<!-- Quick Actions -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<h2 class="card-title">
|
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||||
<Icon name="heroicons:bolt" class="w-6 h-6 text-warning" />
|
<Icon name="bolt" class="w-4 h-4 text-warning" />
|
||||||
Quick Actions
|
Quick Actions
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-col gap-3 mt-4">
|
<div class="flex flex-col gap-2 mt-3">
|
||||||
<a href="/dashboard/tracker" class="btn btn-primary">
|
<a href="/dashboard/tracker" class="btn btn-primary btn-sm">
|
||||||
<Icon name="heroicons:play" class="w-5 h-5" />
|
<Icon name="play" class="w-4 h-4" />
|
||||||
Start Timer
|
Start Timer
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-outline">
|
<a href="/dashboard/clients/new" class="btn btn-ghost btn-sm">
|
||||||
<Icon name="heroicons:plus" class="w-5 h-5" />
|
<Icon name="plus" class="w-4 h-4" />
|
||||||
Add Client
|
Add Client
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/reports" class="btn btn-outline">
|
<a href="/dashboard/reports" class="btn btn-ghost btn-sm">
|
||||||
<Icon name="heroicons:chart-bar" class="w-5 h-5" />
|
<Icon name="chart-bar" class="w-4 h-4" />
|
||||||
View Reports
|
View Reports
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,26 +192,31 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card card-border bg-base-100">
|
||||||
<div class="card-body">
|
<div class="card-body p-4">
|
||||||
<h2 class="card-title">
|
<h2 class="text-sm font-semibold flex items-center gap-2">
|
||||||
<Icon name="heroicons:clock" class="w-6 h-6 text-success" />
|
<Icon name="clock" class="w-4 h-4 text-success" />
|
||||||
Recent Activity
|
Recent Activity
|
||||||
</h2>
|
</h2>
|
||||||
{stats.recentEntries.length > 0 ? (
|
{stats.recentEntries.length > 0 ? (
|
||||||
<ul class="space-y-3 mt-4">
|
<ul class="space-y-2 mt-3">
|
||||||
{stats.recentEntries.map(({ entry, client, category }) => (
|
{stats.recentEntries.map(({ entry, client, tag }) => (
|
||||||
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${category.color || '#3b82f6'}`}>
|
<ColorDot client:load as="li" color={tag?.color || 'oklch(var(--p))'} borderColor class="p-2.5 rounded-lg bg-base-200 border-l-3 hover:bg-base-300 transition-colors">
|
||||||
<div class="font-semibold text-sm">{client.name}</div>
|
<div class="font-medium text-sm">{client.name}</div>
|
||||||
<div class="text-xs text-base-content/60 mt-1">
|
<div class="text-xs text-base-content/60 mt-0.5 flex flex-wrap gap-2 items-center">
|
||||||
{category.name} • {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}
|
<span class="flex gap-1 flex-wrap">
|
||||||
|
{tag ? (
|
||||||
|
<span class="badge badge-xs badge-outline">{tag.name}</span>
|
||||||
|
) : <span class="italic opacity-50">No tag</span>}
|
||||||
|
</span>
|
||||||
|
<span>· {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</ColorDot>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<div class="flex flex-col items-center justify-center py-8 text-center mt-4">
|
<div class="flex flex-col items-center justify-center py-6 text-center mt-3">
|
||||||
<Icon name="heroicons:clock" class="w-12 h-12 text-base-content/20 mb-3" />
|
<Icon name="clock" class="w-10 h-10 text-base-content/30 mb-2" />
|
||||||
<p class="text-base-content/60 text-sm">No recent time entries</p>
|
<p class="text-base-content/60 text-sm">No recent time entries</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||