Refactored a bunch of shit
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s

This commit is contained in:
2026-02-09 01:49:19 -07:00
parent c39865031a
commit 12d59bb42f
40 changed files with 844 additions and 678 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "chronus",
"type": "module",
"version": "2.3.0",
"version": "2.4.0",
"scripts": {
"dev": "astro dev",
"build": "astro build",
@@ -13,7 +13,7 @@
},
"dependencies": {
"@astrojs/check": "0.9.6",
"@astrojs/node": "10.0.0-beta.0",
"@astrojs/node": "10.0.0-beta.2",
"@astrojs/vue": "6.0.0-beta.0",
"@ceereals/vue-pdf": "^0.2.1",
"@iconify/vue": "^5.0.0",
@@ -21,23 +21,23 @@
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tailwindcss/vite": "^4.1.18",
"astro": "6.0.0-beta.6",
"astro": "6.0.0-beta.9",
"astro-icon": "^1.1.5",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"daisyui": "^5.5.17",
"dotenv": "^17.2.3",
"daisyui": "^5.5.18",
"dotenv": "^17.2.4",
"drizzle-orm": "0.45.1",
"nanoid": "^5.1.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.27",
"vue": "^3.5.28",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@catppuccin/daisyui": "^2.1.1",
"@iconify-json/heroicons": "^1.2.3",
"@react-pdf/types": "^2.9.2",
"drizzle-kit": "0.31.8"
"drizzle-kit": "0.31.9"
}
}

664
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
title: string;
value: string;
description?: string;
icon?: string;
color?: string;
valueClass?: string;
}
const { title, value, description, icon, color = 'text-primary', valueClass } = Astro.props;
---
<div class="stat">
{icon && (
<div class:list={["stat-figure", color]}>
<Icon name={icon} class="w-8 h-8" />
</div>
)}
<div class="stat-title">{title}</div>
<div class:list={["stat-value", color, valueClass]}>{value}</div>
{description && <div class="stat-desc">{description}</div>}
</div>

View File

@@ -25,3 +25,16 @@ export function formatTimeRange(start: Date, end: Date | null): string {
const ms = end.getTime() - start.getTime();
return formatDuration(ms);
}
/**
* Formats a cent-based amount as a currency string.
* @param amount - Amount in cents (e.g. 1500 = $15.00)
* @param currency - ISO 4217 currency code (default: 'USD')
* @returns Formatted currency string like "$15.00"
*/
export function formatCurrency(amount: number, currency: string = "USD"): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
}).format(amount / 100);
}

24
src/lib/getCurrentTeam.ts Normal file
View File

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

View File

@@ -2,6 +2,30 @@ import { db } from "../db";
import { clients, tags as tagsTable } from "../db/schema";
import { eq, and } from "drizzle-orm";
export const MAX_LENGTHS = {
name: 255,
email: 320,
password: 128,
phone: 50,
address: 255, // street, city, state, zip, country
currency: 10,
invoiceNumber: 50,
invoiceNotes: 5000,
itemDescription: 2000,
description: 2000, // time entry description
} as const;
export function exceedsLength(
field: string,
value: string | null | undefined,
maxLength: number,
): string | null {
if (value && value.length > maxLength) {
return `${field} must be ${maxLength} characters or fewer`;
}
return null;
}
export async function validateTimeEntryResources({
organizationId,
clientId,
@@ -60,3 +84,9 @@ export function validateTimeRange(
return { valid: true, startDate, endDate };
}
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function isValidEmail(email: string): boolean {
return EMAIL_REGEX.test(email) && email.length <= 320;
}

View File

@@ -65,7 +65,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
},
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
console.error("Passkey authentication verification failed:", error);
return new Response(JSON.stringify({ error: "Verification failed" }), {
status: 400,
});
}

View File

@@ -2,8 +2,13 @@ import type { APIRoute } from "astro";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeyChallenges } from "../../../../../db/schema";
import { lte } from "drizzle-orm";
export const GET: APIRoute = async ({ request }) => {
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const options = await generateAuthenticationOptions({
rpID: new URL(request.url).hostname,
userVerification: "preferred",

View File

@@ -48,7 +48,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
expectedRPID: new URL(request.url).hostname,
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
console.error("Passkey registration verification failed:", error);
return new Response(JSON.stringify({ error: "Verification failed" }), {
status: 400,
});
}

View File

@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { db } from "../../../../../db";
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
import { eq } from "drizzle-orm";
import { eq, lte } from "drizzle-orm";
export const GET: APIRoute = async ({ request, locals }) => {
const user = locals.user;
@@ -13,6 +13,10 @@ export const GET: APIRoute = async ({ request, locals }) => {
});
}
await db
.delete(passkeyChallenges)
.where(lte(passkeyChallenges.expiresAt, new Date()));
const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id),
});

View File

@@ -7,6 +7,7 @@ import {
siteSettings,
} from "../../../db/schema";
import { hashPassword, createSession } from "../../../lib/auth";
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation";
import { eq, count, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
@@ -37,6 +38,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
return redirect("/signup?error=missing_fields");
}
if (!isValidEmail(email)) {
return redirect("/signup?error=invalid_email");
}
if (name.length > MAX_LENGTHS.name) {
return redirect("/signup?error=name_too_long");
}
if (password.length > MAX_LENGTHS.password) {
return redirect("/signup?error=password_too_long");
}
if (password.length < 8) {
return redirect("/signup?error=password_too_short");
}
@@ -47,7 +60,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
.where(eq(users.email, email))
.get();
if (existingUser) {
return redirect("/signup?error=user_exists");
return redirect("/login?registered=true");
}
const passwordHash = await hashPassword(password);

View File

@@ -52,6 +52,17 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can delete clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can delete clients", { status: 403 });
}
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
await db.delete(clients).where(eq(clients.id, id)).run();

View File

@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { clients, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
const user = locals.user;
@@ -49,6 +50,25 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Client name is required", { status: 400 });
}
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address);
if (lengthError) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: lengthError }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(lengthError, { status: 400 });
}
try {
const client = await db
.select()
@@ -87,6 +107,17 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Not authorized", { status: 403 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can update clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can update clients", { status: 403 });
}
await db
.update(clients)
.set({

View File

@@ -3,6 +3,7 @@ import { db } from "../../../db";
import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
@@ -45,6 +46,25 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("Name is required", { status: 400 });
}
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Email", email, MAX_LENGTHS.email) ||
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address);
if (lengthError) {
if (locals.scopes) {
return new Response(JSON.stringify({ error: lengthError }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(lengthError, { status: 400 });
}
const userOrg = await db
.select()
.from(members)
@@ -55,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response("No organization found", { status: 400 });
}
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
if (!isAdminOrOwner) {
if (locals.scopes) {
return new Response(
JSON.stringify({ error: "Only owners and admins can create clients" }),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
return new Response("Only owners and admins can create clients", { status: 403 });
}
const id = nanoid();
await db.insert(clients).values({

View File

@@ -45,6 +45,11 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
return new Response("Unauthorized", { status: 401 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
return new Response("Only owners and admins can convert quotes", { status: 403 });
}
try {
const lastInvoice = await db
.select()

View File

@@ -107,7 +107,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response(buffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`,
"Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.pdf"`,
},
});
} catch (error) {

View File

@@ -222,18 +222,18 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return redirect(`/dashboard/invoices/${id}?error=no-entries`);
}
// Transaction-like operations
try {
await db.insert(invoiceItems).values(newItems);
await db.transaction(async (tx) => {
await tx.insert(invoiceItems).values(newItems);
if (entryIdsToUpdate.length > 0) {
await db
await tx
.update(timeEntries)
.set({ invoiceId: invoice.id })
.where(inArray(timeEntries.id, entryIdsToUpdate));
}
const allItems = await db
const allItems = await tx
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, invoice.id));
@@ -258,7 +258,7 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
);
const total = subtotal - discountAmount + taxAmount;
await db
await tx
.update(invoices)
.set({
subtotal,
@@ -267,6 +267,7 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
total,
})
.where(eq(invoices.id, invoice.id));
});
return redirect(`/dashboard/invoices/${id}?success=imported`);
} catch (error) {

View File

@@ -3,6 +3,7 @@ import { db } from "../../../../../db";
import { invoiceItems, invoices, members } from "../../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
import { MAX_LENGTHS, exceedsLength } from "../../../../../lib/validation";
export const POST: APIRoute = async ({
request,
@@ -61,6 +62,11 @@ export const POST: APIRoute = async ({
return new Response("Missing required fields", { status: 400 });
}
const lengthError = exceedsLength("Description", description, MAX_LENGTHS.itemDescription);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr);

View File

@@ -60,6 +60,13 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 });
}
// Destructive status changes require owner/admin
const destructiveStatuses = ["void"];
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (destructiveStatuses.includes(status) && !isAdminOrOwner) {
return new Response("Only owners and admins can void invoices", { status: 403 });
}
try {
await db
.update(invoices)

View File

@@ -3,6 +3,7 @@ import { db } from "../../../../db";
import { invoices, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
const user = locals.user;
@@ -56,6 +57,14 @@ export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
return new Response("Missing required fields", { status: 400 });
}
const lengthError =
exceedsLength("Invoice number", number, MAX_LENGTHS.invoiceNumber) ||
exceedsLength("Currency", currency, MAX_LENGTHS.currency) ||
exceedsLength("Notes", notes, MAX_LENGTHS.invoiceNotes);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
try {
const issueDate = new Date(issueDateStr);
const dueDate = new Date(dueDateStr);

View File

@@ -43,6 +43,11 @@ export const POST: APIRoute = async ({ request, redirect, locals }) => {
return new Response("Unauthorized", { status: 401 });
}
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
if (!isAdminOrOwner) {
return new Response("Only owners and admins can delete invoices", { status: 403 });
}
try {
// Delete invoice items first (manual cascade)
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));

View File

@@ -4,6 +4,7 @@ import path from "path";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { eq, and } from "drizzle-orm";
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
@@ -29,6 +30,18 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
});
}
const lengthError =
exceedsLength("Name", name, MAX_LENGTHS.name) ||
exceedsLength("Street", street, MAX_LENGTHS.address) ||
exceedsLength("City", city, MAX_LENGTHS.address) ||
exceedsLength("State", state, MAX_LENGTHS.address) ||
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
exceedsLength("Country", country, MAX_LENGTHS.address) ||
exceedsLength("Currency", defaultCurrency, MAX_LENGTHS.currency);
if (lengthError) {
return new Response(lengthError, { status: 400 });
}
try {
// Verify user is admin/owner of this organization
const membership = await db
@@ -67,7 +80,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
);
}
const ext = logo.name.split(".").pop() || "png";
const rawExt = (logo.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z]/g, "");
const allowedExtensions = ["png", "jpg", "jpeg"];
const ext = allowedExtensions.includes(rawExt) ? rawExt : "png";
const filename = `${organizationId}-${Date.now()}.${ext}`;
const dataDir = process.env.DATA_DIR
? process.env.DATA_DIR

View File

@@ -128,6 +128,13 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
"Tag",
"Description",
];
const sanitizeCell = (value: string): string => {
if (/^[=+\-@\t\r]/.test(value)) {
return `\t${value}`;
}
return value;
};
const rows = entries.map((e) => {
const start = e.entry.startTime;
const end = e.entry.endTime;
@@ -144,10 +151,10 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
start.toLocaleTimeString(),
end ? end.toLocaleTimeString() : "",
end ? duration.toFixed(2) : "Running",
`"${(e.user.name || "").replace(/"/g, '""')}"`,
`"${(e.client.name || "").replace(/"/g, '""')}"`,
`"${tagsStr.replace(/"/g, '""')}"`,
`"${(e.entry.description || "").replace(/"/g, '""')}"`,
`"${sanitizeCell((e.user.name || "").replace(/"/g, '""'))}"`,
`"${sanitizeCell((e.client.name || "").replace(/"/g, '""'))}"`,
`"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
`"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
].join(",");
});

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { db } from "../../../db";
import { timeEntries, members } from "../../../db/schema";
import { eq, and, isNull } from "drizzle-orm";
import { nanoid } from "nanoid";
import { validateTimeEntryResources } from "../../../lib/validation";
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response("Unauthorized", { status: 401 });
@@ -17,6 +17,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
return new Response("Client is required", { status: 400 });
}
if (description && description.length > MAX_LENGTHS.description) {
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
}
const runningEntry = await db
.select()
.from(timeEntries)

View File

@@ -1,10 +1,11 @@
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { users, sessions } from "../../../db/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { MAX_LENGTHS } from "../../../lib/validation";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
const user = locals.user;
const contentType = request.headers.get("content-type");
const isJson = contentType?.includes("application/json");
@@ -53,6 +54,13 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
return new Response(msg, { status: 400 });
}
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
if (isJson)
return new Response(JSON.stringify({ error: msg }), { status: 400 });
return new Response(msg, { status: 400 });
}
try {
// Get current user from database
const dbUser = await db
@@ -90,6 +98,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
.where(eq(users.id, user.id))
.run();
// Invalidate all sessions, then re-create one for the current user
const currentSessionId = cookies.get("session_id")?.value;
if (currentSessionId) {
await db
.delete(sessions)
.where(
eq(sessions.userId, user.id),
)
.run();
const { createSession } = await import("../../../lib/auth");
const { sessionId, expiresAt } = await createSession(user.id);
cookies.set("session_id", sessionId, {
path: "/",
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: "lax",
expires: expiresAt,
});
} else {
await db
.delete(sessions)
.where(eq(sessions.userId, user.id))
.run();
}
if (isJson) {
return new Response(JSON.stringify({ success: true }), { status: 200 });
}

View File

@@ -1,26 +1,15 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { db } from '../../db';
import { clients, members } from '../../db/schema';
import { eq, and } from 'drizzle-orm';
import { clients } from '../../db/schema';
import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const organizationId = userMembership.organizationId;

View File

@@ -2,8 +2,9 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { clients, members } from '../../../../db/schema';
import { clients } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const client = await db.select()
.from(clients)

View File

@@ -2,9 +2,11 @@
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../../db';
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
import { clients, timeEntries, tags, users } from '../../../../db/schema';
import { eq, and, desc, sql } from 'drizzle-orm';
import { formatTimeRange } from '../../../../lib/formatTime';
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
import StatCard from '../../../../components/StatCard.astro';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
const { id } = Astro.params;
if (!id) return Astro.redirect('/dashboard/clients');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const client = await db.select()
.from(clients)
@@ -132,23 +122,20 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
<div class="divider"></div>
<div class="stats shadow w-full">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">Total Time Tracked</div>
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
<div class="stat-desc">Across all projects</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
</div>
<div class="stat-title">Total Entries</div>
<div class="stat-value text-secondary">{totalEntriesCount}</div>
<div class="stat-desc">Recorded entries</div>
</div>
<StatCard
title="Total Time Tracked"
value={`${totalHours}h ${totalMinutes}m`}
description="Across all projects"
icon="heroicons:clock"
color="text-primary"
/>
<StatCard
title="Total Entries"
value={String(totalEntriesCount)}
description="Recorded entries"
icon="heroicons:list-bullet"
color="text-secondary"
/>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import StatCard from '../../components/StatCard.astro';
import { db } from '../../db';
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
@@ -134,41 +135,38 @@ const hasMembership = userOrgs.length > 0;
<>
<!-- Stats Overview -->
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">This Week</div>
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:calendar" class="w-8 h-8" />
</div>
<div class="stat-title">This Month</div>
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
<div class="stat-desc">Total tracked time</div>
</div>
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:play-circle" class="w-8 h-8" />
</div>
<div class="stat-title">Active Timers</div>
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
<div class="stat-desc">Currently running</div>
</div>
<div class="stat">
<div class="stat-figure text-info">
<Icon name="heroicons:building-office" class="w-8 h-8" />
</div>
<div class="stat-title">Clients</div>
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
<div class="stat-desc">Total active</div>
</div>
<StatCard
title="This Week"
value={formatDuration(stats.totalTimeThisWeek)}
description="Total tracked time"
icon="heroicons:clock"
color="text-primary"
valueClass="text-3xl"
/>
<StatCard
title="This Month"
value={formatDuration(stats.totalTimeThisMonth)}
description="Total tracked time"
icon="heroicons:calendar"
color="text-secondary"
valueClass="text-3xl"
/>
<StatCard
title="Active Timers"
value={String(stats.activeTimers)}
description="Currently running"
icon="heroicons:play-circle"
color="text-accent"
valueClass="text-3xl"
/>
<StatCard
title="Clients"
value={String(stats.totalClients)}
description="Total active"
icon="heroicons:building-office"
color="text-info"
valueClass="text-3xl"
/>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
import { eq, and } from 'drizzle-orm';
import { formatCurrency } from '../../../lib/formatTime';
const { id } = Astro.params;
const user = Astro.locals.user;
@@ -49,13 +50,6 @@ const items = await db.select()
.where(eq(invoiceItems.invoiceId, invoice.id))
.all();
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: invoice.currency,
}).format(amount / 100);
};
const isDraft = invoice.status === 'draft';
---
@@ -235,8 +229,8 @@ const isDraft = invoice.status === 'draft';
<tr>
<td class="py-4">{item.description}</td>
<td class="py-4 text-right">{item.quantity}</td>
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
<td class="py-4 text-right">{formatCurrency(item.unitPrice, invoice.currency)}</td>
<td class="py-4 text-right font-medium">{formatCurrency(item.amount, invoice.currency)}</td>
{isDraft && (
<td class="py-4 text-right">
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
@@ -299,7 +293,7 @@ const isDraft = invoice.status === 'draft';
<div class="w-64 space-y-3">
<div class="flex justify-between text-sm">
<span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
<span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
{(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm">
@@ -307,7 +301,7 @@ const isDraft = invoice.status === 'draft';
Discount
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
</div>
)}
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
@@ -320,13 +314,13 @@ const isDraft = invoice.status === 'draft';
</button>
)}
</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
</div>
)}
<div class="divider my-2"></div>
<div class="flex justify-between text-lg font-bold">
<span>Total</span>
<span class="text-primary">{formatCurrency(invoice.total)}</span>
<span class="text-primary">{formatCurrency(invoice.total, invoice.currency)}</span>
</div>
</div>
</div>

View File

@@ -1,27 +1,18 @@
---
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import StatCard from '../../../components/StatCard.astro';
import { db } from '../../../db';
import { invoices, clients, members } from '../../../db/schema';
import { invoices, clients } from '../../../db/schema';
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
import { formatCurrency } from '../../../lib/formatTime';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const currentTeamIdResolved = userMembership.organizationId;
@@ -96,13 +87,6 @@ const yearInvoices = allInvoicesRaw.filter(i => {
return issueDate >= yearStart && issueDate <= yearEnd;
});
const formatCurrency = (amount: number, currency: string) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
};
const getStatusColor = (status: string) => {
switch (status) {
case 'paid': return 'badge-success';
@@ -130,40 +114,35 @@ const getStatusColor = (status: string) => {
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:document-text" class="w-8 h-8" />
</div>
<div class="stat-title">Total Invoices</div>
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div>
</div>
<StatCard
title="Total Invoices"
value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
description={selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}
icon="heroicons:document-text"
color="text-primary"
/>
</div>
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
</div>
<div class="stat-title">Open Quotes</div>
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
<div class="stat-desc">Waiting for approval</div>
</div>
<StatCard
title="Open Quotes"
value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
description="Waiting for approval"
icon="heroicons:clipboard-document-list"
color="text-secondary"
/>
</div>
<div class="stats shadow bg-base-100 border border-base-200">
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
</div>
<div class="stat-title">Total Revenue</div>
<div class="stat-value text-success">
{formatCurrency(yearInvoices
<StatCard
title="Total Revenue"
value={formatCurrency(yearInvoices
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
</div>
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
</div>
description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
icon="heroicons:currency-dollar"
color="text-success"
/>
</div>
</div>

View File

@@ -2,26 +2,15 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { clients, members, invoices, organizations } from '../../../db/schema';
import { clients, invoices, organizations } from '../../../db/schema';
import { eq, desc, and } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const currentTeamIdResolved = userMembership.organizationId;

View File

@@ -1,31 +1,21 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import StatCard from '../../components/StatCard.astro';
import TagChart from '../../components/TagChart.vue';
import ClientChart from '../../components/ClientChart.vue';
import MemberChart from '../../components/MemberChart.vue';
import { db } from '../../db';
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const teamMembers = await db.select({
id: users.id,
@@ -247,13 +237,6 @@ const revenueByClient = allClients.map(client => {
};
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
function formatCurrency(amount: number, currency: string = 'USD') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount / 100);
}
function getTimeRangeLabel(range: string) {
switch (range) {
case 'today': return 'Today';
@@ -383,46 +366,44 @@ function getTimeRangeLabel(range: string) {
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-primary">
<Icon name="heroicons:clock" class="w-8 h-8" />
</div>
<div class="stat-title">Total Time</div>
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
</div>
<StatCard
title="Total Time"
value={formatDuration(totalTime)}
description={getTimeRangeLabel(timeRange)}
icon="heroicons:clock"
color="text-primary"
/>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-secondary">
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
</div>
<div class="stat-title">Total Entries</div>
<div class="stat-value text-secondary">{entries.length}</div>
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
</div>
<StatCard
title="Total Entries"
value={String(entries.length)}
description={getTimeRangeLabel(timeRange)}
icon="heroicons:list-bullet"
color="text-secondary"
/>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-success">
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
</div>
<div class="stat-title">Revenue</div>
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
</div>
<StatCard
title="Revenue"
value={formatCurrency(revenueStats.total)}
description={`${invoiceStats.paid} paid invoices`}
icon="heroicons:currency-dollar"
color="text-success"
/>
</div>
<div class="stats shadow border border-base-300">
<div class="stat">
<div class="stat-figure text-accent">
<Icon name="heroicons:user-group" class="w-8 h-8" />
<StatCard
title="Active Members"
value={String(statsByMember.filter(s => s.entryCount > 0).length)}
description={`of ${teamMembers.length} total`}
icon="heroicons:user-group"
color="text-accent"
/>
</div>
<div class="stat-title">Active Members</div>
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
<div class="stat-desc">of {teamMembers.length} total</div>
</div>
</div>
</div>

View File

@@ -50,10 +50,10 @@ const userPasskeys = await db.select()
)}
<!-- Profile Information -->
<ProfileForm client:load user={user} />
<ProfileForm client:idle user={user} />
<!-- Change Password -->
<PasswordForm client:load />
<PasswordForm client:idle />
<!-- Passkeys -->
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({

View File

@@ -5,24 +5,13 @@ import { Icon } from 'astro-icon/components';
import { db } from '../../db';
import { members, users } from '../../db/schema';
import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const teamMembers = await db.select({
member: members,

View File

@@ -2,26 +2,15 @@
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
import { Icon } from 'astro-icon/components';
import { db } from '../../../db';
import { members, organizations, tags } from '../../../db/schema';
import { organizations, tags } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
if (!isAdmin) return Astro.redirect('/dashboard/team');

View File

@@ -4,27 +4,16 @@ import { Icon } from 'astro-icon/components';
import Timer from '../../components/Timer.vue';
import ManualEntry from '../../components/ManualEntry.vue';
import { db } from '../../db';
import { timeEntries, clients, members, tags, users } from '../../db/schema';
import { timeEntries, clients, tags, users } from '../../db/schema';
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
import { formatTimeRange } from '../../lib/formatTime';
import { getCurrentTeam } from '../../lib/getCurrentTeam';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
// Get current team from cookie
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
const userMemberships = await db.select()
.from(members)
.where(eq(members.userId, user.id))
.all();
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
// Use current team or fallback to first membership
const userMembership = currentTeamId
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
: userMemberships[0];
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
if (!userMembership) return Astro.redirect('/dashboard');
const organizationId = userMembership.organizationId;
@@ -153,12 +142,12 @@ const paginationPages = getPaginationPages(page, totalPages);
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
<!-- Tabs for Timer and Manual Entry -->
<div role="tablist" class="tabs tabs-lifted mb-6">
<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">
<div class="tabs tabs-lift mb-6">
<input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
<div class="tab-content bg-base-100 border-base-300 p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>
@@ -177,11 +166,11 @@ const paginationPages = getPaginationPages(page, totalPages);
)}
</div>
<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">
<input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
<div class="tab-content bg-base-100 border-base-300 p-6">
{allClients.length === 0 ? (
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<Icon name="heroicons:exclamation-triangle" class="stroke-current shrink-0 h-6 w-6" />
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
</div>

View File

@@ -52,10 +52,8 @@ export const GET: APIRoute = async ({ params }) => {
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
// SVG excluded to prevent stored XSS
// WebP omitted — not supported in PDF generation
}
return new Response(fileContent, {