18 Commits
2.3.0 ... main

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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -2,15 +2,14 @@
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: { security: {
checkOrigin: false,
csp: process.env.NODE_ENV === "production", csp: process.env.NODE_ENV === "production",
}, },
vite: { vite: {

1715
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ services:
- HOST=0.0.0.0 - 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

View File

@@ -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)"
''; '';
}; };
}); }
);
}; };
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "2.3.0", "version": "2.5.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
@@ -13,31 +13,29 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "0.9.6", "@astrojs/check": "0.9.6",
"@astrojs/node": "10.0.0-beta.0", "@astrojs/node": "9.5.4",
"@astrojs/vue": "6.0.0-beta.0", "@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": "6.0.0-beta.1", "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"
} }
} }

7955
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

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

View File

@@ -9,7 +9,7 @@ const initial = name ? name.charAt(0).toUpperCase() : '?';
--- ---
<div class:list={["avatar placeholder", className]}> <div class: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>

View File

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

View File

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

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

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

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

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

View File

@@ -1,5 +1,6 @@
<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 }[];
@@ -147,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">
@@ -164,37 +165,13 @@ 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>
@@ -206,7 +183,7 @@ function clearForm() {
<select <select
id="manual-client" id="manual-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="isSubmitting" :disabled="isSubmitting"
> >
<option value="">Select a client...</option> <option value="">Select a client...</option>
@@ -226,7 +203,7 @@ function clearForm() {
id="manual-start-date" 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>
@@ -239,7 +216,7 @@ function clearForm() {
id="manual-start-time" 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>
@@ -255,7 +232,7 @@ function clearForm() {
id="manual-end-date" 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>
@@ -268,7 +245,7 @@ function clearForm() {
id="manual-end-time" 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>
@@ -284,7 +261,7 @@ function clearForm() {
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>
@@ -301,7 +278,7 @@ function clearForm() {
'badge badge-lg cursor-pointer transition-all hover:scale-105', 'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTagId === 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"

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ const chartData = computed(() => ({
{ {
data: props.tags.map((t) => t.totalTime / (1000 * 60)), // Convert to minutes data: props.tags.map((t) => t.totalTime / (1000 * 60)), // Convert to minutes
backgroundColor: props.tags.map((t) => t.color), backgroundColor: props.tags.map((t) => t.color),
borderColor: "#1e293b", // Matches typical dark mode bg borderColor: "#1e293b",
borderWidth: 2, borderWidth: 2,
}, },
], ],

View File

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

View File

@@ -1,6 +1,6 @@
<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: {
@@ -118,7 +118,7 @@ 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 Row --> <!-- Client Row -->
@@ -129,7 +129,7 @@ async function stopTimer() {
<select <select
id="timer-client" 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>
@@ -149,7 +149,7 @@ async function stopTimer() {
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>
@@ -166,7 +166,7 @@ async function stopTimer() {
'badge badge-lg cursor-pointer transition-all hover:scale-105', 'badge badge-lg cursor-pointer transition-all hover:scale-105',
selectedTagId === 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"
@@ -188,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
@@ -196,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>

View File

@@ -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";
import { startAuthentication } from "@simplewebauthn/browser"; import { startAuthentication } from "@simplewebauthn/browser";
const loading = ref(false); const loading = ref(false);
@@ -60,12 +60,12 @@ async function handlePasskeyLogin() {
:disabled="loading" :disabled="loading"
> >
<span v-if="loading" class="loading loading-spinner loading-sm"></span> <span v-if="loading" class="loading loading-spinner loading-sm"></span>
<Icon v-else icon="heroicons:finger-print" class="w-5 h-5 mr-2" /> <Icon v-else name="finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey Sign in with Passkey
</button> </button>
<div v-if="error" role="alert" class="alert alert-error mt-4"> <div v-if="error" role="alert" class="alert alert-error mt-4">
<Icon icon="heroicons:exclamation-circle" class="w-6 h-6" /> <Icon name="exclamation-circle" class="w-6 h-6" />
<span>{{ error }}</span> <span>{{ error }}</span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } 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;
@@ -107,12 +107,12 @@ function closeShowTokenModal() {
<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
icon="heroicons:code-bracket-square" name="code-bracket-square"
class="w-5 h-5 sm:w-6 sm:h-6" class="w-5 h-5 sm:w-6 sm:h-6"
/> />
API Tokens API Tokens
@@ -121,7 +121,7 @@ function closeShowTokenModal() {
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
@click="createModalOpen = true" @click="createModalOpen = true"
> >
<Icon icon="heroicons:plus" class="w-4 h-4" /> <Icon name="plus" class="w-4 h-4" />
Create Token Create Token
</button> </button>
</div> </div>
@@ -161,7 +161,7 @@ function closeShowTokenModal() {
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>
@@ -222,7 +222,7 @@ 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">
@@ -239,7 +239,7 @@ function closeShowTokenModal() {
@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>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } 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 {
@@ -94,11 +94,11 @@ async function deletePasskey(id: string) {
</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 <button
@@ -110,7 +110,7 @@ async function deletePasskey(id: string) {
v-if="loading" v-if="loading"
class="loading loading-spinner loading-xs" class="loading loading-spinner loading-xs"
></span> ></span>
<Icon v-else icon="heroicons:plus" class="w-4 h-4" /> <Icon v-else name="plus" class="w-4 h-4" />
Add Passkey Add Passkey
</button> </button>
</div> </div>
@@ -157,7 +157,7 @@ async function deletePasskey(id: string) {
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>

View File

@@ -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 currentPassword = ref(""); const currentPassword = ref("");
const newPassword = ref(""); const newPassword = ref("");
@@ -76,20 +76,20 @@ async function changePassword() {
]" ]"
> >
<Icon <Icon
:icon=" :name="
message.type === 'success' message.type === 'success'
? 'heroicons:check-circle' ? 'check-circle'
: 'heroicons:exclamation-circle' : 'exclamation-circle'
" "
class="w-6 h-6 shrink-0" 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>
@@ -163,7 +163,7 @@ async function changePassword() {
v-if="loading" v-if="loading"
class="loading loading-spinner loading-sm" class="loading loading-spinner loading-sm"
></span> ></span>
<Icon v-else icon="heroicons:lock-closed" class="w-5 h-5" /> <Icon v-else name="lock-closed" class="w-5 h-5" />
Update Password Update Password
</button> </button>
</div> </div>

View File

@@ -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: {
@@ -61,20 +61,20 @@ async function updateProfile() {
]" ]"
> >
<Icon <Icon
:icon=" :name="
message.type === 'success' message.type === 'success'
? 'heroicons:check-circle' ? 'check-circle'
: 'heroicons:exclamation-circle' : 'exclamation-circle'
" "
class="w-6 h-6 shrink-0" 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>
@@ -128,7 +128,7 @@ async function updateProfile() {
v-if="loading" v-if="loading"
class="loading loading-spinner loading-sm" class="loading loading-spinner loading-sm"
></span> ></span>
<Icon v-else icon="heroicons:check" class="w-5 h-5" /> <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
View File

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

1
src/env.d.ts vendored
View File

@@ -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;
} }
} }

View File

@@ -1,12 +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 ThemeToggle from '../components/ThemeToggle.vue'; import ThemeToggle from '../components/ThemeToggle.vue';
import { ClientRouter } from "astro:transitions";
interface Props { interface Props {
title: string; title: string;
@@ -29,7 +28,20 @@ const userMemberships = await db.select({
.all(); .all();
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>
@@ -41,9 +53,8 @@ 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> <script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato'; const theme = localStorage.getItem('theme') || 'sunset';
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
</script> </script>
</head> </head>
@@ -51,158 +62,148 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<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>
<div class="flex-none"> <div class="flex-none">
<ThemeToggle client:load /> <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">
<div class="flex justify-between items-center p-2 hover:bg-transparent"> <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">
<span class="font-semibold text-sm text-base-content/70 pl-2">Theme</span> <Icon name="arrow-right-on-rectangle" class="w-[18px] h-[18px]" />
<ThemeToggle client:load />
</div>
</li>
<li>
<form action="/api/auth/logout" method="POST" class="contents">
<button type="submit" class="flex w-full items-center gap-2 py-2 px-4 text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
Logout 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>

View File

@@ -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;
@@ -18,9 +17,8 @@ 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> <script is:inline>
const theme = localStorage.getItem('theme') || 'macchiato'; const theme = localStorage.getItem('theme') || 'sunset';
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
</script> </script>
</head> </head>

View File

@@ -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) {

View File

@@ -25,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
View File

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

View File

@@ -2,6 +2,30 @@ import { db } from "../db";
import { clients, tags as tagsTable } from "../db/schema"; import { clients, tags as tagsTable } from "../db/schema";
import { eq, and } 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,
@@ -60,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;
}

View File

@@ -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();

View File

@@ -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 for="registration_enabled" class="label pb-2 font-medium text-sm sm:text-base"> <legend class="fieldset-legend text-xs">Allow New Registrations</legend>
Allow New Registrations
</label>
<br>
<input <input
type="checkbox" type="checkbox"
name="registration_enabled" name="registration_enabled"
class="toggle toggle-primary shrink-0 mt-1" class="toggle toggle-primary shrink-0"
checked={registrationEnabled} checked={registrationEnabled}
/> />
</div> </fieldset>
<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>

View File

@@ -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");
}; };

View File

@@ -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');
}; };

View File

@@ -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)

View File

@@ -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",
}); });

View File

@@ -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,
}); });
} }

View File

@@ -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,13 +14,19 @@ export const GET: APIRoute = async ({ request, locals }) => {
}); });
} }
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const userPasskeys = await db.query.passkeys.findMany({ 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) => ({

View File

@@ -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");
}; };

View File

@@ -52,6 +52,17 @@ export const POST: APIRoute = async ({ 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 delete clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can delete clients", { status: 403 });
}
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run(); await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
await db.delete(clients).where(eq(clients.id, id)).run(); await db.delete(clients).where(eq(clients.id, id)).run();

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
import { db } from "../../../../db"; import { db } from "../../../../db";
import { clients, members } from "../../../../db/schema"; import { clients, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => { export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -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({

View File

@@ -3,6 +3,7 @@ import { db } from "../../../db";
import { clients, members } from "../../../db/schema"; import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => { export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user; const user = locals.user;
@@ -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({

View File

@@ -45,6 +45,11 @@ 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 {
const lastInvoice = await db const lastInvoice = await db
.select() .select()

View File

@@ -69,7 +69,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
invoice: { invoice: {
...invoice, ...invoice,
notes: invoice.notes || null, notes: invoice.notes || null,
// Ensure null safety for optional fields that might be undefined in some runtimes depending on driver
discountValue: invoice.discountValue ?? null, discountValue: invoice.discountValue ?? null,
discountType: invoice.discountType ?? null, discountType: invoice.discountType ?? null,
discountAmount: invoice.discountAmount ?? null, discountAmount: invoice.discountAmount ?? null,
@@ -107,7 +106,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response(buffer, { return new Response(buffer, {
headers: { headers: {
"Content-Type": "application/pdf", "Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`, "Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.pdf"`,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -44,7 +44,6 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const startDate = new Date(startDateStr); const startDate = new Date(startDateStr);
const endDate = new Date(endDateStr); const endDate = new Date(endDateStr);
// Set end date to end of day
endDate.setHours(23, 59, 59, 999); endDate.setHours(23, 59, 59, 999);
const invoice = await db const invoice = await db
@@ -174,7 +173,6 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
for (const day of days.values()) { for (const day of days.values()) {
const hours = day.totalDuration / (1000 * 60 * 60); const hours = day.totalDuration / (1000 * 60 * 60);
// Avoid division by zero
const unitPrice = hours > 0 ? Math.round(day.totalAmount / hours) : 0; const unitPrice = hours > 0 ? Math.round(day.totalAmount / hours) : 0;
newItems.push({ newItems.push({
@@ -193,7 +191,6 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const duration = entry.endTime.getTime() - entry.startTime.getTime(); const duration = entry.endTime.getTime() - entry.startTime.getTime();
const hours = duration / (1000 * 60 * 60); const hours = duration / (1000 * 60 * 60);
// Determine rate: max of tags, or 0
const rate = rates.length > 0 ? Math.max(...rates) : 0; const rate = rates.length > 0 ? Math.max(...rates) : 0;
const amount = Math.round(hours * rate); const amount = Math.round(hours * rate);
@@ -222,51 +219,52 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return redirect(`/dashboard/invoices/${id}?error=no-entries`); return redirect(`/dashboard/invoices/${id}?error=no-entries`);
} }
// Transaction-like operations
try { try {
await db.insert(invoiceItems).values(newItems); await db.transaction(async (tx) => {
await tx.insert(invoiceItems).values(newItems);
if (entryIdsToUpdate.length > 0) { if (entryIdsToUpdate.length > 0) {
await db await tx
.update(timeEntries) .update(timeEntries)
.set({ invoiceId: invoice.id }) .set({ invoiceId: invoice.id })
.where(inArray(timeEntries.id, entryIdsToUpdate)); .where(inArray(timeEntries.id, entryIdsToUpdate));
}
const allItems = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id));
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
let discountAmount = 0;
if (invoice.discountType === "percentage") {
discountAmount = Math.round(
subtotal * ((invoice.discountValue || 0) / 100),
);
} else {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
if (invoice.discountValue && invoice.discountValue > 0) {
discountAmount = Math.round((invoice.discountValue || 0) * 100);
} }
}
const taxableAmount = Math.max(0, subtotal - discountAmount); const allItems = await tx
const taxAmount = Math.round( .select()
taxableAmount * ((invoice.taxRate || 0) / 100), .from(invoiceItems)
); .where(eq(invoiceItems.invoiceId, invoice.id));
const total = subtotal - discountAmount + taxAmount;
await db const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
.update(invoices)
.set({ let discountAmount = 0;
subtotal, if (invoice.discountType === "percentage") {
discountAmount, discountAmount = Math.round(
taxAmount, subtotal * ((invoice.discountValue || 0) / 100),
total, );
}) } else {
.where(eq(invoices.id, invoice.id)); 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`); return redirect(`/dashboard/invoices/${id}?success=imported`);
} catch (error) { } catch (error) {

View File

@@ -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,6 +62,11 @@ 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);

View File

@@ -60,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)

View File

@@ -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);

View File

@@ -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));

View File

@@ -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;
@@ -29,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
@@ -67,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

View File

@@ -128,6 +128,13 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
"Tag", "Tag",
"Description", "Description",
]; ];
const sanitizeCell = (value: string): string => {
if (/^[=+\-@\t\r]/.test(value)) {
return `\t${value}`;
}
return value;
};
const rows = entries.map((e) => { 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;
@@ -144,10 +151,10 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
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, '""'))}"`,
`"${tagsStr.replace(/"/g, '""')}"`, `"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
`"${(e.entry.description || "").replace(/"/g, '""')}"`, `"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
].join(","); ].join(",");
}); });

View File

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

View File

@@ -6,6 +6,7 @@ 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 }) => {
@@ -27,6 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
}); });
} }
if (description && description.length > MAX_LENGTHS.description) {
return new Response(
JSON.stringify({ error: `Description must be ${MAX_LENGTHS.description} characters or fewer` }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (!startTime) { if (!startTime) {
return new Response(JSON.stringify({ error: "Start time is required" }), { return new Response(JSON.stringify({ error: "Start time is required" }), {
status: 400, status: 400,

View File

@@ -3,7 +3,7 @@ import { db } from "../../../db";
import { timeEntries, members } 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 });
@@ -17,6 +17,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
return new Response("Client is required", { status: 400 }); return new Response("Client is required", { status: 400 });
} }
if (description && description.length > MAX_LENGTHS.description) {
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
}
const runningEntry = await db const runningEntry = await db
.select() .select()
.from(timeEntries) .from(timeEntries)

View File

@@ -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 });
} }

View File

@@ -1,26 +1,15 @@
--- ---
import DashboardLayout from '../../layouts/DashboardLayout.astro'; import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db'; import { db } from '../../db';
import { clients, members } from '../../db/schema'; import { clients } from '../../db/schema';
import { eq, and } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const organizationId = userMembership.organizationId; const organizationId = userMembership.organizationId;
@@ -32,20 +21,20 @@ const allClients = await db.select()
<DashboardLayout title="Clients - Chronus"> <DashboardLayout title="Clients - Chronus">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">Clients</h1> <h1 class="text-2xl font-extrabold tracking-tight">Clients</h1>
<a href="/dashboard/clients/new" class="btn btn-primary">Add Client</a> <a href="/dashboard/clients/new" class="btn btn-primary btn-sm">Add Client</a>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{allClients.map(client => ( {allClients.map(client => (
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4 gap-1">
<h2 class="card-title">{client.name}</h2> <h2 class="font-semibold">{client.name}</h2>
{client.email && <p class="text-sm text-gray-500">{client.email}</p>} {client.email && <p class="text-sm text-base-content/60">{client.email}</p>}
<p class="text-xs text-gray-400">Created {client.createdAt?.toLocaleDateString() ?? 'N/A'}</p> <p class="text-xs text-base-content/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>

View File

@@ -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>
Client Name
</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>
Email (optional)
</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>
Phone (optional)
</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>
Street Address (optional)
</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>
City (optional)
</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>
State / Province (optional)
</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>
Zip / Postal Code (optional)
</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>
Country (optional)
</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>

View File

@@ -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, tags, 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)
@@ -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,68 +108,65 @@ 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>
@@ -189,20 +178,20 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
</thead> </thead>
<tbody> <tbody>
{recentEntries.map(({ entry, tag, user: entryUser }) => ( {recentEntries.map(({ entry, tag, user: entryUser }) => (
<tr> <tr class="hover">
<td>{entry.description || '-'}</td> <td>{entry.description || '-'}</td>
<td> <td>
{tag ? ( {tag ? (
<div class="badge badge-sm badge-outline flex items-center gap-1"> <div class="badge badge-xs badge-outline flex items-center gap-1">
{tag.color && ( {tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${tag.color}`}></span> <ColorDot client:load color={tag.color} class="w-2 h-2 rounded-full" />
)} )}
<span>{tag.name}</span> <span>{tag.name}</span>
</div> </div>
) : '-'} ) : '-'}
</td> </td>
<td>{entryUser?.name || 'Unknown'}</td> <td class="text-base-content/60">{entryUser?.name || 'Unknown'}</td>
<td>{entry.startTime.toLocaleDateString()}</td> <td class="text-base-content/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>
))} ))}
@@ -210,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>

View File

@@ -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>
Client Name
</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>
Email (optional)
</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>
Phone (optional)
</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>
Street Address (optional)
</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>
City (optional)
</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>
State / Province (optional)
</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>
Zip / Postal Code (optional)
</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>
Country (optional)
</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>

View File

@@ -1,6 +1,8 @@
--- ---
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, tags } 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';
@@ -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,31 +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, tag }) => ( {stats.recentEntries.map(({ entry, client, tag }) => (
<li class="p-3 rounded-lg bg-base-200 border-l-4 hover:bg-base-300 transition-colors" style={`border-color: ${tag?.color || '#3b82f6'}`}> <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 flex flex-wrap gap-2 items-center"> <div class="text-xs text-base-content/60 mt-0.5 flex flex-wrap gap-2 items-center">
<span class="flex gap-1 flex-wrap"> <span class="flex gap-1 flex-wrap">
{tag ? ( {tag ? (
<span class="badge badge-xs badge-outline">{tag.name}</span> <span class="badge badge-xs badge-outline">{tag.name}</span>
) : <span class="italic opacity-50">No tag</span>} ) : <span class="italic opacity-50">No tag</span>}
</span> </span>
<span> {entry.endTime ? formatDuration(entry.endTime.getTime() - entry.startTime.getTime()) : 'Running...'}</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>
)} )}

View File

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

View File

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

View File

@@ -1,34 +1,28 @@
--- ---
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 AutoSubmit from '../../../components/AutoSubmit.vue';
import ConfirmForm from '../../../components/ConfirmForm.vue';
import { db } from '../../../db'; import { db } from '../../../db';
import { invoices, clients, members } from '../../../db/schema'; import { invoices, clients } from '../../../db/schema';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm'; import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
import { formatCurrency } from '../../../lib/formatTime';
const user = Astro.locals.user; 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 currentTeamIdResolved = userMembership.organizationId; const currentTeamIdResolved = userMembership.organizationId;
// Get filter parameters // Get filter parameters
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const yearParam = Astro.url.searchParams.get('year'); const yearParam = Astro.url.searchParams.get('year');
const selectedYear = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam); const selectedYear: string | number = yearParam === 'current' || !yearParam ? 'current' : parseInt(yearParam);
const yearNum = typeof selectedYear === 'number' ? selectedYear : currentYear;
const selectedType = Astro.url.searchParams.get('type') || 'all'; const selectedType = Astro.url.searchParams.get('type') || 'all';
const selectedStatus = Astro.url.searchParams.get('status') || 'all'; const selectedStatus = Astro.url.searchParams.get('status') || 'all';
const sortBy = Astro.url.searchParams.get('sort') || 'date-desc'; const sortBy = Astro.url.searchParams.get('sort') || 'date-desc';
@@ -52,8 +46,8 @@ if (!availableYears.includes(currentYear)) {
} }
// Filter by year // Filter by year
const yearStart = selectedYear === 'current' ? new Date(currentYear, 0, 1) : new Date(selectedYear, 0, 1); const yearStart = new Date(yearNum, 0, 1);
const yearEnd = selectedYear === 'current' ? new Date() : new Date(selectedYear, 11, 31, 23, 59, 59); const yearEnd = selectedYear === 'current' ? new Date() : new Date(yearNum, 11, 31, 23, 59, 59);
let filteredInvoices = allInvoicesRaw.filter(i => { let filteredInvoices = allInvoicesRaw.filter(i => {
const issueDate = i.invoice.issueDate; const issueDate = i.invoice.issueDate;
@@ -96,13 +90,6 @@ const yearInvoices = allInvoicesRaw.filter(i => {
return issueDate >= yearStart && issueDate <= yearEnd; return issueDate >= yearStart && issueDate <= yearEnd;
}); });
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'paid': return 'badge-success'; case 'paid': return 'badge-success';
@@ -119,115 +106,102 @@ const getStatusColor = (status: string) => {
<DashboardLayout title="Invoices & Quotes - Chronus"> <DashboardLayout title="Invoices & Quotes - Chronus">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6"> <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div> <div>
<h1 class="text-3xl font-bold">Invoices & Quotes</h1> <h1 class="text-2xl font-extrabold tracking-tight">Invoices & Quotes</h1>
<p class="text-base-content/60 mt-1">Manage your billing and estimates</p> <p class="text-base-content/60 text-sm mt-1">Manage your billing and estimates</p>
</div> </div>
<a href="/dashboard/invoices/new" class="btn btn-primary"> <a href="/dashboard/invoices/new" class="btn btn-primary btn-sm">
<Icon name="heroicons:plus" class="w-5 h-5" /> <Icon name="plus" class="w-4 h-4" />
Create New Create New
</a> </a>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
<div class="stats shadow bg-base-100 border border-base-200"> <StatCard
<div class="stat"> title="Total Invoices"
<div class="stat-figure text-primary"> value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
<Icon name="heroicons:document-text" class="w-8 h-8" /> description={selectedYear === 'current' ? `${currentYear} (YTD)` : String(selectedYear)}
</div> icon="document-text"
<div class="stat-title">Total Invoices</div> color="text-primary"
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div> />
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div> <StatCard
</div> title="Open Quotes"
</div> value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
description="Waiting for approval"
<div class="stats shadow bg-base-100 border border-base-200"> icon="clipboard-document-list"
<div class="stat"> color="text-secondary"
<div class="stat-figure text-secondary"> />
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" /> <StatCard
</div> title="Total Revenue"
<div class="stat-title">Open Quotes</div> value={formatCurrency(yearInvoices
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div> .filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
<div class="stat-desc">Waiting for approval</div> .reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
</div> description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
</div> icon="currency-dollar"
color="text-success"
<div class="stats shadow bg-base-100 border border-base-200"> />
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
</div>
<div class="stat-title">Total Revenue</div>
<div class="stat-value text-success">
{formatCurrency(yearInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
</div>
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
</div>
</div>
</div> </div>
<!-- Filters --> <!-- Filters -->
<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">
<form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <form method="GET" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Year</legend>
<span class="label-text font-medium">Year</span> <AutoSubmit client:load>
</label> <select name="year" class="select w-full">
<select name="year" class="select select-bordered w-full" onchange="this.form.submit()"> <option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option>
<option value="current" selected={selectedYear === 'current'}>Current Year to Date ({currentYear})</option> {availableYears.map(year => (
{availableYears.map(year => ( <option value={year} selected={year === selectedYear}>{year}</option>
<option value={year} selected={year === selectedYear}>{year}</option> ))}
))} </select>
</select> </AutoSubmit>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Type</legend>
<span class="label-text font-medium">Type</span> <AutoSubmit client:load>
</label> <select name="type" class="select w-full">
<select name="type" class="select select-bordered w-full" onchange="this.form.submit()"> <option value="all" selected={selectedType === 'all'}>All Types</option>
<option value="all" selected={selectedType === 'all'}>All Types</option> <option value="invoice" selected={selectedType === 'invoice'}>Invoices</option>
<option value="invoice" selected={selectedType === 'invoice'}>Invoices</option> <option value="quote" selected={selectedType === 'quote'}>Quotes</option>
<option value="quote" selected={selectedType === 'quote'}>Quotes</option> </select>
</select> </AutoSubmit>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Status</legend>
<span class="label-text font-medium">Status</span> <AutoSubmit client:load>
</label> <select name="status" class="select w-full">
<select name="status" class="select select-bordered w-full" onchange="this.form.submit()"> <option value="all" selected={selectedStatus === 'all'}>All Statuses</option>
<option value="all" selected={selectedStatus === 'all'}>All Statuses</option> <option value="draft" selected={selectedStatus === 'draft'}>Draft</option>
<option value="draft" selected={selectedStatus === 'draft'}>Draft</option> <option value="sent" selected={selectedStatus === 'sent'}>Sent</option>
<option value="sent" selected={selectedStatus === 'sent'}>Sent</option> <option value="paid" selected={selectedStatus === 'paid'}>Paid</option>
<option value="paid" selected={selectedStatus === 'paid'}>Paid</option> <option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option>
<option value="accepted" selected={selectedStatus === 'accepted'}>Accepted</option> <option value="declined" selected={selectedStatus === 'declined'}>Declined</option>
<option value="declined" selected={selectedStatus === 'declined'}>Declined</option> <option value="void" selected={selectedStatus === 'void'}>Void</option>
<option value="void" selected={selectedStatus === 'void'}>Void</option> </select>
</select> </AutoSubmit>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label"> <legend class="fieldset-legend text-xs">Sort By</legend>
<span class="label-text font-medium">Sort By</span> <AutoSubmit client:load>
</label> <select name="sort" class="select w-full">
<select name="sort" class="select select-bordered w-full" onchange="this.form.submit()"> <option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option>
<option value="date-desc" selected={sortBy === 'date-desc'}>Date (Newest First)</option> <option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option>
<option value="date-asc" selected={sortBy === 'date-asc'}>Date (Oldest First)</option> <option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option>
<option value="amount-desc" selected={sortBy === 'amount-desc'}>Amount (High to Low)</option> <option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option>
<option value="amount-asc" selected={sortBy === 'amount-asc'}>Amount (Low to High)</option> <option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option>
<option value="number-desc" selected={sortBy === 'number-desc'}>Number (Z-A)</option> <option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option>
<option value="number-asc" selected={sortBy === 'number-asc'}>Number (A-Z)</option> </select>
</select> </AutoSubmit>
</div> </fieldset>
</form> </form>
{(selectedYear !== 'current' || selectedType !== 'all' || selectedStatus !== 'all' || sortBy !== 'date-desc') && ( {(selectedYear !== 'current' || selectedType !== 'all' || selectedStatus !== 'all' || sortBy !== 'date-desc') && (
<div class="mt-4"> <div class="mt-3">
<a href="/dashboard/invoices" class="btn btn-ghost btn-sm"> <a href="/dashboard/invoices" class="btn btn-ghost btn-xs">
<Icon name="heroicons:x-mark" class="w-4 h-4" /> <Icon name="x-mark" class="w-3 h-3" />
Clear Filters Clear Filters
</a> </a>
</div> </div>
@@ -235,19 +209,19 @@ const getStatusColor = (status: string) => {
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="px-6 py-4 border-b border-base-200 bg-base-200/30"> <div class="px-4 py-3 border-b border-base-content/20">
<p class="text-sm text-base-content/70"> <p class="text-xs text-base-content/60">
Showing <span class="font-semibold text-base-content">{allInvoices.length}</span> Showing <span class="font-semibold text-base-content">{allInvoices.length}</span>
{allInvoices.length === 1 ? 'result' : 'results'} {allInvoices.length === 1 ? 'result' : 'results'}
{selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`} {selectedYear === 'current' ? ` for ${currentYear} (year to date)` : ` for ${selectedYear}`}
</p> </p>
</div> </div>
<div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0"> <div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0">
<table class="table table-zebra"> <table class="table table-sm">
<thead> <thead>
<tr class="bg-base-200/50"> <tr>
<th>Number</th> <th>Number</th>
<th>Client</th> <th>Client</th>
<th>Date</th> <th>Date</th>
@@ -261,14 +235,14 @@ const getStatusColor = (status: string) => {
<tbody> <tbody>
{allInvoices.length === 0 ? ( {allInvoices.length === 0 ? (
<tr> <tr>
<td colspan="8" class="text-center py-8 text-base-content/60"> <td colspan="8" class="text-center py-8 text-base-content/60 text-sm">
No invoices or quotes found. Create one to get started. No invoices or quotes found. Create one to get started.
</td> </td>
</tr> </tr>
) : ( ) : (
allInvoices.map(({ invoice, client }) => ( allInvoices.map(({ invoice, client }) => (
<tr class="hover:bg-base-200/50 transition-colors"> <tr class="hover">
<td class="font-mono font-medium"> <td class="font-mono font-medium text-sm">
<a href={`/dashboard/invoices/${invoice.id}`} class="link link-hover text-primary"> <a href={`/dashboard/invoices/${invoice.id}`} class="link link-hover text-primary">
{invoice.number} {invoice.number}
</a> </a>
@@ -277,7 +251,7 @@ const getStatusColor = (status: string) => {
{client ? ( {client ? (
<div class="font-medium">{client.name}</div> <div class="font-medium">{client.name}</div>
) : ( ) : (
<span class="text-base-content/40 italic">Deleted Client</span> <span class="text-base-content/60 italic">Deleted Client</span>
)} )}
</td> </td>
<td>{invoice.issueDate.toLocaleDateString()}</td> <td>{invoice.issueDate.toLocaleDateString()}</td>
@@ -286,7 +260,7 @@ const getStatusColor = (status: string) => {
{formatCurrency(invoice.total, invoice.currency)} {formatCurrency(invoice.total, invoice.currency)}
</td> </td>
<td> <td>
<div class={`badge ${getStatusColor(invoice.status)} badge-sm uppercase font-bold tracking-wider`}> <div class={`badge ${getStatusColor(invoice.status)} badge-xs uppercase font-bold tracking-wider`}>
{invoice.status} {invoice.status}
</div> </div>
</td> </td>
@@ -295,25 +269,25 @@ const getStatusColor = (status: string) => {
</td> </td>
<td class="text-right"> <td class="text-right">
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square"> <div role="button" tabindex="0" class="btn btn-ghost btn-xs btn-square">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" /> <Icon name="ellipsis-vertical" class="w-4 h-4" />
</div> </div>
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-100"> <ul tabindex="0" class="dropdown-content menu p-2 bg-base-100 rounded-box w-52 border border-base-content/20 z-100">
<li> <li>
<a href={`/dashboard/invoices/${invoice.id}`}> <a href={`/dashboard/invoices/${invoice.id}`}>
<Icon name="heroicons:eye" class="w-4 h-4" /> <Icon name="eye" class="w-4 h-4" />
View Details View Details
</a> </a>
</li> </li>
<li> <li>
<a href={`/dashboard/invoices/${invoice.id}/edit`}> <a href={`/dashboard/invoices/${invoice.id}/edit`}>
<Icon name="heroicons:pencil-square" class="w-4 h-4" /> <Icon name="pencil-square" class="w-4 h-4" />
Edit Edit
</a> </a>
</li> </li>
<li> <li>
<a href={`/api/invoices/${invoice.id}/generate`} download> <a href={`/api/invoices/${invoice.id}/generate`} download>
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" /> <Icon name="arrow-down-tray" class="w-4 h-4" />
Download PDF Download PDF
</a> </a>
</li> </li>
@@ -322,7 +296,7 @@ const getStatusColor = (status: string) => {
<form method="POST" action={`/api/invoices/${invoice.id}/status`} class="w-full"> <form method="POST" action={`/api/invoices/${invoice.id}/status`} class="w-full">
<input type="hidden" name="status" value="sent" /> <input type="hidden" name="status" value="sent" />
<button type="submit" class="w-full justify-start"> <button type="submit" class="w-full justify-start">
<Icon name="heroicons:paper-airplane" class="w-4 h-4" /> <Icon name="paper-airplane" class="w-4 h-4" />
Mark as Sent Mark as Sent
</button> </button>
</form> </form>
@@ -330,13 +304,13 @@ const getStatusColor = (status: string) => {
)} )}
<div class="divider my-1"></div> <div class="divider my-1"></div>
<li> <li>
<form method="POST" action={`/api/invoices/delete`} onsubmit="return confirm('Are you sure? This action cannot be undone.');" class="w-full"> <ConfirmForm client:load message="Are you sure? This action cannot be undone." action="/api/invoices/delete" class="w-full">
<input type="hidden" name="id" value={invoice.id} /> <input type="hidden" name="id" value={invoice.id} />
<button type="submit" class="w-full justify-start text-error hover:bg-error/10"> <button type="submit" class="w-full justify-start text-error hover:bg-base-300">
<Icon name="heroicons:trash" class="w-4 h-4" /> <Icon name="trash" class="w-4 h-4" />
Delete Delete
</button> </button>
</form> </ConfirmForm>
</li> </li>
</ul> </ul>
</div> </div>

View File

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

View File

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

View File

@@ -1,31 +1,23 @@
--- ---
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 TagChart from '../../components/TagChart.vue'; import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue'; import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue'; import MemberChart from '../../components/MemberChart.vue';
import AutoSubmit from '../../components/AutoSubmit.vue';
import ColorDot from '../../components/ColorDot.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema'; import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm'; import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime'; import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const teamMembers = await db.select({ const teamMembers = await db.select({
id: users.id, id: users.id,
@@ -247,13 +239,6 @@ const revenueByClient = allClients.map(client => {
}; };
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue); }).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
function formatCurrency(amount: number, currency: string = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
}
function getTimeRangeLabel(range: string) { function getTimeRangeLabel(range: string) {
switch (range) { switch (range) {
case 'today': return 'Today'; case 'today': return 'Today';
@@ -269,170 +254,139 @@ function getTimeRangeLabel(range: string) {
--- ---
<DashboardLayout title="Reports - Chronus"> <DashboardLayout title="Reports - Chronus">
<h1 class="text-3xl font-bold mb-6">Team Reports</h1> <h1 class="text-2xl font-extrabold tracking-tight mb-6">Team Reports</h1>
<!-- Filters --> <!-- Filters -->
<div class="card bg-base-200 shadow-xl border border-base-300 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="reports-range"> <legend class="fieldset-legend text-xs">Time Range</legend>
Time Range <AutoSubmit client:load>
</label> <select id="reports-range" name="range" class="select w-full">
<select id="reports-range" name="range" class="select select-bordered" onchange="this.form.submit()"> <option value="today" selected={timeRange === 'today'}>Today</option>
<option value="today" selected={timeRange === 'today'}>Today</option> <option value="week" selected={timeRange === 'week'}>Last 7 Days</option>
<option value="week" selected={timeRange === 'week'}>Last 7 Days</option> <option value="month" selected={timeRange === 'month'}>Last 30 Days</option>
<option value="month" selected={timeRange === 'month'}>Last 30 Days</option> <option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option>
<option value="mtd" selected={timeRange === 'mtd'}>Month to Date</option> <option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option>
<option value="ytd" selected={timeRange === 'ytd'}>Year to Date</option> <option value="last-month" selected={timeRange === 'last-month'}>Last Month</option>
<option value="last-month" selected={timeRange === 'last-month'}>Last Month</option> <option value="custom" selected={timeRange === 'custom'}>Custom Range</option>
<option value="custom" selected={timeRange === 'custom'}>Custom Range</option> </select>
</select> </AutoSubmit>
</div> </fieldset>
{timeRange === 'custom' && ( {timeRange === 'custom' && (
<> <>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="reports-from"> <legend class="fieldset-legend text-xs">From Date</legend>
From Date <AutoSubmit client:load>
</label> <input
<input type="date"
type="date" id="reports-from"
id="reports-from" name="from"
name="from" class="input w-full"
class="input input-bordered w-full" value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))}
value={customFrom || (startDate.getFullYear() + '-' + String(startDate.getMonth() + 1).padStart(2, '0') + '-' + String(startDate.getDate()).padStart(2, '0'))} />
onchange="this.form.submit()" </AutoSubmit>
/> </fieldset>
</div> <fieldset class="fieldset">
<div class="form-control"> <legend class="fieldset-legend text-xs">To Date</legend>
<label class="label font-medium" for="reports-to"> <AutoSubmit client:load>
To Date <input
</label> type="date"
<input id="reports-to"
type="date" name="to"
id="reports-to" class="input w-full"
name="to" value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))}
class="input input-bordered w-full" />
value={customTo || (endDate.getFullYear() + '-' + String(endDate.getMonth() + 1).padStart(2, '0') + '-' + String(endDate.getDate()).padStart(2, '0'))} </AutoSubmit>
onchange="this.form.submit()" </fieldset>
/>
</div>
</> </>
)} )}
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="reports-member"> <legend class="fieldset-legend text-xs">Team Member</legend>
Team Member <AutoSubmit client:load>
</label> <select id="reports-member" name="member" class="select w-full">
<select id="reports-member" name="member" class="select select-bordered" onchange="this.form.submit()"> <option value="">All Members</option>
<option value="">All Members</option> {teamMembers.map(member => (
{teamMembers.map(member => ( <option value={member.id} selected={selectedMemberId === member.id}>
<option value={member.id} selected={selectedMemberId === member.id}> {member.name}
{member.name} </option>
</option> ))}
))} </select>
</select> </AutoSubmit>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="reports-tag"> <legend class="fieldset-legend text-xs">Tag</legend>
Tag <AutoSubmit client:load>
</label> <select id="reports-tag" name="tag" class="select w-full">
<select id="reports-tag" name="tag" class="select select-bordered" onchange="this.form.submit()"> <option value="">All Tags</option>
<option value="">All Tags</option> {allTags.map(tag => (
{allTags.map(tag => ( <option value={tag.id} selected={selectedTagId === tag.id}>
<option value={tag.id} selected={selectedTagId === tag.id}> {tag.name}
{tag.name} </option>
</option> ))}
))} </select>
</select> </AutoSubmit>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="reports-client"> <legend class="fieldset-legend text-xs">Client</legend>
Client <AutoSubmit client:load>
</label> <select id="reports-client" name="client" class="select w-full">
<select id="reports-client" name="client" class="select select-bordered" onchange="this.form.submit()"> <option value="">All Clients</option>
<option value="">All Clients</option> {allClients.map(client => (
{allClients.map(client => ( <option value={client.id} selected={selectedClientId === client.id}>
<option value={client.id} selected={selectedClientId === client.id}> {client.name}
{client.name} </option>
</option> ))}
))} </select>
</select> </AutoSubmit>
</div> </fieldset>
</form> </form>
<style>
@media (max-width: 767px) {
form {
align-items: stretch !important;
}
.form-control {
width: 100%;
}
}
select, input {
width: 100%;
}
</style>
</div> </div>
</div> </div>
<!-- Summary Stats --> <!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<div class="stats shadow border border-base-300"> <StatCard
<div class="stat"> title="Total Time"
<div class="stat-figure text-primary"> value={formatDuration(totalTime)}
<Icon name="heroicons:clock" class="w-8 h-8" /> description={getTimeRangeLabel(timeRange)}
</div> icon="clock"
<div class="stat-title">Total Time</div> color="text-primary"
<div class="stat-value text-primary">{formatDuration(totalTime)}</div> />
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div> <StatCard
</div> title="Total Entries"
</div> value={String(entries.length)}
description={getTimeRangeLabel(timeRange)}
<div class="stats shadow border border-base-300"> icon="list-bullet"
<div class="stat"> color="text-secondary"
<div class="stat-figure text-secondary"> />
<Icon name="heroicons:list-bullet" class="w-8 h-8" /> <StatCard
</div> title="Revenue"
<div class="stat-title">Total Entries</div> value={formatCurrency(revenueStats.total)}
<div class="stat-value text-secondary">{entries.length}</div> description={`${invoiceStats.paid} paid invoices`}
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div> icon="currency-dollar"
</div> color="text-success"
</div> />
<StatCard
<div class="stats shadow border border-base-300"> title="Active Members"
<div class="stat"> value={String(statsByMember.filter(s => s.entryCount > 0).length)}
<div class="stat-figure text-success"> description={`of ${teamMembers.length} total`}
<Icon name="heroicons:currency-dollar" class="w-8 h-8" /> icon="user-group"
</div> color="text-accent"
<div class="stat-title">Revenue</div> />
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
</div>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:user-group" class="w-8 h-8" />
</div>
<div class="stat-title">Active Members</div>
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
<div class="stat-desc">of {teamMembers.length} total</div>
</div>
</div>
</div> </div>
<!-- Invoice & Quote Stats --> <!-- Invoice & Quote Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:document-text" class="w-6 h-6" /> <Icon name="document-text" class="w-4 h-4" />
Invoices Overview Invoices Overview
</h2> </h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@@ -440,11 +394,11 @@ function getTimeRangeLabel(range: string) {
<div class="stat-title text-xs">Total Invoices</div> <div class="stat-title text-xs">Total Invoices</div>
<div class="stat-value text-2xl">{invoiceStats.total}</div> <div class="stat-value text-2xl">{invoiceStats.total}</div>
</div> </div>
<div class="stat bg-success/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Paid</div> <div class="stat-title text-xs">Paid</div>
<div class="stat-value text-2xl text-success">{invoiceStats.paid}</div> <div class="stat-value text-2xl text-success">{invoiceStats.paid}</div>
</div> </div>
<div class="stat bg-info/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Sent</div> <div class="stat-title text-xs">Sent</div>
<div class="stat-value text-2xl text-info">{invoiceStats.sent}</div> <div class="stat-value text-2xl text-info">{invoiceStats.sent}</div>
</div> </div>
@@ -465,10 +419,10 @@ function getTimeRangeLabel(range: string) {
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:clipboard-document-list" class="w-6 h-6" /> <Icon name="clipboard-document-list" class="w-4 h-4" />
Quotes Overview Quotes Overview
</h2> </h2>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@@ -476,15 +430,15 @@ function getTimeRangeLabel(range: string) {
<div class="stat-title text-xs">Total Quotes</div> <div class="stat-title text-xs">Total Quotes</div>
<div class="stat-value text-2xl">{quoteStats.total}</div> <div class="stat-value text-2xl">{quoteStats.total}</div>
</div> </div>
<div class="stat bg-success/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Accepted</div> <div class="stat-title text-xs">Accepted</div>
<div class="stat-value text-2xl text-success">{quoteStats.accepted}</div> <div class="stat-value text-2xl text-success">{quoteStats.accepted}</div>
</div> </div>
<div class="stat bg-info/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Pending</div> <div class="stat-title text-xs">Pending</div>
<div class="stat-value text-2xl text-info">{quoteStats.sent}</div> <div class="stat-value text-2xl text-info">{quoteStats.sent}</div>
</div> </div>
<div class="stat bg-error/10 rounded-lg"> <div class="stat bg-base-200 rounded-lg">
<div class="stat-title text-xs">Declined</div> <div class="stat-title text-xs">Declined</div>
<div class="stat-value text-2xl text-error">{quoteStats.declined}</div> <div class="stat-value text-2xl text-error">{quoteStats.declined}</div>
</div> </div>
@@ -506,14 +460,14 @@ function getTimeRangeLabel(range: string) {
<!-- Revenue by Client - Only show if there's revenue data and no client filter --> <!-- Revenue by Client - Only show if there's revenue data and no client filter -->
{!selectedClientId && revenueByClient.length > 0 && ( {!selectedClientId && revenueByClient.length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:banknotes" class="w-6 h-6" /> <Icon name="banknotes" class="w-4 h-4" />
Revenue by Client Revenue by Client
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Client</th> <th>Client</th>
@@ -526,11 +480,11 @@ function getTimeRangeLabel(range: string) {
{revenueByClient.slice(0, 10).map(stat => ( {revenueByClient.slice(0, 10).map(stat => (
<tr> <tr>
<td> <td>
<div class="font-bold">{stat.client.name}</div> <div class="font-medium">{stat.client.name}</div>
</td> </td>
<td class="font-mono font-bold text-success">{formatCurrency(stat.revenue)}</td> <td class="font-mono font-semibold text-success text-sm">{formatCurrency(stat.revenue)}</td>
<td>{stat.invoiceCount}</td> <td>{stat.invoiceCount}</td>
<td class="font-mono"> <td class="font-mono text-sm">
{stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)} {stat.invoiceCount > 0 ? formatCurrency(stat.revenue / stat.invoiceCount) : formatCurrency(0)}
</td> </td>
</tr> </tr>
@@ -545,13 +499,13 @@ function getTimeRangeLabel(range: string) {
{/* Charts Section - Only show if there's data */} {/* Charts Section - Only show if there's data */}
{totalTime > 0 && ( {totalTime > 0 && (
<> <>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
{/* Tag Distribution Chart - Only show when no tag filter */} {/* Tag Distribution Chart - Only show when no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && ( {!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:chart-pie" class="w-6 h-6" /> <Icon name="chart-pie" class="w-4 h-4" />
Tag Distribution Tag Distribution
</h2> </h2>
<div class="h-64 w-full"> <div class="h-64 w-full">
@@ -570,10 +524,10 @@ function getTimeRangeLabel(range: string) {
{/* Client Distribution Chart - Only show when no client filter */} {/* Client Distribution Chart - Only show when no client filter */}
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && ( {!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:chart-bar" class="w-6 h-6" /> <Icon name="chart-bar" class="w-4 h-4" />
Time by Client Time by Client
</h2> </h2>
<div class="h-64 w-full"> <div class="h-64 w-full">
@@ -592,10 +546,10 @@ function getTimeRangeLabel(range: string) {
{/* Team Member Chart - Only show when no member filter */} {/* Team Member Chart - Only show when no member filter */}
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && ( {!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:users" class="w-6 h-6" /> <Icon name="users" class="w-4 h-4" />
Time by Team Member Time by Team Member
</h2> </h2>
<div class="h-64 w-full"> <div class="h-64 w-full">
@@ -615,14 +569,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Member - Only show if there's data and no member filter */} {/* Stats by Member - Only show if there's data and no member filter */}
{!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && ( {!selectedMemberId && statsByMember.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:users" class="w-6 h-6" /> <Icon name="users" class="w-4 h-4" />
By Team Member By Team Member
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Member</th> <th>Member</th>
@@ -636,13 +590,13 @@ function getTimeRangeLabel(range: string) {
<tr> <tr>
<td> <td>
<div> <div>
<div class="font-bold">{stat.member.name}</div> <div class="font-medium">{stat.member.name}</div>
<div class="text-sm opacity-50">{stat.member.email}</div> <div class="text-xs text-base-content/60">{stat.member.email}</div>
</div> </div>
</td> </td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td> <td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td> <td>{stat.entryCount}</td>
<td class="font-mono"> <td class="font-mono text-sm">
{stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'} {stat.entryCount > 0 ? formatDuration(stat.totalTime / stat.entryCount) : '00:00:00 (0m)'}
</td> </td>
</tr> </tr>
@@ -656,14 +610,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Tag - Only show if there's data and no tag filter */} {/* Stats by Tag - Only show if there's data and no tag filter */}
{!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && ( {!selectedTagId && statsByTag.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:tag" class="w-6 h-6" /> <Icon name="tag" class="w-4 h-4" />
By Tag By Tag
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Tag</th> <th>Tag</th>
@@ -678,21 +632,21 @@ function getTimeRangeLabel(range: string) {
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{stat.tag.color && ( {stat.tag.color && (
<span class="w-4 h-4 rounded-full" style={`background-color: ${stat.tag.color}`}></span> <ColorDot client:load color={stat.tag.color} class="w-3 h-3 rounded-full" />
)} )}
<span>{stat.tag.name}</span> <span>{stat.tag.name}</span>
</div> </div>
</td> </td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td> <td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td> <td>{stat.entryCount}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<progress <progress
class="progress progress-primary w-20" class="progress progress-primary w-16"
value={stat.totalTime} value={stat.totalTime}
max={totalTime} max={totalTime}
></progress> ></progress>
<span class="text-sm"> <span class="text-xs">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}% {totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span> </span>
</div> </div>
@@ -708,14 +662,14 @@ function getTimeRangeLabel(range: string) {
{/* Stats by Client - Only show if there's data and no client filter */} {/* Stats by Client - Only show if there's data and no client filter */}
{!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && ( {!selectedClientId && statsByClient.filter(s => s.totalTime > 0).length > 0 && (
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-3">
<Icon name="heroicons:building-office" class="w-6 h-6" /> <Icon name="building-office" class="w-4 h-4" />
By Client By Client
</h2> </h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Client</th> <th>Client</th>
@@ -728,16 +682,16 @@ function getTimeRangeLabel(range: string) {
{statsByClient.filter(s => s.totalTime > 0).map(stat => ( {statsByClient.filter(s => s.totalTime > 0).map(stat => (
<tr> <tr>
<td>{stat.client.name}</td> <td>{stat.client.name}</td>
<td class="font-mono">{formatDuration(stat.totalTime)}</td> <td class="font-mono text-sm">{formatDuration(stat.totalTime)}</td>
<td>{stat.entryCount}</td> <td>{stat.entryCount}</td>
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<progress <progress
class="progress progress-secondary w-20" class="progress progress-secondary w-16"
value={stat.totalTime} value={stat.totalTime}
max={totalTime} max={totalTime}
></progress> ></progress>
<span class="text-sm"> <span class="text-xs">
{totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}% {totalTime > 0 ? Math.round((stat.totalTime / totalTime) * 100) : 0}%
</span> </span>
</div> </div>
@@ -752,23 +706,23 @@ function getTimeRangeLabel(range: string) {
)} )}
{/* Detailed Entries */} {/* Detailed Entries */}
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-3">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:document-text" class="w-6 h-6" /> <Icon name="document-text" class="w-4 h-4" />
Detailed Entries ({entries.length}) Detailed Entries ({entries.length})
</h2> </h2>
{entries.length > 0 && ( {entries.length > 0 && (
<a href={`/api/reports/export${url.search}`} class="btn btn-sm btn-outline" target="_blank"> <a href={`/api/reports/export${url.search}`} class="btn btn-xs btn-ghost" target="_blank">
<Icon name="heroicons:arrow-down-tray" class="w-4 h-4" /> <Icon name="arrow-down-tray" class="w-3.5 h-3.5" />
Export CSV Export CSV
</a> </a>
)} )}
</div> </div>
{entries.length > 0 ? ( {entries.length > 0 ? (
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
@@ -784,7 +738,7 @@ function getTimeRangeLabel(range: string) {
<tr> <tr>
<td class="whitespace-nowrap"> <td class="whitespace-nowrap">
{e.entry.startTime.toLocaleDateString()}<br/> {e.entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50"> <span class="text-xs text-base-content/60">
{e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {e.entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</td> </td>
@@ -792,18 +746,18 @@ function getTimeRangeLabel(range: string) {
<td>{e.client.name}</td> <td>{e.client.name}</td>
<td> <td>
{e.tag ? ( {e.tag ? (
<div class="badge badge-sm badge-outline flex items-center gap-1"> <div class="badge badge-xs badge-outline flex items-center gap-1">
{e.tag.color && ( {e.tag.color && (
<span class="w-2 h-2 rounded-full" style={`background-color: ${e.tag.color}`}></span> <ColorDot client:load color={e.tag.color} class="w-2 h-2 rounded-full" />
)} )}
<span>{e.tag.name}</span> <span>{e.tag.name}</span>
</div> </div>
) : ( ) : (
<span class="opacity-50">-</span> <span class="text-base-content/60">-</span>
)} )}
</td> </td>
<td>{e.entry.description || '-'}</td> <td class="text-base-content/60">{e.entry.description || '-'}</td>
<td class="font-mono"> <td class="font-mono text-sm">
{e.entry.endTime {e.entry.endTime
? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime()) ? formatDuration(e.entry.endTime.getTime() - e.entry.startTime.getTime())
: 'Running...' : 'Running...'
@@ -815,12 +769,12 @@ function getTimeRangeLabel(range: string) {
</table> </table>
</div> </div>
) : ( ) : (
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex flex-col items-center justify-center py-10 text-center">
<Icon name="heroicons:inbox" class="w-16 h-16 text-base-content/20 mb-4" /> <Icon name="inbox" class="w-12 h-12 text-base-content/30 mb-3" />
<h3 class="text-lg font-semibold mb-2">No time entries found</h3> <h3 class="text-base font-semibold mb-1">No time entries found</h3>
<p class="text-base-content/60 mb-4">Try adjusting your filters or select a different time range.</p> <p class="text-base-content/60 text-sm mb-4">Try adjusting your filters or select a different time range.</p>
<a href="/dashboard/tracker" class="btn btn-primary"> <a href="/dashboard/tracker" class="btn btn-primary btn-sm">
<Icon name="heroicons:play" class="w-5 h-5" /> <Icon name="play" class="w-4 h-4" />
Start Tracking Time Start Tracking Time
</a> </a>
</div> </div>

View File

@@ -1,6 +1,6 @@
--- ---
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 { db } from '../../db'; import { db } from '../../db';
import { apiTokens, passkeys } from '../../db/schema'; import { apiTokens, passkeys } from '../../db/schema';
import { eq, desc } from 'drizzle-orm'; import { eq, desc } from 'drizzle-orm';
@@ -30,30 +30,30 @@ const userPasskeys = await db.select()
<DashboardLayout title="Account Settings - Chronus"> <DashboardLayout title="Account Settings - Chronus">
<div class="max-w-4xl mx-auto px-4 sm:px-6"> <div class="max-w-4xl mx-auto px-4 sm:px-6">
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-primary"> <h1 class="text-2xl font-extrabold tracking-tight mb-6 sm:mb-8">
Account Settings Account Settings
</h1> </h1>
{/* Success Messages */} {/* Success Messages */}
{successType === 'profile' && ( {successType === 'profile' && (
<div class="alert alert-success mb-6"> <div class="alert alert-success mb-6">
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 shrink-0" /> <Icon name="check-circle" class="w-5 h-5 sm:w-6 sm:h-6 shrink-0" />
<span class="text-sm sm:text-base">Profile updated successfully!</span> <span class="text-sm sm:text-base">Profile updated successfully!</span>
</div> </div>
)} )}
{successType === 'password' && ( {successType === 'password' && (
<div class="alert alert-success mb-6"> <div class="alert alert-success mb-6">
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 shrink-0" /> <Icon name="check-circle" class="w-5 h-5 sm:w-6 sm:h-6 shrink-0" />
<span class="text-sm sm:text-base">Password changed successfully!</span> <span class="text-sm sm:text-base">Password changed successfully!</span>
</div> </div>
)} )}
<!-- Profile Information --> <!-- Profile Information -->
<ProfileForm client:load user={user} /> <ProfileForm client:idle user={user} />
<!-- Change Password --> <!-- Change Password -->
<PasswordForm client:load /> <PasswordForm client:idle />
<!-- Passkeys --> <!-- Passkeys -->
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({ <PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
@@ -69,25 +69,25 @@ const userPasskeys = await db.select()
createdAt: t.createdAt ? t.createdAt.toISOString() : '' createdAt: t.createdAt ? t.createdAt.toISOString() : ''
}))} /> }))} />
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card card-border bg-base-100">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4">
<h2 class="card-title mb-6 text-lg sm:text-xl"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
<Icon name="heroicons:information-circle" class="w-5 h-5 sm:w-6 sm:h-6" /> <Icon name="information-circle" class="w-4 h-4" />
Account Information Account Information
</h2> </h2>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0"> <div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-content/20 gap-2 sm:gap-0">
<span class="text-base-content/70 text-sm sm:text-base">Account ID</span> <span class="text-base-content/60 text-sm">Account ID</span>
<span class="font-mono text-xs sm:text-sm break-all">{user.id}</span> <span class="font-mono text-xs break-all">{user.id}</span>
</div> </div>
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0"> <div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-content/20 gap-2 sm:gap-0">
<span class="text-base-content/70 text-sm sm:text-base">Email</span> <span class="text-base-content/60 text-sm">Email</span>
<span class="text-sm sm:text-base break-all">{user.email}</span> <span class="text-sm break-all">{user.email}</span>
</div> </div>
<div class="flex flex-col sm:flex-row sm:justify-between py-3 gap-2 sm:gap-0"> <div class="flex flex-col sm:flex-row sm:justify-between py-3 gap-2 sm:gap-0">
<span class="text-base-content/70 text-sm sm:text-base">Site Administrator</span> <span class="text-base-content/60 text-sm">Site Administrator</span>
<span class={user.isSiteAdmin ? "badge badge-primary" : "badge badge-ghost"}> <span class={user.isSiteAdmin ? "badge badge-xs badge-primary" : "badge badge-xs badge-ghost"}>
{user.isSiteAdmin ? "Yes" : "No"} {user.isSiteAdmin ? "Yes" : "No"}
</span> </span>
</div> </div>

View File

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

View File

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

View File

@@ -1,27 +1,19 @@
--- ---
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 ConfirmForm from '../../../components/ConfirmForm.vue';
import ColorDot from '../../../components/ColorDot.vue';
import { db } from '../../../db'; import { db } from '../../../db';
import { members, organizations, tags } from '../../../db/schema'; import { organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin'; const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team'); if (!isAdmin) return Astro.redirect('/dashboard/team');
@@ -48,42 +40,40 @@ const successType = url.searchParams.get('success');
<DashboardLayout title="Team Settings - Chronus"> <DashboardLayout title="Team Settings - Chronus">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<a href="/dashboard/team" class="btn btn-ghost btn-sm"> <a href="/dashboard/team" class="btn btn-ghost btn-xs">
<Icon name="heroicons:arrow-left" class="w-5 h-5" /> <Icon name="arrow-left" class="w-4 h-4" />
</a> </a>
<h1 class="text-3xl font-bold">Team Settings</h1> <h1 class="text-2xl font-extrabold tracking-tight">Team Settings</h1>
</div> </div>
<!-- Team Settings --> <!-- Team Settings -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<h2 class="card-title mb-4"> <h2 class="text-sm font-semibold flex items-center gap-2 mb-4">
<Icon name="heroicons:building-office-2" class="w-6 h-6" /> <Icon name="building-office-2" class="w-4 h-4" />
Team Settings Team Settings
</h2> </h2>
{successType === 'org-name' && ( {successType === 'org-name' && (
<div class="alert alert-success mb-4"> <div class="alert alert-success mb-4">
<Icon name="heroicons:check-circle" class="w-6 h-6" /> <Icon name="check-circle" class="w-4 h-4" />
<span>Team information updated successfully!</span> <span class="text-sm">Team information updated successfully!</span>
</div> </div>
)} )}
<form <form
action="/api/organizations/update-name" action="/api/organizations/update-name"
method="POST" method="POST"
class="space-y-4" class="space-y-3"
enctype="multipart/form-data" enctype="multipart/form-data"
> >
<input type="hidden" name="organizationId" value={organization.id} /> <input type="hidden" name="organizationId" value={organization.id} />
<div class="form-control"> <fieldset class="fieldset">
<div class="label"> <legend class="fieldset-legend text-xs">Team Logo</legend>
<span class="label-text font-medium">Team Logo</span> <div class="flex items-center gap-4">
</div>
<div class="flex items-center gap-6">
<div class="avatar placeholder"> <div class="avatar placeholder">
<div class="bg-base-200 text-neutral-content rounded-xl w-24 border border-base-300 flex items-center justify-center overflow-hidden"> <div class="bg-base-200 text-neutral-content rounded-xl w-20 border border-base-content/20 flex items-center justify-center overflow-hidden">
{organization.logoUrl ? ( {organization.logoUrl ? (
<img <img
src={organization.logoUrl} src={organization.logoUrl}
@@ -92,8 +82,8 @@ const successType = url.searchParams.get('success');
/> />
) : ( ) : (
<Icon <Icon
name="heroicons:photo" name="photo"
class="w-8 h-8 opacity-40 text-base-content" class="w-6 h-6 opacity-70 text-base-content"
/> />
)} )}
</div> </div>
@@ -103,118 +93,100 @@ const successType = url.searchParams.get('success');
type="file" type="file"
name="logo" name="logo"
accept="image/png, image/jpeg" accept="image/png, image/jpeg"
class="file-input file-input-bordered w-full max-w-xs" class="file-input file-input-bordered file-input-sm w-full max-w-xs"
/> />
<div class="text-xs text-base-content/60 mt-2"> <div class="text-xs text-base-content/60 mt-1">
Upload a company logo (PNG, JPG). Upload a company logo (PNG, JPG). Will be displayed on invoices and quotes.
<br />
Will be displayed on invoices and quotes.
</div> </div>
</div> </div>
</div> </div>
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="team-name"> <legend class="fieldset-legend text-xs">Team Name</legend>
Team Name
</label>
<input <input
type="text" type="text"
id="team-name" id="team-name"
name="name" name="name"
value={organization.name} value={organization.name}
placeholder="Organization name" placeholder="Organization name"
class="input input-bordered w-full" class="input w-full"
required required
/> />
<div class="label"> <p class="text-xs text-base-content/60 mt-1">This name is visible to all team members</p>
<span class="label-text-alt text-base-content/60">This name is visible to all team members</span> </fieldset>
</div>
</div>
<div class="divider">Address Information</div> <div class="divider text-xs text-base-content/60 my-2">Address Information</div>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="team-street"> <legend class="fieldset-legend text-xs">Street Address</legend>
Street Address
</label>
<input <input
type="text" type="text"
id="team-street" id="team-street"
name="street" name="street"
value={organization.street || ''} value={organization.street || ''}
placeholder="123 Main Street" placeholder="123 Main Street"
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 font-medium" for="team-city"> <legend class="fieldset-legend text-xs">City</legend>
City
</label>
<input <input
type="text" type="text"
id="team-city" id="team-city"
name="city" name="city"
value={organization.city || ''} value={organization.city || ''}
placeholder="City" placeholder="City"
class="input input-bordered w-full" class="input w-full"
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="team-state"> <legend class="fieldset-legend text-xs">State/Province</legend>
State/Province
</label>
<input <input
type="text" type="text"
id="team-state" id="team-state"
name="state" name="state"
value={organization.state || ''} value={organization.state || ''}
placeholder="State/Province" placeholder="State/Province"
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 font-medium" for="team-zip"> <legend class="fieldset-legend text-xs">Postal Code</legend>
Postal Code
</label>
<input <input
type="text" type="text"
id="team-zip" id="team-zip"
name="zip" name="zip"
value={organization.zip || ''} value={organization.zip || ''}
placeholder="12345" placeholder="12345"
class="input input-bordered w-full" class="input w-full"
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="team-country"> <legend class="fieldset-legend text-xs">Country</legend>
Country
</label>
<input <input
type="text" type="text"
id="team-country" id="team-country"
name="country" name="country"
value={organization.country || ''} value={organization.country || ''}
placeholder="Country" placeholder="Country"
class="input input-bordered w-full" class="input w-full"
/> />
</div> </fieldset>
</div> </div>
<div class="divider">Defaults</div> <div class="divider text-xs text-base-content/60 my-2">Defaults</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 font-medium" for="default-tax-rate"> <legend class="fieldset-legend text-xs">Default Tax Rate (%)</legend>
Default Tax Rate (%)
</label>
<input <input
type="number" type="number"
id="default-tax-rate" id="default-tax-rate"
@@ -223,18 +195,16 @@ const successType = url.searchParams.get('success');
min="0" min="0"
max="100" max="100"
value={organization.defaultTaxRate || 0} value={organization.defaultTaxRate || 0}
class="input input-bordered w-full" class="input w-full"
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="default-currency"> <legend class="fieldset-legend text-xs">Default Currency</legend>
Default Currency
</label>
<select <select
id="default-currency" id="default-currency"
name="defaultCurrency" name="defaultCurrency"
class="select select-bordered w-full" class="select w-full"
> >
<option value="USD" selected={!organization.defaultCurrency || organization.defaultCurrency === 'USD'}>USD ($)</option> <option value="USD" selected={!organization.defaultCurrency || organization.defaultCurrency === 'USD'}>USD ($)</option>
<option value="EUR" selected={organization.defaultCurrency === 'EUR'}>EUR (€)</option> <option value="EUR" selected={organization.defaultCurrency === 'EUR'}>EUR (€)</option>
@@ -242,16 +212,16 @@ const successType = url.searchParams.get('success');
<option value="CAD" selected={organization.defaultCurrency === 'CAD'}>CAD ($)</option> <option value="CAD" selected={organization.defaultCurrency === 'CAD'}>CAD ($)</option>
<option value="AUD" selected={organization.defaultCurrency === 'AUD'}>AUD ($)</option> <option value="AUD" selected={organization.defaultCurrency === 'AUD'}>AUD ($)</option>
</select> </select>
</div> </fieldset>
</div> </div>
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mt-6"> <div class="flex flex-col sm:flex-row justify-between items-center gap-3 mt-4">
<span class="text-xs text-base-content/60 text-center sm:text-left"> <span class="text-xs text-base-content/60 text-center sm:text-left">
Address information appears on invoices and quotes Address information appears on invoices and quotes
</span> </span>
<button type="submit" class="btn btn-primary w-full sm:w-auto"> <button type="submit" class="btn btn-primary btn-sm w-full sm:w-auto">
<Icon name="heroicons:check" class="w-5 h-5" /> <Icon name="check" class="w-4 h-4" />
Save Changes Save Changes
</button> </button>
</div> </div>
@@ -260,35 +230,34 @@ const successType = url.searchParams.get('success');
</div> </div>
<!-- Tags Section --> <!-- Tags Section -->
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:tag" class="w-6 h-6" /> <Icon name="tag" class="w-4 h-4" />
Tags & Rates Tags & Rates
</h2> </h2>
{/* We'll use a simple form submission for now or client-side JS for better UX later */} <ModalButton client:load modalId="new_tag_modal" class="btn btn-primary btn-xs">
<button onclick="document.getElementById('new_tag_modal').showModal()" class="btn btn-primary btn-sm"> <Icon name="plus" class="w-3 h-3" />
<Icon name="heroicons:plus" class="w-5 h-5" />
Add Tag Add Tag
</button> </ModalButton>
</div> </div>
<p class="text-base-content/70 mb-4"> <p class="text-base-content/60 text-xs mb-4">
Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes. Tags can be used to categorize time entries. You can also associate an hourly rate with a tag for billing purposes.
</p> </p>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<div class="alert alert-info"> <div class="alert alert-info">
<Icon name="heroicons:information-circle" class="w-6 h-6" /> <Icon name="information-circle" class="w-4 h-4" />
<div> <div>
<div class="font-bold">No tags yet</div> <div class="font-semibold text-sm">No tags yet</div>
<div class="text-sm">Create tags to add context and rates to your time entries.</div> <div class="text-xs">Create tags to add context and rates to your time entries.</div>
</div> </div>
</div> </div>
) : ( ) : (
<div class="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>
@@ -298,66 +267,59 @@ const successType = url.searchParams.get('success');
</thead> </thead>
<tbody> <tbody>
{allTags.map(tag => ( {allTags.map(tag => (
<tr> <tr class="hover">
<td> <td>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{tag.color && ( {tag.color && (
<div class="w-3 h-3 rounded-full" style={`background-color: ${tag.color}`}></div> <ColorDot client:load color={tag.color} class="w-3 h-3 rounded-full" />
)} )}
<span class="font-medium">{tag.name}</span> <span class="font-medium">{tag.name}</span>
</div> </div>
</td> </td>
<td> <td>
{tag.rate ? ( {tag.rate ? (
<span class="font-mono">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span> <span class="font-mono text-sm">{new Intl.NumberFormat('en-US', { style: 'currency', currency: organization.defaultCurrency || 'USD' }).format(tag.rate / 100)}</span>
) : ( ) : (
<span class="text-base-content/40 text-xs italic">No rate</span> <span class="text-base-content/60 text-xs italic">No rate</span>
)} )}
</td> </td>
<td> <td>
<div class="flex gap-2"> <div class="flex gap-1">
<button <ModalButton
onclick={`document.getElementById('edit_tag_modal_${tag.id}').showModal()`} client:load
modalId={`edit_tag_modal_${tag.id}`}
class="btn btn-ghost btn-xs btn-square" class="btn btn-ghost btn-xs btn-square"
> >
<Icon name="heroicons:pencil" class="w-4 h-4" /> <Icon name="pencil" class="w-3 h-3" />
</button> </ModalButton>
<form method="POST" action={`/api/tags/${tag.id}/delete`} onsubmit="return confirm('Are you sure you want to delete this tag?');"> <ConfirmForm client:load message="Are you sure you want to delete this tag?" action={`/api/tags/${tag.id}/delete`}>
<button class="btn btn-ghost btn-xs btn-square text-error"> <button class="btn btn-ghost btn-xs btn-square text-error">
<Icon name="heroicons:trash" class="w-4 h-4" /> <Icon name="trash" class="w-3 h-3" />
</button> </button>
</form> </ConfirmForm>
</div> </div>
{/* Edit Modal */} {/* Edit Modal */}
<dialog id={`edit_tag_modal_${tag.id}`} class="modal"> <dialog id={`edit_tag_modal_${tag.id}`} class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg">Edit Tag</h3> <h3 class="font-semibold text-base">Edit Tag</h3>
<form method="POST" action={`/api/tags/${tag.id}/update`}> <form method="POST" action={`/api/tags/${tag.id}/update`}>
<div class="form-control w-full mb-4"> <fieldset class="fieldset mb-3">
<label class="label"> <legend class="fieldset-legend text-xs">Name</legend>
<span class="label-text">Name</span> <input type="text" name="name" value={tag.name} class="input w-full" required />
</label> </fieldset>
<input type="text" name="name" value={tag.name} class="input input-bordered w-full" required /> <fieldset class="fieldset mb-3">
</div> <legend class="fieldset-legend text-xs">Color</legend>
<div class="form-control w-full mb-4"> <input type="color" name="color" value={tag.color || '#3b82f6'} class="input w-full h-12 p-1" />
<label class="label"> </fieldset>
<span class="label-text">Color</span> <fieldset class="fieldset mb-4">
</label> <legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
<input type="color" name="color" value={tag.color || '#3b82f6'} class="input input-bordered w-full h-12 p-1" /> <input type="number" name="rate" value={tag.rate || 0} min="0" class="input w-full" />
</div> <p class="text-xs text-base-content/60 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
<div class="form-control w-full mb-6"> </fieldset>
<label class="label">
<span class="label-text">Hourly Rate (cents)</span>
</label>
<input type="number" name="rate" value={tag.rate || 0} min="0" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
</label>
</div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onclick={`document.getElementById('edit_tag_modal_${tag.id}').close()`}>Cancel</button> <ModalButton client:load modalId={`edit_tag_modal_${tag.id}`} action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary btn-sm">Save</button>
</div> </div>
</form> </form>
</div> </div>
@@ -377,33 +339,25 @@ const successType = url.searchParams.get('success');
<dialog id="new_tag_modal" class="modal"> <dialog id="new_tag_modal" class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg">New Tag</h3> <h3 class="font-semibold text-base">New Tag</h3>
<form method="POST" action="/api/tags/create"> <form method="POST" action="/api/tags/create">
<input type="hidden" name="organizationId" value={organization.id} /> <input type="hidden" name="organizationId" value={organization.id} />
<div class="form-control w-full mb-4"> <fieldset class="fieldset mb-3">
<label class="label"> <legend class="fieldset-legend text-xs">Name</legend>
<span class="label-text">Name</span> <input type="text" name="name" class="input w-full" required placeholder="e.g. Billable, Rush" />
</label> </fieldset>
<input type="text" name="name" class="input input-bordered w-full" required placeholder="e.g. Billable, Rush" /> <fieldset class="fieldset mb-3">
</div> <legend class="fieldset-legend text-xs">Color</legend>
<div class="form-control w-full mb-4"> <input type="color" name="color" value="#3b82f6" class="input w-full h-12 p-1" />
<label class="label"> </fieldset>
<span class="label-text">Color</span> <fieldset class="fieldset mb-4">
</label> <legend class="fieldset-legend text-xs">Hourly Rate (cents)</legend>
<input type="color" name="color" value="#3b82f6" class="input input-bordered w-full h-12 p-1" /> <input type="number" name="rate" value="0" min="0" class="input w-full" />
</div> <p class="text-xs text-base-content/60 mt-1">Enter rate in cents (e.g. 5000 = $50.00)</p>
<div class="form-control w-full mb-6"> </fieldset>
<label class="label">
<span class="label-text">Hourly Rate (cents)</span>
</label>
<input type="number" name="rate" value="0" min="0" class="input input-bordered w-full" />
<label class="label">
<span class="label-text-alt text-base-content/60">Enter rate in cents (e.g. 5000 = $50.00)</span>
</label>
</div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('new_tag_modal').close()">Cancel</button> <ModalButton client:load modalId="new_tag_modal" action="close" class="btn btn-sm">Cancel</ModalButton>
<button type="submit" class="btn btn-primary">Create Tag</button> <button type="submit" class="btn btn-primary btn-sm">Create Tag</button>
</div> </div>
</form> </form>
</div> </div>
@@ -413,5 +367,4 @@ const successType = url.searchParams.get('success');
</dialog> </dialog>
</DashboardLayout> </DashboardLayout>

View File

@@ -1,30 +1,21 @@
--- ---
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 Timer from '../../components/Timer.vue'; import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue'; import ManualEntry from '../../components/ManualEntry.vue';
import AutoSubmit from '../../components/AutoSubmit.vue';
import ConfirmForm from '../../components/ConfirmForm.vue';
import { db } from '../../db'; import { db } from '../../db';
import { timeEntries, clients, members, tags, users } from '../../db/schema'; import { timeEntries, clients, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm'; import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime'; import { formatTimeRange } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user; const user = Astro.locals.user;
if (!user) return Astro.redirect('/login'); if (!user) return Astro.redirect('/login');
// Get current team from cookie const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
const currentTeamId = Astro.cookies.get('currentTeamId')?.value; if (!userMembership) return Astro.redirect('/dashboard');
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const organizationId = userMembership.organizationId; const organizationId = userMembership.organizationId;
@@ -150,15 +141,15 @@ const paginationPages = getPaginationPages(page, totalPages);
--- ---
<DashboardLayout title="Time Tracker - Chronus"> <DashboardLayout title="Time Tracker - Chronus">
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1> <h1 class="text-2xl font-extrabold tracking-tight mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry --> <!-- Tabs for Timer and Manual Entry -->
<div role="tablist" class="tabs tabs-lifted mb-6"> <div class="tabs tabs-border mb-6">
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked /> <input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6"> <div class="tab-content border-base-content/20 p-6">
{allClients.length === 0 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> <Icon name="exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span> <span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a> <a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div> </div>
@@ -177,11 +168,11 @@ const paginationPages = getPaginationPages(page, totalPages);
)} )}
</div> </div>
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" /> <input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6"> <div class="tab-content border-base-content/20 p-6">
{allClients.length === 0 ? ( {allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4"> <div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> <Icon name="exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span> <span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a> <a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div> </div>
@@ -200,77 +191,73 @@ const paginationPages = getPaginationPages(page, totalPages);
) : null} ) : null}
<!-- Filters and Search --> <!-- Filters and Search -->
<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 mb-6"> <div class="card card-border bg-base-100 mb-6">
<div class="card-body"> <div class="card-body p-4">
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4"> <form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3">
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="tracker-search"> <legend class="fieldset-legend text-xs">Search</legend>
Search
</label>
<input <input
type="text" type="text"
id="tracker-search" id="tracker-search"
name="search" name="search"
placeholder="Search descriptions..." placeholder="Search descriptions..."
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" class="input w-full"
value={searchTerm} value={searchTerm}
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="tracker-client"> <legend class="fieldset-legend text-xs">Client</legend>
Client <AutoSubmit client:load>
</label> <select id="tracker-client" name="client" class="select w-full">
<select id="tracker-client" name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()"> <option value="">All Clients</option>
<option value="">All Clients</option> {allClients.map(client => (
{allClients.map(client => ( <option value={client.id} selected={filterClient === client.id}>
<option value={client.id} selected={filterClient === client.id}> {client.name}
{client.name} </option>
</option> ))}
))} </select>
</select> </AutoSubmit>
</div> </fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Status</legend>
<AutoSubmit client:load>
<select id="tracker-status" name="status" class="select w-full">
<option value="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
</select>
</AutoSubmit>
</fieldset>
<fieldset class="fieldset">
<legend class="fieldset-legend text-xs">Entry Type</legend>
<AutoSubmit client:load>
<select id="tracker-type" name="type" class="select w-full">
<option value="" selected={filterType === ''}>All Types</option>
<option value="timed" selected={filterType === 'timed'}>Timed</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
</AutoSubmit>
</fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="tracker-status"> <legend class="fieldset-legend text-xs">Sort By</legend>
Status <AutoSubmit client:load>
</label> <select id="tracker-sort" name="sort" class="select w-full">
<select id="tracker-status" name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()"> <option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="" selected={filterStatus === ''}>All Entries</option> <option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option> <option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
<option value="running" selected={filterStatus === 'running'}>Running</option> <option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select> </select>
</div> </AutoSubmit>
</fieldset>
<div class="form-control">
<label class="label font-medium" for="tracker-type">
Entry Type
</label>
<select id="tracker-type" name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
<option value="" selected={filterType === ''}>All Types</option>
<option value="timed" selected={filterType === 'timed'}>Timed</option>
<option value="manual" selected={filterType === 'manual'}>Manual</option>
</select>
</div>
<div class="form-control">
<label class="label font-medium" for="tracker-sort">
Sort By
</label>
<select id="tracker-sort" name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
<option value="duration-asc" selected={sortBy === 'duration-asc'}>Shortest Duration</option>
</select>
</div>
<input type="hidden" name="page" value="1" /> <input type="hidden" name="page" value="1" />
<div class="form-control md:col-span-2 lg:col-span-6"> <div class="flex items-end md:col-span-2 lg:col-span-1">
<button type="submit" class="btn btn-primary shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"> <button type="submit" class="btn btn-primary btn-sm w-full">
<Icon name="heroicons:magnifying-glass" class="w-5 h-5" /> <Icon name="magnifying-glass" class="w-4 h-4" />
Search Search
</button> </button>
</div> </div>
@@ -278,24 +265,24 @@ const paginationPages = getPaginationPages(page, totalPages);
</div> </div>
</div> </div>
<div class="card bg-base-200/30 backdrop-blur-sm shadow-lg border border-base-300/50 hover:border-base-300 transition-all duration-200"> <div class="card card-border bg-base-100">
<div class="card-body"> <div class="card-body p-4">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-3">
<h2 class="card-title"> <h2 class="text-sm font-semibold flex items-center gap-2">
<Icon name="heroicons:list-bullet" class="w-6 h-6" /> <Icon name="list-bullet" class="w-4 h-4" />
Time Entries ({totalCount?.count || 0} total) Time Entries ({totalCount?.count || 0} total)
</h2> </h2>
{(filterClient || filterStatus || filterType || searchTerm) && ( {(filterClient || filterStatus || filterType || searchTerm) && (
<a href="/dashboard/tracker" class="btn btn-sm btn-ghost hover:bg-base-300/50 transition-colors"> <a href="/dashboard/tracker" class="btn btn-xs btn-ghost">
<Icon name="heroicons:x-mark" class="w-4 h-4" /> <Icon name="x-mark" class="w-3 h-3" />
Clear Filters Clear Filters
</a> </a>
)} )}
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra"> <table class="table table-sm">
<thead> <thead>
<tr class="bg-base-300/30"> <tr>
<th>Type</th> <th>Type</th>
<th>Client</th> <th>Client</th>
<th>Description</th> <th>Description</th>
@@ -308,26 +295,26 @@ const paginationPages = getPaginationPages(page, totalPages);
</thead> </thead>
<tbody> <tbody>
{entries.map(({ entry, client, user: entryUser }) => ( {entries.map(({ entry, client, user: entryUser }) => (
<tr class="hover:bg-base-300/20 transition-colors"> <tr class="hover">
<td> <td>
{entry.isManual ? ( {entry.isManual ? (
<span class="badge badge-info badge-sm gap-1 shadow-sm" title="Manual Entry"> <span class="badge badge-info badge-xs gap-1" title="Manual Entry">
<Icon name="heroicons:pencil" class="w-3 h-3" /> <Icon name="pencil" class="w-3 h-3" />
Manual Manual
</span> </span>
) : ( ) : (
<span class="badge badge-success badge-sm gap-1 shadow-sm" title="Timed Entry"> <span class="badge badge-success badge-xs gap-1" title="Timed Entry">
<Icon name="heroicons:clock" class="w-3 h-3" /> <Icon name="clock" class="w-3 h-3" />
Timed Timed
</span> </span>
)} )}
</td> </td>
<td class="font-medium">{client?.name || 'Unknown'}</td> <td class="font-medium">{client?.name || 'Unknown'}</td>
<td class="text-base-content/80">{entry.description || '-'}</td> <td class="text-base-content/60">{entry.description || '-'}</td>
<td>{entryUser?.name || 'Unknown'}</td> <td>{entryUser?.name || 'Unknown'}</td>
<td class="whitespace-nowrap"> <td class="whitespace-nowrap">
{entry.startTime.toLocaleDateString()}<br/> {entry.startTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50"> <span class="text-xs text-base-content/60">
{entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {entry.startTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</td> </td>
@@ -335,25 +322,24 @@ const paginationPages = getPaginationPages(page, totalPages);
{entry.endTime ? ( {entry.endTime ? (
<> <>
{entry.endTime.toLocaleDateString()}<br/> {entry.endTime.toLocaleDateString()}<br/>
<span class="text-xs opacity-50"> <span class="text-xs text-base-content/60">
{entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} {entry.endTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span> </span>
</> </>
) : ( ) : (
<span class="badge badge-success shadow-sm">Running</span> <span class="badge badge-success badge-xs">Running</span>
)} )}
</td> </td>
<td class="font-mono font-semibold text-primary">{formatTimeRange(entry.startTime, entry.endTime)}</td> <td class="font-mono font-semibold text-primary text-sm">{formatTimeRange(entry.startTime, entry.endTime)}</td>
<td> <td>
<form method="POST" action={`/api/time-entries/${entry.id}/delete`} class="inline"> <ConfirmForm client:load message="Are you sure you want to delete this entry?" action={`/api/time-entries/${entry.id}/delete`} class="inline">
<button <button
type="submit" type="submit"
class="btn btn-ghost btn-sm text-error hover:bg-error/10 transition-colors" class="btn btn-ghost btn-xs text-error"
onclick="return confirm('Are you sure you want to delete this entry?')"
> >
<Icon name="heroicons:trash" class="w-4 h-4" /> <Icon name="trash" class="w-3.5 h-3.5" />
</button> </button>
</form> </ConfirmForm>
</td> </td>
</tr> </tr>
))} ))}
@@ -363,20 +349,20 @@ const paginationPages = getPaginationPages(page, totalPages);
<!-- Pagination --> <!-- Pagination -->
{totalPages > 1 && ( {totalPages > 1 && (
<div class="flex justify-center items-center gap-2 mt-6"> <div class="flex justify-center items-center gap-1 mt-4">
<a <a
href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`} href={`?page=${Math.max(1, page - 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === 1 ? 'btn-disabled' : 'hover:bg-base-300/50'}`} class={`btn btn-xs ${page === 1 ? 'btn-disabled' : ''}`}
> >
<Icon name="heroicons:chevron-left" class="w-4 h-4" /> <Icon name="chevron-left" class="w-3 h-3" />
Previous Prev
</a> </a>
<div class="flex gap-1"> <div class="flex gap-0.5">
{paginationPages.map(pageNum => ( {paginationPages.map(pageNum => (
<a <a
href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`} href={`?page=${pageNum}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === pageNum ? 'btn-active' : 'hover:bg-base-300/50'}`} class={`btn btn-xs ${page === pageNum ? 'btn-active' : ''}`}
> >
{pageNum} {pageNum}
</a> </a>
@@ -385,10 +371,10 @@ const paginationPages = getPaginationPages(page, totalPages);
<a <a
href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`} href={`?page=${Math.min(totalPages, page + 1)}${filterClient ? `&client=${filterClient}` : ''}${filterStatus ? `&status=${filterStatus}` : ''}${filterType ? `&type=${filterType}` : ''}${sortBy ? `&sort=${sortBy}` : ''}${searchTerm ? `&search=${searchTerm}` : ''}`}
class={`btn btn-sm transition-all ${page === totalPages ? 'btn-disabled' : 'hover:bg-base-300/50'}`} class={`btn btn-xs ${page === totalPages ? 'btn-disabled' : ''}`}
> >
Next Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" /> <Icon name="chevron-right" class="w-3 h-3" />
</a> </a>
</div> </div>
)} )}

View File

@@ -1,5 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import Icon from '../components/Icon.astro';
if (Astro.locals.user) { if (Astro.locals.user) {
return Astro.redirect('/dashboard'); return Astro.redirect('/dashboard');
@@ -7,48 +8,56 @@ if (Astro.locals.user) {
--- ---
<Layout title="Chronus - Time Tracking"> <Layout title="Chronus - Time Tracking">
<div class="hero flex-1 bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12"> <div class="flex-1 flex flex-col">
<div class="hero-content text-center"> <!-- Hero -->
<div class="max-w-4xl"> <div class="flex-1 flex items-center justify-center px-4 py-16 sm:py-24 bg-base-100">
<img src="/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" /> <div class="max-w-3xl text-center">
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary"> <img src="/logo.webp" alt="Chronus Logo" class="h-20 w-20 mx-auto mb-8" />
Chronus <h1 class="text-5xl sm:text-6xl lg:text-7xl font-extrabold tracking-tight text-base-content mb-4">
Track time,<br />
<span class="text-primary">effortlessly.</span>
</h1> </h1>
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto"> <p class="text-lg sm:text-xl text-base-content/60 max-w-xl mx-auto mb-10 leading-relaxed">
Modern time tracking designed for teams that value simplicity and precision. Modern time tracking designed for teams that value simplicity and precision.
</p> </p>
<div class="flex gap-4 justify-center mt-8 flex-wrap"> <div class="flex gap-3 justify-center flex-wrap">
<a href="/signup" class="btn btn-primary btn-lg"> <a href="/signup" class="btn btn-primary btn-lg px-8">
Get Started Get Started
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <Icon name="arrow-right" class="h-5 w-5" />
<path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</a> </a>
<a href="/login" class="btn btn-outline btn-lg">Login</a> <a href="/login" class="btn btn-ghost btn-lg px-8">Login</a>
</div> </div>
</div>
</div>
<!-- Feature highlights --> <!-- Features -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-16"> <div class="bg-base-200 border-t border-base-content/20 px-4 py-16 sm:py-20">
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> <div class="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card-body items-start"> <div class="card bg-base-100 card-border">
<div class="text-4xl mb-3">⚡</div> <div class="card-body">
<h3 class="card-title text-lg">Lightning Fast</h3> <div class="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center mb-2">
<p class="text-sm text-base-content/70">Track tasks with a single click.</p> <Icon name="bolt" class="h-5 w-5 text-primary" />
</div> </div>
<h3 class="card-title text-base">Lightning Fast</h3>
<p class="text-sm text-base-content/60">Track tasks with a single click. Start, stop, and organize in seconds.</p>
</div> </div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> </div>
<div class="card-body items-start"> <div class="card bg-base-100 card-border">
<div class="text-4xl mb-3">📊</div> <div class="card-body">
<h3 class="card-title text-lg">Detailed Reports</h3> <div class="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center mb-2">
<p class="text-sm text-base-content/70">Get actionable insights into your team's tasks.</p> <Icon name="chart-bar" class="h-5 w-5 text-info" />
</div> </div>
<h3 class="card-title text-base">Detailed Reports</h3>
<p class="text-sm text-base-content/60">Get actionable insights with charts, filters, and CSV exports.</p>
</div> </div>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"> </div>
<div class="card-body items-start"> <div class="card bg-base-100 card-border">
<div class="text-4xl mb-3">👥</div> <div class="card-body">
<h3 class="card-title text-lg">Team Collaboration</h3> <div class="w-10 h-10 rounded-lg bg-base-200 flex items-center justify-center mb-2">
<p class="text-sm text-base-content/70">Built for multiple team members.</p> <Icon name="users" class="h-5 w-5 text-accent" />
</div> </div>
<h3 class="card-title text-base">Team Collaboration</h3>
<p class="text-sm text-base-content/60">Built for teams with roles, permissions, and shared workspaces.</p>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,6 +1,6 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components'; import Icon from '../components/Icon.astro';
import { db } from '../db'; import { db } from '../db';
import { siteSettings, users } from '../db/schema'; import { siteSettings, users } from '../db/schema';
import { eq, count } from 'drizzle-orm'; import { eq, count } from 'drizzle-orm';
@@ -34,92 +34,82 @@ const errorMessage =
<Layout title="Sign Up - Chronus"> <Layout title="Sign Up - Chronus">
<div class="flex justify-center items-center flex-1 bg-base-100"> <div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4"> <div class="card card-border bg-base-200 w-full max-w-sm mx-4">
<div class="card-body"> <div class="card-body gap-0">
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" /> <img src="/logo.webp" alt="Chronus" class="h-14 w-14 mx-auto mb-3" />
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2> <h2 class="text-2xl font-extrabold tracking-tight text-center">Create Account</h2>
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p> <p class="text-center text-base-content/60 text-sm mt-1 mb-5">Join Chronus to start tracking time</p>
{errorMessage && ( {errorMessage && (
<div role="alert" class="alert alert-error mb-4"> <div role="alert" class="alert alert-error mb-4 text-sm">
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" /> <Icon name="exclamation-circle" class="w-5 h-5" />
<span>{errorMessage}</span> <span>{errorMessage}</span>
</div> </div>
)} )}
{registrationDisabled ? ( {registrationDisabled ? (
<> <>
<div class="alert alert-warning"> <div class="alert alert-warning text-sm">
<Icon name="heroicons:exclamation-triangle" class="w-6 h-6" /> <Icon name="exclamation-triangle" class="w-5 h-5" />
<span>Registration is currently disabled by the site administrator.</span> <span>Registration is currently disabled by the site administrator.</span>
</div> </div>
<div class="divider"></div> <div class="divider text-xs"></div>
<div class="text-center"> <p class="text-center text-sm text-base-content/60">
<p class="text-sm text-base-content/70"> Already have an account?
Already have an account? <a href="/login" class="link link-primary font-semibold">Sign in</a>
<a href="/login" class="link link-primary font-semibold">Sign in</a> </p>
</p>
</div>
</> </>
) : ( ) : (
<> <>
<form action="/api/auth/signup" method="POST" class="space-y-4"> <form action="/api/auth/signup" method="POST" class="space-y-3">
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="name"> <legend class="fieldset-legend text-xs">Full Name</legend>
Full Name
</label>
<input <input
type="text" type="text"
id="name" id="name"
name="name" name="name"
placeholder="John Doe" placeholder="John Doe"
class="input input-bordered w-full" class="input w-full"
autocomplete="name" autocomplete="name"
required required
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="email"> <legend class="fieldset-legend text-xs">Email</legend>
Email
</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="email"
placeholder="your@email.com" placeholder="your@email.com"
class="input input-bordered w-full" class="input w-full"
autocomplete="email" autocomplete="email"
required required
/> />
</div> </fieldset>
<div class="form-control"> <fieldset class="fieldset">
<label class="label font-medium" for="password"> <legend class="fieldset-legend text-xs">Password</legend>
Password
</label>
<input <input
type="password" type="password"
id="password" id="password"
name="password" name="password"
placeholder="Create a strong password" placeholder="Create a strong password"
class="input input-bordered w-full" class="input w-full"
autocomplete="new-password" autocomplete="new-password"
required required
/> />
</div> </fieldset>
<button class="btn btn-primary w-full mt-6">Create Account</button> <button class="btn btn-primary w-full mt-4">Create Account</button>
</form> </form>
<div class="divider">OR</div> <div class="divider text-xs">OR</div>
<div class="text-center"> <p class="text-center text-sm text-base-content/60">
<p class="text-sm text-base-content/70"> Already have an account?
Already have an account? <a href="/login" class="link link-primary font-semibold">Sign in</a>
<a href="/login" class="link link-primary font-semibold">Sign in</a> </p>
</p>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -52,10 +52,6 @@ export const GET: APIRoute = async ({ params }) => {
case ".gif": case ".gif":
contentType = "image/gif"; contentType = "image/gif";
break; break;
case ".svg":
contentType = "image/svg+xml";
break;
// WebP is intentionally omitted as it is not supported in PDF generation
} }
return new Response(fileContent, { return new Response(fileContent, {

View File

@@ -305,7 +305,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
if (organization.logoUrl) { if (organization.logoUrl) {
try { try {
let logoPath; let logoPath;
// Handle uploads directory which might be external to public/
if (organization.logoUrl.startsWith("/uploads/")) { if (organization.logoUrl.startsWith("/uploads/")) {
const dataDir = process.env.DATA_DIR const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR ? process.env.DATA_DIR

View File

@@ -1,6 +1,20 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui" { @plugin "daisyui" {
themes: false; themes: sunset --default, winter;
}
/* Smoother transitions globally */
@layer base {
* {
@apply transition-colors duration-150;
}
/* Opt out for elements where color transitions are unwanted */
input, select, textarea, progress, .loading, .countdown, svg {
transition: none;
}
}
/* Override DaisyUI card-border to use a visible border in both themes */
.card-border {
border-color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
} }
@plugin "./theme-dark.ts";
@plugin "./theme-light.ts";

View File

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

View File

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

View File

@@ -34,7 +34,6 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
if (discountType === "percentage") { if (discountType === "percentage") {
discountAmount = Math.round(subtotal * (discountValue / 100)); discountAmount = Math.round(subtotal * (discountValue / 100));
} else { } else {
// Fixed amount is assumed to be in cents
discountAmount = Math.round(discountValue); discountAmount = Math.round(discountValue);
} }