21 Commits

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

@@ -0,0 +1,16 @@
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
CREATE INDEX `categories_organization_id_idx` ON `categories` (`organization_id`);--> statement-breakpoint
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
CREATE INDEX `invoices_organization_id_idx` ON `invoices` (`organization_id`);--> statement-breakpoint
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_user_id_idx` ON `time_entries` (`user_id`);--> statement-breakpoint
CREATE INDEX `time_entries_organization_id_idx` ON `time_entries` (`organization_id`);--> statement-breakpoint
CREATE INDEX `time_entries_client_id_idx` ON `time_entries` (`client_id`);--> statement-breakpoint
CREATE INDEX `time_entries_start_time_idx` ON `time_entries` (`start_time`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_time_entry_id_idx` ON `time_entry_tags` (`time_entry_id`);--> statement-breakpoint
CREATE INDEX `time_entry_tags_tag_id_idx` ON `time_entry_tags` (`tag_id`);

View File

@@ -0,0 +1,3 @@
ALTER TABLE `invoices` ADD `discount_value` real DEFAULT 0;--> statement-breakpoint
ALTER TABLE `invoices` ADD `discount_type` text DEFAULT 'percentage';--> statement-breakpoint
ALTER TABLE `invoices` ADD `discount_amount` integer DEFAULT 0;

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,29 @@
{
"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
},
{
"idx": 2,
"version": "6",
"when": 1768773436601,
"tag": "0002_chilly_cyclops",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1768842088321,
"tag": "0003_amusing_wendigo",
"breakpoints": true
}
]

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "1.2.0",
"version": "2.1.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -16,25 +16,26 @@
"@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",
"dotenv": "^17.2.3",
"drizzle-orm": "0.45.1",
"nanoid": "^5.1.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.26",
"vue": "^3.5.27",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@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"
}
}

778
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

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),
}),
);
@@ -244,6 +277,9 @@ export const invoices = sqliteTable(
notes: text("notes"),
currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().default(0), // in cents
discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
discountAmount: integer("discount_amount").default(0), // in cents
taxRate: real("tax_rate").default(0), // percentage
taxAmount: integer("tax_amount").notNull().default(0), // in cents
total: integer("total").notNull().default(0), // in cents
@@ -260,6 +296,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 +320,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

@@ -4,12 +4,7 @@ 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,
}) => {
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
const user = locals.user;
if (!user) {
return redirect("/login");
@@ -38,8 +33,8 @@ export const POST: APIRoute = async ({
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, invoice.organizationId)
)
eq(members.organizationId, invoice.organizationId),
),
)
.get();
@@ -53,6 +48,8 @@ export const POST: APIRoute = async ({
const issueDateStr = formData.get("issueDate") as string;
const dueDateStr = formData.get("dueDate") as string;
const taxRateStr = formData.get("taxRate") as string;
const discountType = (formData.get("discountType") as string) || "percentage";
const discountValueStr = formData.get("discountValue") as string;
const notes = formData.get("notes") as string;
if (!number || !currency || !issueDateStr || !dueDateStr) {
@@ -64,6 +61,11 @@ export const POST: APIRoute = async ({
const dueDate = new Date(dueDateStr);
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
let discountValue = discountValueStr ? parseFloat(discountValueStr) : 0;
if (discountType === "fixed") {
discountValue = Math.round(discountValue * 100);
}
await db
.update(invoices)
.set({
@@ -72,6 +74,8 @@ export const POST: APIRoute = async ({
issueDate,
dueDate,
taxRate,
discountType: discountType as "percentage" | "fixed",
discountValue,
notes: notes || null,
})
.where(eq(invoices.id, invoiceId));

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,25 @@ 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 && (
{(invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Tax ({invoice.taxRate}%)</span>
<span class="text-base-content/60">
Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
</div>
)}
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
<div class="flex justify-between text-sm items-center group">
<span class="text-base-content/60 flex items-center gap-2">
Tax ({invoice.taxRate ?? 0}%)
{isDraft && (
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
<Icon name="heroicons:pencil" class="w-3 h-3" />
</button>
)}
</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
</div>
)}
@@ -303,10 +335,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

@@ -38,6 +38,10 @@ if (!membership) {
// Format dates for input[type="date"]
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
const discountValueDisplay = invoice.discountType === 'fixed'
? (invoice.discountValue || 0) / 100
: (invoice.discountValue || 0);
---
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
@@ -99,7 +103,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"
@@ -110,6 +116,27 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
/>
</div>
<!-- Discount -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Discount</span>
</label>
<div class="join w-full">
<select name="discountType" class="select select-bordered join-item">
<option value="percentage" selected={!invoice.discountType || invoice.discountType === 'percentage'}>%</option>
<option value="fixed" selected={invoice.discountType === 'fixed'}>Fixed</option>
</select>
<input
type="number"
name="discountValue"
step="0.01"
min="0"
class="input input-bordered join-item w-full"
value={discountValueDisplay}
/>
</div>
</div>
<!-- Tax Rate -->
<div class="form-control">
<label class="label">
@@ -128,7 +155,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 {
@@ -32,6 +40,9 @@ interface Invoice {
dueDate: Date;
currency: string;
subtotal: number;
discountValue: number | null;
discountType: string | null;
discountAmount: number | null;
taxRate: number | null;
taxAmount: number;
total: number;
@@ -67,6 +78,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 +101,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 +162,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 +293,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 +301,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 +373,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 +391,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 +412,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,
@@ -465,6 +506,24 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
formatCurrency(invoice.subtotal),
),
]),
(invoice.discountAmount ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(
Text,
{ style: styles.totalLabel },
`Discount${
invoice.discountType === "percentage"
? ` (${invoice.discountValue}%)`
: ""
}`,
),
h(
Text,
{ style: styles.totalValue },
`-${formatCurrency(invoice.discountAmount ?? 0)}`,
),
])
: null,
(invoice.taxRate ?? 0) > 0
? h(View, { style: styles.totalRow }, [
h(

View File

@@ -27,16 +27,34 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
// Note: amounts are in cents
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(subtotal * (taxRate / 100));
// Calculate discount
const discountType = invoice.discountType || "percentage";
const discountValue = invoice.discountValue || 0;
let discountAmount = 0;
const total = subtotal + taxAmount;
if (discountType === "percentage") {
discountAmount = Math.round(subtotal * (discountValue / 100));
} else {
// Fixed amount is assumed to be in cents
discountAmount = Math.round(discountValue);
}
// Ensure discount doesn't exceed subtotal
discountAmount = Math.max(0, Math.min(discountAmount, subtotal));
const taxableAmount = subtotal - discountAmount;
const taxRate = invoice.taxRate || 0;
const taxAmount = Math.round(taxableAmount * (taxRate / 100));
const total = taxableAmount + taxAmount;
// Update invoice
await db
.update(invoices)
.set({
subtotal,
discountAmount,
taxAmount,
total,
})