18 Commits

Author SHA1 Message Date
3d4b8762e5 Oops
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m8s
2026-01-18 14:47:45 -07:00
5e70dd6bb8 2.0.0
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m49s
2026-01-18 14:27:47 -07:00
ce47de9e56 Fixed icons for Vue... I guess we need to be consistent.
All checks were successful
Docker Deploy / build-and-push (push) Successful in 8m22s
2026-01-18 13:46:03 -07:00
db1d180afc Removed footer
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m52s
2026-01-18 01:43:21 -07:00
82e1b8a626 Style updates
Some checks failed
Docker Deploy / build-and-push (push) Has been cancelled
2026-01-18 01:40:22 -07:00
253c24c89b Last try!
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m30s
2026-01-17 22:41:56 -07:00
39c51b1115 Deps
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m50s
2026-01-17 22:30:54 -07:00
091766d6e4 Fixed a few things lol
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m11s
2026-01-17 22:19:10 -07:00
0cd77677f2 FINISHED
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s
2026-01-17 15:56:25 -07:00
3734b2693a Moved to lbSQL fully
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m33s
2026-01-17 10:58:10 -07:00
996092d14e 1.3.0 - Invoices, Manual entries, and Auto Migrations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m55s
2026-01-17 01:39:12 -07:00
aae8693dd3 Trying this again...
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-01-17 01:32:07 -07:00
bebc4b2743 Responsive updates
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m36s
2026-01-17 01:01:53 -07:00
7026435cd3 Changed DB driver
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m42s
2026-01-16 18:45:28 -07:00
85750a5c79 Fixed docker
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m48s
2026-01-16 18:20:47 -07:00
6aa4548a38 ????
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m40s
2026-01-16 18:00:55 -07:00
42fbea6ae7 pls
Some checks failed
Docker Deploy / build-and-push (push) Failing after 3m19s
2026-01-16 17:55:36 -07:00
c4ecc0b899 :|
All checks were successful
Docker Deploy / build-and-push (push) Successful in 5m40s
2026-01-16 17:39:57 -07:00
53 changed files with 2717 additions and 502 deletions

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ CREATE TABLE `members` (
CREATE TABLE `organizations` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`logo_url` text,
`street` text,
`city` text,
`state` text,

View File

@@ -0,0 +1,6 @@
ALTER TABLE `clients` ADD `phone` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `street` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `city` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `state` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `zip` text;--> statement-breakpoint
ALTER TABLE `clients` ADD `country` text;

View File

@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "cfa98c92-215e-4dbc-b8d4-23a655684d1b",
"id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"api_tokens": {
@@ -513,6 +513,13 @@
"notNull": true,
"autoincrement": false
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"street": {
"name": "street",
"type": "text",

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,15 @@
{
"idx": 0,
"version": "6",
"when": 1768609277648,
"tag": "0000_mixed_morlocks",
"when": 1768688193284,
"tag": "0000_motionless_king_cobra",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768690333269,
"tag": "0001_lazy_roughhouse",
"breakpoints": true
}
]

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "1.2.0",
"version": "2.0.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -16,11 +16,12 @@
"@astrojs/node": "^9.5.2",
"@astrojs/vue": "^5.1.4",
"@ceereals/vue-pdf": "^0.2.1",
"@iconify/vue": "^5.0.0",
"@libsql/client": "^0.17.0",
"@tailwindcss/vite": "^4.1.18",
"astro": "^5.16.11",
"astro-icon": "^1.1.5",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.6.0",
"chart.js": "^4.5.1",
"daisyui": "^5.5.14",
"drizzle-orm": "0.45.1",
@@ -34,7 +35,6 @@
"@catppuccin/daisyui": "^2.1.1",
"@iconify-json/heroicons": "^1.2.3",
"@react-pdf/types": "^2.9.2",
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "0.31.8"
}
}

346
pnpm-lock.yaml generated
View File

@@ -20,6 +20,12 @@ importers:
'@ceereals/vue-pdf':
specifier: ^0.2.1
version: 0.2.1(vue@3.5.26(typescript@5.9.3))
'@iconify/vue':
specifier: ^5.0.0
version: 5.0.0(vue@3.5.26(typescript@5.9.3))
'@libsql/client':
specifier: ^0.17.0
version: 0.17.0
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@6.4.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.2))
@@ -32,9 +38,6 @@ importers:
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
better-sqlite3:
specifier: ^12.6.0
version: 12.6.0
chart.js:
specifier: ^4.5.1
version: 4.5.1
@@ -43,7 +46,7 @@ importers:
version: 5.5.14
drizzle-orm:
specifier: 0.45.1
version: 0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.0)
version: 0.45.1(@libsql/client@0.17.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.0)
nanoid:
specifier: ^5.1.6
version: 5.1.6
@@ -69,9 +72,6 @@ importers:
'@react-pdf/types':
specifier: ^2.9.2
version: 2.9.2
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
drizzle-kit:
specifier: 0.31.8
version: 0.31.8
@@ -633,6 +633,11 @@ packages:
'@iconify/utils@2.3.0':
resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
'@iconify/vue@5.0.0':
resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
peerDependencies:
vue: '>=3'
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
@@ -793,6 +798,66 @@ packages:
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@libsql/client@0.17.0':
resolution: {integrity: sha512-TLjSU9Otdpq0SpKHl1tD1Nc9MKhrsZbCFGot3EbCxRa8m1E5R1mMwoOjKMMM31IyF7fr+hPNHLpYfwbMKNusmg==}
'@libsql/core@0.17.0':
resolution: {integrity: sha512-hnZRnJHiS+nrhHKLGYPoJbc78FE903MSDrFJTbftxo+e52X+E0Y0fHOCVYsKWcg6XgB7BbJYUrz/xEkVTSaipw==}
'@libsql/darwin-arm64@0.5.22':
resolution: {integrity: sha512-4B8ZlX3nIDPndfct7GNe0nI3Yw6ibocEicWdC4fvQbSs/jdq/RC2oCsoJxJ4NzXkvktX70C1J4FcmmoBy069UA==}
cpu: [arm64]
os: [darwin]
'@libsql/darwin-x64@0.5.22':
resolution: {integrity: sha512-ny2HYWt6lFSIdNFzUFIJ04uiW6finXfMNJ7wypkAD8Pqdm6nAByO+Fdqu8t7sD0sqJGeUCiOg480icjyQ2/8VA==}
cpu: [x64]
os: [darwin]
'@libsql/hrana-client@0.9.0':
resolution: {integrity: sha512-pxQ1986AuWfPX4oXzBvLwBnfgKDE5OMhAdR/5cZmRaB4Ygz5MecQybvwZupnRz341r2CtFmbk/BhSu7k2Lm+Jw==}
'@libsql/isomorphic-ws@0.1.5':
resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==}
'@libsql/linux-arm-gnueabihf@0.5.22':
resolution: {integrity: sha512-3Uo3SoDPJe/zBnyZKosziRGtszXaEtv57raWrZIahtQDsjxBVjuzYQinCm9LRCJCUT5t2r5Z5nLDPJi2CwZVoA==}
cpu: [arm]
os: [linux]
'@libsql/linux-arm-musleabihf@0.5.22':
resolution: {integrity: sha512-LCsXh07jvSojTNJptT9CowOzwITznD+YFGGW+1XxUr7fS+7/ydUrpDfsMX7UqTqjm7xG17eq86VkWJgHJfvpNg==}
cpu: [arm]
os: [linux]
'@libsql/linux-arm64-gnu@0.5.22':
resolution: {integrity: sha512-KSdnOMy88c9mpOFKUEzPskSaF3VLflfSUCBwas/pn1/sV3pEhtMF6H8VUCd2rsedwoukeeCSEONqX7LLnQwRMA==}
cpu: [arm64]
os: [linux]
'@libsql/linux-arm64-musl@0.5.22':
resolution: {integrity: sha512-mCHSMAsDTLK5YH//lcV3eFEgiR23Ym0U9oEvgZA0667gqRZg/2px+7LshDvErEKv2XZ8ixzw3p1IrBzLQHGSsw==}
cpu: [arm64]
os: [linux]
'@libsql/linux-x64-gnu@0.5.22':
resolution: {integrity: sha512-kNBHaIkSg78Y4BqAdgjcR2mBilZXs4HYkAmi58J+4GRwDQZh5fIUWbnQvB9f95DkWUIGVeenqLRFY2pcTmlsew==}
cpu: [x64]
os: [linux]
'@libsql/linux-x64-musl@0.5.22':
resolution: {integrity: sha512-UZ4Xdxm4pu3pQXjvfJiyCzZop/9j/eA2JjmhMaAhe3EVLH2g11Fy4fwyUp9sT1QJYR1kpc2JLuybPM0kuXv/Tg==}
cpu: [x64]
os: [linux]
'@libsql/win32-x64-msvc@0.5.22':
resolution: {integrity: sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA==}
cpu: [x64]
os: [win32]
'@neon-rs/load@0.0.4':
resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
@@ -1124,6 +1189,9 @@ packages:
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yauzl@2.10.3':
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
@@ -1520,6 +1588,9 @@ packages:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
cross-fetch@4.1.0:
resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1564,6 +1635,10 @@ packages:
daisyui@5.5.14:
resolution: {integrity: sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1614,6 +1689,10 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.0.2:
resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1893,6 +1972,10 @@ packages:
picomatch:
optional: true
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -1921,6 +2004,10 @@ packages:
resolution: {integrity: sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q==}
engines: {node: '>=20'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -2157,6 +2244,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2207,6 +2297,10 @@ packages:
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
libsql@0.5.22:
resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==}
os: [darwin, linux, win32]
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@@ -2515,9 +2609,27 @@ packages:
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==}
engines: {node: '>=10'}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-mock-http@1.0.4:
resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==}
@@ -2682,6 +2794,9 @@ packages:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
promise-limit@2.7.0:
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@@ -2996,6 +3111,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -3363,6 +3481,13 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@@ -3372,6 +3497,9 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-pm-runs@1.1.0:
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
engines: {node: '>=4'}
@@ -3396,6 +3524,18 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@0.1.0:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
@@ -4039,6 +4179,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@iconify/vue@5.0.0(vue@3.5.26(typescript@5.9.3))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.26(typescript@5.9.3)
'@img/colour@1.0.0':
optional: true
@@ -4161,6 +4306,70 @@ snapshots:
'@kurkle/color@0.3.4': {}
'@libsql/client@0.17.0':
dependencies:
'@libsql/core': 0.17.0
'@libsql/hrana-client': 0.9.0
js-base64: 3.7.8
libsql: 0.5.22
promise-limit: 2.7.0
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@libsql/core@0.17.0':
dependencies:
js-base64: 3.7.8
'@libsql/darwin-arm64@0.5.22':
optional: true
'@libsql/darwin-x64@0.5.22':
optional: true
'@libsql/hrana-client@0.9.0':
dependencies:
'@libsql/isomorphic-ws': 0.1.5
cross-fetch: 4.1.0
js-base64: 3.7.8
node-fetch: 3.3.2
transitivePeerDependencies:
- bufferutil
- encoding
- utf-8-validate
'@libsql/isomorphic-ws@0.1.5':
dependencies:
'@types/ws': 8.18.1
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
'@libsql/linux-arm-gnueabihf@0.5.22':
optional: true
'@libsql/linux-arm-musleabihf@0.5.22':
optional: true
'@libsql/linux-arm64-gnu@0.5.22':
optional: true
'@libsql/linux-arm64-musl@0.5.22':
optional: true
'@libsql/linux-x64-gnu@0.5.22':
optional: true
'@libsql/linux-x64-musl@0.5.22':
optional: true
'@libsql/win32-x64-msvc@0.5.22':
optional: true
'@neon-rs/load@0.0.4': {}
'@oslojs/encoding@1.1.0': {}
'@polka/url@1.0.0-next.29': {}
@@ -4442,6 +4651,7 @@ snapshots:
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.0.9
optional: true
'@types/debug@4.1.12':
dependencies:
@@ -4471,6 +4681,10 @@ snapshots:
'@types/web-bluetooth@0.0.21': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.0.9
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 25.0.9
@@ -4854,6 +5068,7 @@ snapshots:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
optional: true
bidi-js@1.0.3:
dependencies:
@@ -4862,6 +5077,7 @@ snapshots:
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
optional: true
birpc@2.9.0: {}
@@ -4870,6 +5086,7 @@ snapshots:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
optional: true
boolbase@1.0.0: {}
@@ -4912,6 +5129,7 @@ snapshots:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
optional: true
bundle-name@4.1.0:
dependencies:
@@ -4988,7 +5206,8 @@ snapshots:
dependencies:
readdirp: 5.0.0
chownr@1.1.4: {}
chownr@1.1.4:
optional: true
chownr@3.0.0: {}
@@ -5041,6 +5260,12 @@ snapshots:
dependencies:
is-what: 5.5.0
cross-fetch@4.1.0:
dependencies:
node-fetch: 2.7.0
transitivePeerDependencies:
- encoding
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -5088,6 +5313,8 @@ snapshots:
daisyui@5.5.14: {}
data-uri-to-buffer@4.0.1: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -5099,8 +5326,10 @@ snapshots:
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
optional: true
deep-extend@0.6.0: {}
deep-extend@0.6.0:
optional: true
default-browser-id@5.0.1: {}
@@ -5125,6 +5354,8 @@ snapshots:
destr@2.0.5: {}
detect-libc@2.0.2: {}
detect-libc@2.1.2: {}
deterministic-object-hash@2.0.2:
@@ -5170,8 +5401,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.0):
drizzle-orm@0.45.1(@libsql/client@0.17.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.0):
optionalDependencies:
'@libsql/client': 0.17.0
'@types/better-sqlite3': 7.6.13
better-sqlite3: 12.6.0
@@ -5324,7 +5556,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expand-template@2.0.3: {}
expand-template@2.0.3:
optional: true
exsolve@1.0.8: {}
@@ -5352,11 +5585,17 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
file-uri-to-path@1.0.0: {}
file-uri-to-path@1.0.0:
optional: true
fill-range@7.1.1:
dependencies:
@@ -5388,9 +5627,14 @@ snapshots:
dependencies:
tiny-inflate: 1.0.3
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fs-constants@1.0.0:
optional: true
fs-extra@10.1.0:
dependencies:
@@ -5446,7 +5690,8 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
github-from-package@0.0.0: {}
github-from-package@0.0.0:
optional: true
github-slugger@2.0.0: {}
@@ -5604,13 +5849,15 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ieee754@1.2.1:
optional: true
import-meta-resolve@4.2.0: {}
inherits@2.0.4: {}
ini@1.3.8: {}
ini@1.3.8:
optional: true
iron-webcrypto@1.2.1: {}
@@ -5656,6 +5903,8 @@ snapshots:
jiti@2.6.1: {}
js-base64@3.7.8: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -5698,6 +5947,21 @@ snapshots:
kolorist@1.8.0: {}
libsql@0.5.22:
dependencies:
'@neon-rs/load': 0.0.4
detect-libc: 2.0.2
optionalDependencies:
'@libsql/darwin-arm64': 0.5.22
'@libsql/darwin-x64': 0.5.22
'@libsql/linux-arm-gnueabihf': 0.5.22
'@libsql/linux-arm-musleabihf': 0.5.22
'@libsql/linux-arm64-gnu': 0.5.22
'@libsql/linux-arm64-musl': 0.5.22
'@libsql/linux-x64-gnu': 0.5.22
'@libsql/linux-x64-musl': 0.5.22
'@libsql/win32-x64-msvc': 0.5.22
lightningcss-android-arm64@1.30.2:
optional: true
@@ -6112,7 +6376,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
mimic-response@3.1.0: {}
mimic-response@3.1.0:
optional: true
minimist@1.2.8: {}
@@ -6124,7 +6389,8 @@ snapshots:
mitt@3.0.1: {}
mkdirp-classic@0.5.3: {}
mkdirp-classic@0.5.3:
optional: true
mlly@1.8.0:
dependencies:
@@ -6143,7 +6409,8 @@ snapshots:
nanoid@5.1.6: {}
napi-build-utils@2.0.0: {}
napi-build-utils@2.0.0:
optional: true
neotraverse@0.6.18: {}
@@ -6154,9 +6421,22 @@ snapshots:
node-abi@3.86.0:
dependencies:
semver: 7.7.3
optional: true
node-domexception@1.0.0: {}
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-mock-http@1.0.4: {}
node-releases@2.0.27: {}
@@ -6330,6 +6610,7 @@ snapshots:
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
optional: true
prettier@3.8.0: {}
@@ -6339,6 +6620,8 @@ snapshots:
prismjs@1.30.0: {}
promise-limit@2.7.0: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3
@@ -6367,12 +6650,14 @@ snapshots:
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
optional: true
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
readdirp@4.1.2: {}
@@ -6616,13 +6901,15 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-concat@1.0.1:
optional: true
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
optional: true
simple-swizzle@0.2.4:
dependencies:
@@ -6686,7 +6973,8 @@ snapshots:
strip-final-newline@4.0.0: {}
strip-json-comments@2.0.1: {}
strip-json-comments@2.0.1:
optional: true
superjson@2.2.6:
dependencies:
@@ -6728,6 +7016,7 @@ snapshots:
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
optional: true
tar-stream@2.2.0:
dependencies:
@@ -6736,6 +7025,7 @@ snapshots:
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
optional: true
tar@7.5.3:
dependencies:
@@ -6764,6 +7054,8 @@ snapshots:
totalist@3.0.1: {}
tr46@0.0.3: {}
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -6777,6 +7069,7 @@ snapshots:
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
optional: true
type-fest@4.41.0: {}
@@ -7094,12 +7387,21 @@ snapshots:
web-namespaces@2.0.1: {}
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
whatwg-mimetype@4.0.0: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-pm-runs@1.1.0: {}
which@2.0.2:
@@ -7124,6 +7426,8 @@ snapshots:
wrappy@1.0.2: {}
ws@8.19.0: {}
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.0

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,45 +1,40 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { createClient } from "@libsql/client";
import path from "path";
import fs from "fs";
const runMigrations = () => {
console.log("Starting database migrations...");
async function runMigrate() {
console.log("Running migrations...");
const dbUrl =
process.env.DATABASE_URL || path.resolve(process.cwd(), "chronus.db");
const dbDir = path.dirname(dbUrl);
const dataDir = process.env.DATA_DIR;
if (!fs.existsSync(dbDir)) {
console.log(`Creating directory for database: ${dbDir}`);
fs.mkdirSync(dbDir, { recursive: true });
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
console.log(`Using database at: ${dbUrl}`);
const sqlite = new Database(dbUrl);
const db = drizzle(sqlite);
const migrationsFolder = path.resolve(process.cwd(), "drizzle");
if (!fs.existsSync(migrationsFolder)) {
console.error(`Migrations folder not found at: ${migrationsFolder}`);
console.error(
"Did you run `drizzle-kit generate` and copy the folder to the container?",
);
process.exit(1);
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const url = `file:${path.join(dataDir, "chronus.db")}`;
console.log(`Using database: ${url}`);
const client = createClient({
url,
});
const db = drizzle(client);
try {
migrate(db, { migrationsFolder });
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations completed successfully");
} catch (error) {
console.error("Migration failed:", error);
process.exit(1);
} finally {
client.close();
}
}
sqlite.close();
};
runMigrations();
runMigrate();

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { Icon } from "@iconify/vue";
const props = defineProps<{
initialRunningEntry: {
@@ -224,14 +225,16 @@ async function stopTimer() {
@click="startTimer"
class="btn btn-primary btn-lg min-w-40 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
>
Start Timer
<Icon icon="heroicons:play" class="w-5 h-5" />
Start Timer
</button>
<button
v-else
@click="stopTimer"
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
>
Stop Timer
<Icon icon="heroicons:stop" class="w-5 h-5" />
Stop Timer
</button>
</div>
</div>

View File

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

View File

@@ -5,6 +5,7 @@ import {
real,
primaryKey,
foreignKey,
index,
} from "drizzle-orm/sqlite-core";
import { nanoid } from "nanoid";
@@ -26,6 +27,7 @@ export const organizations = sqliteTable("organizations", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
logoUrl: text("logo_url"),
street: text("street"),
city: text("city"),
state: text("state"),
@@ -56,6 +58,10 @@ export const members = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
userIdIdx: index("members_user_id_idx").on(table.userId),
organizationIdIdx: index("members_organization_id_idx").on(
table.organizationId,
),
}),
);
@@ -68,6 +74,12 @@ export const clients = sqliteTable(
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
email: text("email"),
phone: text("phone"),
street: text("street"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(),
),
@@ -77,6 +89,9 @@ export const clients = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("clients_organization_id_idx").on(
table.organizationId,
),
}),
);
@@ -98,6 +113,9 @@ export const categories = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("categories_organization_id_idx").on(
table.organizationId,
),
}),
);
@@ -136,6 +154,12 @@ export const timeEntries = sqliteTable(
columns: [table.categoryId],
foreignColumns: [categories.id],
}),
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
organizationIdIdx: index("time_entries_organization_id_idx").on(
table.organizationId,
),
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
}),
);
@@ -157,6 +181,9 @@ export const tags = sqliteTable(
columns: [table.organizationId],
foreignColumns: [organizations.id],
}),
organizationIdIdx: index("tags_organization_id_idx").on(
table.organizationId,
),
}),
);
@@ -176,6 +203,10 @@ export const timeEntryTags = sqliteTable(
columns: [table.tagId],
foreignColumns: [tags.id],
}),
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
table.timeEntryId,
),
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
}),
);
@@ -191,6 +222,7 @@ export const sessions = sqliteTable(
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("sessions_user_id_idx").on(table.userId),
}),
);
@@ -225,6 +257,7 @@ export const apiTokens = sqliteTable(
columns: [table.userId],
foreignColumns: [users.id],
}),
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
}),
);
@@ -260,6 +293,10 @@ export const invoices = sqliteTable(
columns: [table.clientId],
foreignColumns: [clients.id],
}),
organizationIdIdx: index("invoices_organization_id_idx").on(
table.organizationId,
),
clientIdIdx: index("invoices_client_id_idx").on(table.clientId),
}),
);
@@ -280,5 +317,6 @@ export const invoiceItems = sqliteTable(
columns: [table.invoiceId],
foreignColumns: [invoices.id],
}),
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
}),
);

View File

@@ -4,7 +4,6 @@ import { Icon } from 'astro-icon/components';
import { db } from '../db';
import { members, organizations } from '../db/schema';
import { eq } from 'drizzle-orm';
import Footer from '../components/Footer.astro';
import Avatar from '../components/Avatar.astro';
import { ClientRouter } from "astro:transitions";
@@ -57,7 +56,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</label>
</div>
<div class="flex-1 px-2 flex items-center gap-2">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
<span class="text-xl font-bold text-primary">Chronus</span>
</div>
</div>
@@ -73,7 +72,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
<!-- Sidebar content here -->
<li class="mb-6">
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
<img src="/logo.webp" alt="Chronus" class="h-10 w-10" />
Chronus
</a>
</li>
@@ -181,8 +180,8 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</li>
<li>
<form action="/api/auth/logout" method="POST">
<button type="submit" class="w-full text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
<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
</button>
@@ -192,6 +191,5 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</div>
</div>
<Footer />
</body>
</html>

View File

@@ -1,6 +1,5 @@
---
import '../styles/global.css';
import Footer from '../components/Footer.astro';
import { ClientRouter } from "astro:transitions";
interface Props {
@@ -21,10 +20,9 @@ const { title } = Astro.props;
<title>{title}</title>
<ClientRouter />
</head>
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
<div class="flex-1 overflow-auto">
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
<div class="flex-1 flex flex-col">
<slot />
</div>
<Footer />
</body>
</html>

View File

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

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

@@ -0,0 +1,83 @@
import { db } from "../db";
import { clients, categories, tags as tagsTable } from "../db/schema";
import { eq, and, inArray } from "drizzle-orm";
export async function validateTimeEntryResources({
organizationId,
clientId,
categoryId,
tagIds,
}: {
organizationId: string;
clientId: string;
categoryId: string;
tagIds?: string[];
}) {
const [client, category] = await Promise.all([
db
.select()
.from(clients)
.where(
and(
eq(clients.id, clientId),
eq(clients.organizationId, organizationId),
),
)
.get(),
db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, organizationId),
),
)
.get(),
]);
if (!client) {
return { valid: false, error: "Invalid client" };
}
if (!category) {
return { valid: false, error: "Invalid category" };
}
if (tagIds && tagIds.length > 0) {
const validTags = await db
.select()
.from(tagsTable)
.where(
and(
inArray(tagsTable.id, tagIds),
eq(tagsTable.organizationId, organizationId),
),
)
.all();
if (validTags.length !== tagIds.length) {
return { valid: false, error: "Invalid tags" };
}
}
return { valid: true };
}
export function validateTimeRange(
start: string | number | Date,
end: string | number | Date,
) {
const startDate = new Date(start);
const endDate = new Date(end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return { valid: false, error: "Invalid date format" };
}
if (endDate <= startDate) {
return { valid: false, error: "End time must be after start time" };
}
return { valid: true, startDate, endDate };
}

View File

@@ -41,7 +41,7 @@ const allUsers = await db.select().from(users).all();
<form method="POST" action="/api/admin/settings">
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">
<span class="label-text flex-1 min-w-0 pr-4">
<div class="font-semibold">Allow New Registrations</div>
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
</span>

View File

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

View File

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

View File

@@ -16,15 +16,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
let name: string | undefined;
let email: string | undefined;
let phone: string | undefined;
let street: string | undefined;
let city: string | undefined;
let state: string | undefined;
let zip: string | undefined;
let country: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
email = formData.get("email")?.toString();
phone = formData.get("phone")?.toString();
street = formData.get("street")?.toString();
city = formData.get("city")?.toString();
state = formData.get("state")?.toString();
zip = formData.get("zip")?.toString();
country = formData.get("country")?.toString();
}
if (!name || name.trim().length === 0) {
@@ -74,6 +92,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.set({
name: name.trim(),
email: email?.trim() || null,
phone: phone?.trim() || null,
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
})
.where(eq(clients.id, id))
.run();
@@ -85,6 +109,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
id,
name: name.trim(),
email: email?.trim() || null,
phone: phone?.trim() || null,
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
}),
{
status: 200,

View File

@@ -12,15 +12,33 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
let name: string | undefined;
let email: string | undefined;
let phone: string | undefined;
let street: string | undefined;
let city: string | undefined;
let state: string | undefined;
let zip: string | undefined;
let country: string | undefined;
if (request.headers.get("Content-Type")?.includes("application/json")) {
const body = await request.json();
name = body.name;
email = body.email;
phone = body.phone;
street = body.street;
city = body.city;
state = body.state;
zip = body.zip;
country = body.country;
} else {
const formData = await request.formData();
name = formData.get("name")?.toString();
email = formData.get("email")?.toString();
phone = formData.get("phone")?.toString();
street = formData.get("street")?.toString();
city = formData.get("city")?.toString();
state = formData.get("state")?.toString();
zip = formData.get("zip")?.toString();
country = formData.get("country")?.toString();
}
if (!name) {
@@ -44,13 +62,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
organizationId: userOrg.organizationId,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
});
if (locals.scopes) {
return new Response(JSON.stringify({ id, name, email: email || null }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({
id,
name,
email: email || null,
phone: phone || null,
street: street || null,
city: city || null,
state: state || null,
zip: zip || null,
country: country || null,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect("/dashboard/clients");

View File

@@ -0,0 +1,97 @@
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and, desc } from "drizzle-orm";
export const POST: APIRoute = async ({ redirect, locals, params }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
}
const { id: invoiceId } = params;
if (!invoiceId) {
return new Response("Invoice ID required", { status: 400 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
.where(eq(invoices.id, invoiceId))
.get();
if (!invoice) {
return new Response("Invoice not found", { status: 404 });
}
if (invoice.type !== "quote") {
return new Response("Only quotes can be converted to invoices", {
status: 400,
});
}
// Verify membership
const membership = await db
.select()
.from(members)
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId),
),
)
.get();
if (!membership) {
return new Response("Unauthorized", { status: 401 });
}
try {
// Generate next invoice number
const lastInvoice = await db
.select()
.from(invoices)
.where(
and(
eq(invoices.organizationId, invoice.organizationId),
eq(invoices.type, "invoice"),
),
)
.orderBy(desc(invoices.createdAt))
.limit(1)
.get();
let nextInvoiceNumber = "INV-001";
if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
let prefix = lastInvoice.number.replace(match[0], "");
if (prefix === "EST-") prefix = "INV-";
nextInvoiceNumber =
prefix + num.toString().padStart(match[0].length, "0");
}
}
// Convert quote to invoice:
// 1. Change type to 'invoice'
// 2. Set status to 'draft' (so user can review before sending)
// 3. Update number to next invoice sequence
// 4. Update issue date to today
await db
.update(invoices)
.set({
type: "invoice",
status: "draft",
number: nextInvoiceNumber,
issueDate: new Date(),
})
.where(eq(invoices.id, invoiceId));
return redirect(`/dashboard/invoices/${invoiceId}`);
} catch (error) {
console.error("Error converting quote to invoice:", error);
return new Response("Internal Server Error", { status: 500 });
}
};

View File

@@ -69,7 +69,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
// Generate PDF using Vue PDF
// Suppress verbose logging from PDF renderer
const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn;
console.log = () => {};
console.warn = () => {};
try {
const pdfDocument = createInvoiceDocument({
@@ -83,6 +85,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
// Restore console.log
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
@@ -95,6 +98,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
} catch (pdfError) {
// Restore console.log on error
console.log = originalConsoleLog;
console.warn = originalConsoleWarn;
throw pdfError;
}
} catch (error) {

View File

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

View File

@@ -3,7 +3,12 @@ import { db } from "../../../db";
import { invoices, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => {
export const POST: APIRoute = async ({
request,
redirect,
locals,
cookies,
}) => {
const user = locals.user;
if (!user) {
return redirect("/login");
@@ -36,7 +41,8 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
}
const membership = currentTeamId
? userMemberships.find((m) => m.organizationId === currentTeamId)
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
userMemberships[0]
: userMemberships[0];
if (!membership) {
@@ -72,3 +78,7 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
return new Response("Internal Server Error", { status: 500 });
}
};
export const GET: APIRoute = async ({ redirect }) => {
return redirect("/dashboard/invoices/new");
};

View File

@@ -1,4 +1,6 @@
import type { APIRoute } from "astro";
import { promises as fs } from "fs";
import path from "path";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
@@ -17,6 +19,7 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
const state = formData.get("state") as string | null;
const zip = formData.get("zip") as string | null;
const country = formData.get("country") as string | null;
const logo = formData.get("logo") as File | null;
if (!organizationId || !name || name.trim().length === 0) {
return new Response("Organization ID and name are required", {
@@ -49,17 +52,59 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
);
}
let logoUrl: string | undefined;
if (logo && logo.size > 0) {
const allowedTypes = ["image/png", "image/jpeg"];
if (!allowedTypes.includes(logo.type)) {
return new Response(
"Invalid file type. Only PNG and JPG are allowed.",
{
status: 400,
},
);
}
const ext = logo.name.split(".").pop() || "png";
const filename = `${organizationId}-${Date.now()}.${ext}`;
const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR
: import.meta.env.DATA_DIR;
if (!dataDir) {
throw new Error("DATA_DIR environment variable is not set");
}
const uploadDir = path.join(dataDir, "uploads");
try {
await fs.access(uploadDir);
} catch {
await fs.mkdir(uploadDir, { recursive: true });
}
const buffer = Buffer.from(await logo.arrayBuffer());
await fs.writeFile(path.join(uploadDir, filename), buffer);
logoUrl = `/uploads/${filename}`;
}
// Update organization information
const updateData: any = {
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
};
if (logoUrl) {
updateData.logoUrl = logoUrl;
}
await db
.update(organizations)
.set({
name: name.trim(),
street: street?.trim() || null,
city: city?.trim() || null,
state: state?.trim() || null,
zip: zip?.trim() || null,
country: country?.trim() || null,
})
.set(updateData)
.where(eq(organizations.id, organizationId))
.run();

View File

@@ -1,18 +1,19 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import {
validateTimeEntryResources,
validateTimeRange,
} from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const body = await request.json();
@@ -20,67 +21,47 @@ export const POST: APIRoute = async ({ request, locals }) => {
// Validation
if (!clientId) {
return new Response(
JSON.stringify({ error: 'Client is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "Client is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!categoryId) {
return new Response(
JSON.stringify({ error: 'Category is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "Category is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!startTime) {
return new Response(
JSON.stringify({ error: 'Start time is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "Start time is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!endTime) {
return new Response(
JSON.stringify({ error: 'End time is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "End time is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const startDate = new Date(startTime);
const endDate = new Date(endTime);
const timeValidation = validateTimeRange(startTime, endTime);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return new Response(
JSON.stringify({ error: 'Invalid date format' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
if (
!timeValidation.valid ||
!timeValidation.startDate ||
!timeValidation.endDate
) {
return new Response(JSON.stringify({ error: timeValidation.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (endDate <= startDate) {
return new Response(
JSON.stringify({ error: 'End time must be after start time' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
const { startDate, endDate } = timeValidation;
// Get user's organization
const member = await db
@@ -91,57 +72,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
.get();
if (!member) {
return new Response(
JSON.stringify({ error: 'No organization found' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
return new Response(JSON.stringify({ error: "No organization found" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Verify category belongs to organization
const category = await db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId)
)
)
.get();
const resourceValidation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
categoryId,
tagIds: Array.isArray(tags) ? tags : undefined,
});
if (!category) {
return new Response(
JSON.stringify({ error: 'Invalid category' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
// Verify client belongs to organization
const client = await db
.select()
.from(clients)
.where(
and(
eq(clients.id, clientId),
eq(clients.organizationId, member.organizationId)
)
)
.get();
if (!client) {
return new Response(
JSON.stringify({ error: 'Invalid client' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
}
);
if (!resourceValidation.valid) {
return new Response(JSON.stringify({ error: resourceValidation.error }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const id = nanoid();
@@ -166,7 +114,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
tags.map((tagId: string) => ({
timeEntryId: id,
tagId,
}))
})),
);
}
@@ -179,17 +127,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
}),
{
status: 201,
headers: { 'Content-Type': 'application/json' }
}
headers: { "Content-Type": "application/json" },
},
);
} catch (error) {
console.error('Error creating manual time entry:', error);
console.error("Error creating manual time entry:", error);
return new Response(
JSON.stringify({ error: 'Failed to create time entry' }),
JSON.stringify({ error: "Failed to create time entry" }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
headers: { "Content-Type": "application/json" },
},
);
}
};

View File

@@ -1,13 +1,9 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import {
timeEntries,
members,
timeEntryTags,
categories,
} from "../../../db/schema";
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid";
import { validateTimeEntryResources } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response("Unauthorized", { status: 401 });
@@ -48,19 +44,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
return new Response("No organization found", { status: 400 });
}
const category = await db
.select()
.from(categories)
.where(
and(
eq(categories.id, categoryId),
eq(categories.organizationId, member.organizationId),
),
)
.get();
const validation = await validateTimeEntryResources({
organizationId: member.organizationId,
clientId,
categoryId,
tagIds: tags,
});
if (!category) {
return new Response("Invalid category", { status: 400 });
if (!validation.valid) {
return new Response(validation.error, { status: 400 });
}
const startTime = new Date();

View File

@@ -58,7 +58,7 @@ if (!client) return Astro.redirect('/dashboard/clients');
name="name"
value={client.name}
placeholder="Acme Corp"
class="input input-bordered"
class="input input-bordered w-full"
required
/>
</div>
@@ -72,11 +72,101 @@ if (!client) return Astro.redirect('/dashboard/clients');
id="email"
name="email"
value={client.email || ''}
placeholder="contact@acme.com"
class="input input-bordered"
placeholder="jason.borne@cia.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone (optional)</span>
</label>
<input
type="tel"
id="phone"
name="phone"
value={client.phone || ''}
placeholder="+1 (780) 420-1337"
class="input input-bordered w-full"
/>
</div>
<div class="divider">Address Details</div>
<div class="form-control">
<label class="label" for="street">
<span class="label-text">Street Address (optional)</span>
</label>
<input
type="text"
id="street"
name="street"
value={client.street || ''}
placeholder="123 Business Rd"
class="input input-bordered w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="city">
<span class="label-text">City (optional)</span>
</label>
<input
type="text"
id="city"
name="city"
value={client.city || ''}
placeholder="Edmonton"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State / Province (optional)</span>
</label>
<input
type="text"
id="state"
name="state"
value={client.state || ''}
placeholder="AB"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="zip">
<span class="label-text">Zip / Postal Code (optional)</span>
</label>
<input
type="text"
id="zip"
name="zip"
value={client.zip || ''}
placeholder="10001"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="country">
<span class="label-text">Country (optional)</span>
</label>
<input
type="text"
id="country"
name="country"
value={client.country || ''}
placeholder="Canada"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="card-actions justify-between mt-6">
<button
type="button"

View File

@@ -86,12 +86,34 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<div class="flex justify-between items-start">
<div>
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
{client.email && (
<div class="flex items-center gap-2 text-base-content/70 mb-4">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div>
)}
<div class="space-y-2 mb-4">
{client.email && (
<div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:envelope" class="w-4 h-4" />
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
</div>
)}
{client.phone && (
<div class="flex items-center gap-2 text-base-content/70">
<Icon name="heroicons:phone" class="w-4 h-4" />
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
</div>
)}
{(client.street || client.city || client.state || client.zip || client.country) && (
<div class="flex items-start gap-2 text-base-content/70">
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
<div class="text-sm space-y-0.5">
{client.street && <div>{client.street}</div>}
{(client.city || client.state || client.zip) && (
<div>
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
</div>
)}
{client.country && <div>{client.country}</div>}
</div>
</div>
)}
</div>
</div>
<div class="flex gap-2">
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">

View File

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

View File

@@ -41,52 +41,48 @@ if (currentOrg) {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const weekEntries = await db.select()
const weekStats = await db.select({
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, currentOrg.organizationId),
gte(timeEntries.startTime, weekAgo)
gte(timeEntries.startTime, weekAgo),
sql`${timeEntries.endTime} IS NOT NULL`
))
.all();
.get();
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
const monthEntries = await db.select()
const monthStats = await db.select({
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
})
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, currentOrg.organizationId),
gte(timeEntries.startTime, monthAgo)
gte(timeEntries.startTime, monthAgo),
sql`${timeEntries.endTime} IS NOT NULL`
))
.all();
.get();
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
if (e.endTime) {
return sum + (e.endTime.getTime() - e.startTime.getTime());
}
return sum;
}, 0);
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
const activeCount = await db.select()
const activeCount = await db.select({ count: sql<number>`count(*)` })
.from(timeEntries)
.where(and(
eq(timeEntries.organizationId, currentOrg.organizationId),
isNull(timeEntries.endTime)
))
.all();
.get();
stats.activeTimers = activeCount.length;
stats.activeTimers = activeCount?.count || 0;
const clientCount = await db.select()
const clientCount = await db.select({ count: sql<number>`count(*)` })
.from(clients)
.where(eq(clients.organizationId, currentOrg.organizationId))
.all();
.get();
stats.totalClients = clientCount.length;
stats.totalClients = clientCount?.count || 0;
stats.recentEntries = await db.select({
entry: timeEntries,
@@ -107,7 +103,7 @@ const hasMembership = userOrgs.length > 0;
---
<DashboardLayout title="Dashboard - Chronus">
<div class="flex justify-between items-center mb-8">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-8">
<div>
<h1 class="text-4xl font-bold text-primary mb-2">
Dashboard

View File

@@ -90,24 +90,32 @@ const isDraft = invoice.status === 'draft';
</button>
</form>
)}
{(invoice.status === 'sent' && invoice.type === 'invoice') && (
{(invoice.status !== 'paid' && invoice.status !== 'void' && invoice.type === 'invoice') && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="paid" />
<button type="submit" class="btn btn-success text-white">
<button type="submit" class="btn btn-success">
<Icon name="heroicons:check" class="w-5 h-5" />
Mark Paid
</button>
</form>
)}
{(invoice.status === 'sent' && invoice.type === 'quote') && (
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && invoice.type === 'quote') && (
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
<input type="hidden" name="status" value="accepted" />
<button type="submit" class="btn btn-success text-white">
<button type="submit" class="btn btn-success">
<Icon name="heroicons:check" class="w-5 h-5" />
Mark Accepted
</button>
</form>
)}
{(invoice.type === 'quote' && invoice.status === 'accepted') && (
<form method="POST" action={`/api/invoices/${invoice.id}/convert`}>
<button type="submit" class="btn btn-primary">
<Icon name="heroicons:document-duplicate" class="w-5 h-5" />
Convert to Invoice
</button>
</form>
)}
<div class="dropdown dropdown-end">
<div role="button" tabindex="0" class="btn btn-square btn-ghost border border-base-300">
<Icon name="heroicons:ellipsis-horizontal" class="w-6 h-6" />
@@ -125,12 +133,6 @@ const isDraft = invoice.status === 'draft';
Download PDF
</a>
</li>
<li>
<button type="button" onclick="window.print()">
<Icon name="heroicons:printer" class="w-4 h-4" />
Print
</button>
</li>
{invoice.status !== 'void' && invoice.status !== 'draft' && (
<li>
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
@@ -196,7 +198,19 @@ const isDraft = invoice.status === 'draft';
{client ? (
<div>
<div class="font-bold text-lg">{client.name}</div>
<div class="text-base-content/70">{client.email}</div>
{client.email && <div class="text-base-content/70">{client.email}</div>}
{client.phone && <div class="text-base-content/70">{client.phone}</div>}
{(client.street || client.city || client.state || client.zip || client.country) && (
<div class="text-sm text-base-content/70 mt-2 space-y-0.5">
{client.street && <div>{client.street}</div>}
{(client.city || client.state || client.zip) && (
<div>
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
</div>
)}
{client.country && <div>{client.country}</div>}
</div>
)}
</div>
) : (
<div class="italic text-base-content/40">Client deleted</div>
@@ -205,7 +219,8 @@ const isDraft = invoice.status === 'draft';
<!-- Items Table -->
<div class="mb-8">
<table class="w-full">
<div class="overflow-x-auto">
<table class="w-full min-w-150">
<thead>
<tr class="border-b-2 border-base-200 text-left text-xs font-bold uppercase tracking-wider text-base-content/40">
<th class="py-3">Description</th>
@@ -242,7 +257,8 @@ const isDraft = invoice.status === 'draft';
</tr>
)}
</tbody>
</table>
</table>
</div>
</div>
<!-- Add Item Form (Only if Draft) -->
@@ -278,9 +294,16 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
</div>
{(invoice.taxRate ?? 0) > 0 && (
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Tax ({invoice.taxRate}%)</span>
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
<div class="flex justify-between text-sm items-center group">
<span class="text-base-content/60 flex items-center gap-2">
Tax ({invoice.taxRate ?? 0}%)
{isDraft && (
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<Icon name="heroicons:pencil" class="w-3 h-3" />
</button>
)}
</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
</div>
)}
@@ -303,10 +326,42 @@ const isDraft = invoice.status === 'draft';
{/* Edit Notes (Draft Only) - Simplistic approach */}
{isDraft && !invoice.notes && (
<div class="mt-8 text-center">
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-ghost">Add Notes</a>
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-primary">Edit Details</a>
</div>
)}
</div>
</div>
</div>
<!-- Tax Modal -->
<dialog id="tax_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Update Tax Rate</h3>
<p class="py-4">Enter the tax percentage to apply to the subtotal.</p>
<form method="POST" action={`/api/invoices/${invoice.id}/update-tax`}>
<div class="form-control mb-6">
<label class="label">
<span class="label-text">Tax Rate (%)</span>
</label>
<input
type="number"
name="taxRate"
step="0.01"
min="0"
max="100"
class="input input-bordered w-full"
value={invoice.taxRate ?? 0}
required
/>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="document.getElementById('tax_modal').close()">Cancel</button>
<button type="submit" class="btn btn-primary">Update</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</DashboardLayout>

View File

@@ -99,7 +99,9 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Due Date</span>
<span class="label-text font-semibold">
{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}
</span>
</label>
<input
type="date"
@@ -128,7 +130,7 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
</div>
<!-- Notes -->
<div class="form-control">
<div class="form-control flex flex-col">
<label class="label">
<span class="label-text font-semibold">Notes / Terms</span>
</label>

View File

@@ -109,7 +109,8 @@ const getStatusColor = (status: string) => {
<div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-0">
<table class="table table-zebra">
<div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0">
<table class="table table-zebra">
<thead>
<tr class="bg-base-200/50">
<th>Number</th>
@@ -162,7 +163,7 @@ const getStatusColor = (status: string) => {
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square">
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
</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 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-100">
<li>
<a href={`/dashboard/invoices/${invoice.id}`}>
<Icon name="heroicons:eye" class="w-4 h-4" />
@@ -210,6 +211,7 @@ const getStatusColor = (status: string) => {
)}
</tbody>
</table>
</div>
</div>
</div>
</DashboardLayout>

View File

@@ -47,7 +47,9 @@ if (lastInvoice) {
const match = lastInvoice.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
const prefix = lastInvoice.number.replace(match[0], '');
let prefix = lastInvoice.number.replace(match[0], '');
// Ensure we don't carry over an EST- prefix to an invoice
if (prefix === 'EST-') prefix = 'INV-';
nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0');
}
}
@@ -68,7 +70,9 @@ if (lastQuote) {
const match = lastQuote.number.match(/(\d+)$/);
if (match) {
const num = parseInt(match[1]) + 1;
const prefix = lastQuote.number.replace(match[0], '');
let prefix = lastQuote.number.replace(match[0], '');
// Ensure we don't carry over an INV- prefix to a quote
if (prefix === 'INV-') prefix = 'EST-';
nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0');
}
}
@@ -167,7 +171,7 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
<!-- Due Date -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Due Date</span>
<span class="label-text font-semibold" id="dueDateLabel">Due Date</span>
</label>
<input
type="date"
@@ -212,22 +216,26 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
// Update number based on document type
const typeRadios = document.querySelectorAll('input[name="type"]');
const numberInput = document.getElementById('documentNumber') as HTMLInputElement | null;
const dueDateLabel = document.getElementById('dueDateLabel');
if (numberInput) {
const invoiceNumber = numberInput.dataset.invoiceNumber || 'INV-001';
const quoteNumber = numberInput.dataset.quoteNumber || 'EST-001';
const invoiceNumber = numberInput?.dataset.invoiceNumber || 'INV-001';
const quoteNumber = numberInput?.dataset.quoteNumber || 'EST-001';
typeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (numberInput) {
if (target.value === 'quote') {
numberInput.value = quoteNumber;
} else if (target.value === 'invoice') {
numberInput.value = invoiceNumber;
}
typeRadios.forEach(radio => {
radio.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
if (numberInput) {
if (target.value === 'quote') {
numberInput.value = quoteNumber;
} else if (target.value === 'invoice') {
numberInput.value = invoiceNumber;
}
});
}
if (dueDateLabel) {
dueDateLabel.textContent = target.value === 'quote' ? 'Valid Until' : 'Due Date';
}
});
}
});
</script>

View File

@@ -318,6 +318,20 @@ function getTimeRangeLabel(range: string) {
</select>
</div>
</form>
<style>
@media (max-width: 767px) {
form {
align-items: stretch !important;
}
.form-control {
width: 100%;
}
}
select {
width: 100%;
}
</style>
</div>
</div>

View File

@@ -38,7 +38,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
---
<DashboardLayout title="Team - Chronus">
<div class="flex justify-between items-center mb-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<h1 class="text-3xl font-bold">Team Members</h1>
<div class="flex gap-2">
{isAdmin && (

View File

@@ -67,9 +67,51 @@ const successType = url.searchParams.get('success');
</div>
)}
<form action="/api/organizations/update-name" method="POST" class="space-y-4">
<form
action="/api/organizations/update-name"
method="POST"
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="organizationId" value={organization.id} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Team Logo</span>
</div>
<div class="flex items-center gap-6">
<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">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
class="w-full h-full object-cover"
/>
) : (
<Icon
name="heroicons:photo"
class="w-8 h-8 opacity-40 text-base-content"
/>
)}
</div>
</div>
<div>
<input
type="file"
name="logo"
accept="image/png, image/jpeg"
class="file-input file-input-bordered w-full max-w-xs"
/>
<div class="text-xs text-base-content/60 mt-2">
Upload a company logo (PNG, JPG).
<br />
Will be displayed on invoices and quotes.
</div>
</div>
</div>
</div>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Team Name</span>
@@ -158,14 +200,12 @@ const successType = url.searchParams.get('success');
</label>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mt-6">
<span class="text-xs text-base-content/60 text-center sm:text-left">
Address information appears on invoices and quotes
</span>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:check" class="w-5 h-5" />
Save Changes
</button>

View File

@@ -164,14 +164,16 @@ const paginationPages = getPaginationPages(page, totalPages);
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
<div 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>
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
<div 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>
<span class="flex-1 text-center sm:text-left">You need to create a category before tracking time.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
</div>
) : (
<Timer
@@ -192,14 +194,16 @@ const paginationPages = getPaginationPages(page, totalPages);
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
<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>
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
) : allCategories.length === 0 ? (
<div class="alert alert-warning">
<span>You need to create a category before adding time entries.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
<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>
<span class="flex-1 text-center sm:text-left">You need to create a category before adding time entries.</span>
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
</div>
) : (
<ManualEntry
@@ -228,7 +232,7 @@ const paginationPages = getPaginationPages(page, totalPages);
type="text"
name="search"
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"
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"
value={searchTerm}
/>
</div>
@@ -237,7 +241,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Client</span>
</label>
<select 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" onchange="this.form.submit()">
<select 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>
{allClients.map(client => (
<option value={client.id} selected={filterClient === client.id}>
@@ -251,7 +255,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Category</span>
</label>
<select name="category" 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" onchange="this.form.submit()">
<select name="category" 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 Categories</option>
{allCategories.map(category => (
<option value={category.id} selected={filterCategory === category.id}>
@@ -265,7 +269,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Status</span>
</label>
<select 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" onchange="this.form.submit()">
<select 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="" selected={filterStatus === ''}>All Entries</option>
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
<option value="running" selected={filterStatus === 'running'}>Running</option>
@@ -276,7 +280,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Entry Type</span>
</label>
<select 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" onchange="this.form.submit()">
<select 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>
@@ -287,7 +291,7 @@ const paginationPages = getPaginationPages(page, totalPages);
<label class="label">
<span class="label-text font-medium">Sort By</span>
</label>
<select 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" onchange="this.form.submit()">
<select 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>

View File

@@ -7,10 +7,10 @@ if (Astro.locals.user) {
---
<Layout title="Chronus - Time Tracking">
<div class="hero h-full bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
<div class="hero flex-1 bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
<div class="hero-content text-center">
<div class="max-w-4xl">
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
<img src="/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
Chronus
</h1>

View File

@@ -1,19 +1,35 @@
---
import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
if (Astro.locals.user) {
return Astro.redirect('/dashboard');
}
const error = Astro.url.searchParams.get('error');
const errorMessage =
error === 'invalid_credentials'
? 'Invalid email or password'
: error === 'missing_fields'
? 'Please fill in all fields'
: null;
---
<Layout title="Login - Chronus">
<div class="flex justify-center items-center min-h-screen bg-base-100">
<div class="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-body">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
{errorMessage && (
<div role="alert" class="alert alert-error mb-4">
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{errorMessage}</span>
</div>
)}
<form action="/api/auth/login" method="POST" class="space-y-4">
<label class="form-control">
<div class="label">

View File

@@ -20,16 +20,33 @@ if (!isFirstUser) {
.get();
registrationDisabled = registrationSetting?.value !== 'true';
}
const error = Astro.url.searchParams.get('error');
const errorMessage =
error === 'user_exists'
? 'An account with this email already exists'
: error === 'missing_fields'
? 'Please fill in all fields'
: error === 'registration_disabled'
? 'Registration is currently disabled'
: null;
---
<Layout title="Sign Up - Chronus">
<div class="flex justify-center items-center min-h-screen bg-base-100">
<div class="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-body">
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
{errorMessage && (
<div role="alert" class="alert alert-error mb-4">
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{errorMessage}</span>
</div>
)}
{registrationDisabled ? (
<>
<div class="alert alert-warning">

View File

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

View File

@@ -1,5 +1,7 @@
import { h } from "vue";
import { Document, Page, Text, View } from "@ceereals/vue-pdf";
import { Document, Page, Text, View, Image } from "@ceereals/vue-pdf";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
import type { Style } from "@react-pdf/types";
interface InvoiceItem {
@@ -13,6 +15,11 @@ interface InvoiceItem {
interface Client {
name: string;
email: string | null;
street: string | null;
city: string | null;
state: string | null;
zip: string | null;
country: string | null;
}
interface Organization {
@@ -22,6 +29,7 @@ interface Organization {
state: string | null;
zip: string | null;
country: string | null;
logoUrl?: string | null;
}
interface Invoice {
@@ -67,6 +75,12 @@ const styles = {
flex: 1,
maxWidth: 280,
} as Style,
logo: {
height: 40,
marginBottom: 8,
objectFit: "contain",
objectPosition: "left",
} as Style,
headerRight: {
flex: 1,
alignItems: "flex-end",
@@ -84,40 +98,7 @@ const styles = {
lineHeight: 1.5,
marginBottom: 12,
} as Style,
statusBadge: {
paddingHorizontal: 12,
paddingVertical: 4,
borderRadius: 6,
fontSize: 9,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
alignSelf: "flex-start",
} as Style,
statusDraft: {
backgroundColor: "#F3F4F6",
color: "#6B7280",
} as Style,
statusSent: {
backgroundColor: "#DBEAFE",
color: "#1E40AF",
} as Style,
statusPaid: {
backgroundColor: "#D1FAE5",
color: "#065F46",
} as Style,
statusAccepted: {
backgroundColor: "#D1FAE5",
color: "#065F46",
} as Style,
statusVoid: {
backgroundColor: "#FEE2E2",
color: "#991B1B",
} as Style,
statusDeclined: {
backgroundColor: "#FEE2E2",
color: "#991B1B",
} as Style,
invoiceTypeContainer: {
alignItems: "flex-end",
marginBottom: 16,
@@ -178,6 +159,11 @@ const styles = {
fontSize: 10,
color: "#6B7280",
} as Style,
clientAddress: {
fontSize: 10,
color: "#6B7280",
lineHeight: 1.5,
} as Style,
table: {
marginBottom: 40,
} as Style,
@@ -304,24 +290,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
});
};
const getStatusStyle = (status: string): Style => {
const baseStyle = styles.statusBadge;
switch (status) {
case "draft":
return { ...baseStyle, ...styles.statusDraft };
case "sent":
return { ...baseStyle, ...styles.statusSent };
case "paid":
case "accepted":
return { ...baseStyle, ...styles.statusPaid };
case "void":
case "declined":
return { ...baseStyle, ...styles.statusVoid };
default:
return { ...baseStyle, ...styles.statusDraft };
}
};
return h(Document, [
h(
Page,
@@ -330,6 +298,55 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
// Header
h(View, { style: styles.header }, [
h(View, { style: styles.headerLeft }, [
(() => {
if (organization.logoUrl) {
try {
let logoPath;
// Handle uploads directory which might be external to public/
if (organization.logoUrl.startsWith("/uploads/")) {
const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR
: import.meta.env.DATA_DIR;
if (!dataDir) {
throw new Error(
"DATA_DIR environment variable is not set",
);
}
const uploadDir = join(dataDir, "uploads");
const filename = organization.logoUrl.replace(
"/uploads/",
"",
);
logoPath = join(uploadDir, filename);
} else {
logoPath = join(
process.cwd(),
"public",
organization.logoUrl,
);
}
if (existsSync(logoPath)) {
const ext = logoPath.split(".").pop()?.toLowerCase();
if (ext === "png" || ext === "jpg" || ext === "jpeg") {
return h(Image, {
src: {
data: readFileSync(logoPath),
format: ext === "png" ? "png" : "jpg",
},
style: styles.logo,
});
}
}
} catch (e) {
// Ignore errors
}
}
return null;
})(),
h(Text, { style: styles.organizationName }, organization.name),
organization.street || organization.city
? h(
@@ -353,9 +370,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
].filter(Boolean),
)
: null,
h(View, { style: getStatusStyle(invoice.status) }, [
h(Text, invoice.status),
]),
]),
h(View, { style: styles.headerRight }, [
h(View, { style: styles.invoiceTypeContainer }, [
@@ -374,14 +388,16 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
formatDate(invoice.issueDate),
),
]),
h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Due Date"),
h(
Text,
{ style: styles.metaValue },
formatDate(invoice.dueDate),
),
]),
invoice.type !== "quote"
? h(View, { style: styles.metaRow }, [
h(Text, { style: styles.metaLabel }, "Due Date"),
h(
Text,
{ style: styles.metaValue },
formatDate(invoice.dueDate),
),
])
: null,
]),
]),
]),
@@ -393,6 +409,28 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
[
h(Text, { style: styles.sectionLabel }, "Bill To"),
h(Text, { style: styles.clientName }, client.name),
client.street ||
client.city ||
client.state ||
client.zip ||
client.country
? h(
View,
{ style: styles.clientAddress },
[
client.street ? h(Text, client.street) : null,
client.city || client.state || client.zip
? h(
Text,
[client.city, client.state, client.zip]
.filter(Boolean)
.join(", "),
)
: null,
client.country ? h(Text, client.country) : null,
].filter(Boolean),
)
: null,
client.email
? h(Text, { style: styles.clientEmail }, client.email)
: null,