Refactored a bunch of shit
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 3m57s
This commit is contained in:
14
package.json
14
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronus",
|
"name": "chronus",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "0.9.6",
|
"@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",
|
"@astrojs/vue": "6.0.0-beta.0",
|
||||||
"@ceereals/vue-pdf": "^0.2.1",
|
"@ceereals/vue-pdf": "^0.2.1",
|
||||||
"@iconify/vue": "^5.0.0",
|
"@iconify/vue": "^5.0.0",
|
||||||
@@ -21,23 +21,23 @@
|
|||||||
"@simplewebauthn/browser": "^13.2.2",
|
"@simplewebauthn/browser": "^13.2.2",
|
||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "6.0.0-beta.6",
|
"astro": "6.0.0-beta.9",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"daisyui": "^5.5.17",
|
"daisyui": "^5.5.18",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.4",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.28",
|
||||||
"vue-chartjs": "^5.3.3"
|
"vue-chartjs": "^5.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@catppuccin/daisyui": "^2.1.1",
|
"@catppuccin/daisyui": "^2.1.1",
|
||||||
"@iconify-json/heroicons": "^1.2.3",
|
"@iconify-json/heroicons": "^1.2.3",
|
||||||
"@react-pdf/types": "^2.9.2",
|
"@react-pdf/types": "^2.9.2",
|
||||||
"drizzle-kit": "0.31.8"
|
"drizzle-kit": "0.31.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
664
pnpm-lock.yaml
generated
664
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
25
src/components/StatCard.astro
Normal file
25
src/components/StatCard.astro
Normal 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>
|
||||||
@@ -25,3 +25,16 @@ export function formatTimeRange(start: Date, end: Date | null): string {
|
|||||||
const ms = end.getTime() - start.getTime();
|
const ms = end.getTime() - start.getTime();
|
||||||
return formatDuration(ms);
|
return formatDuration(ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a cent-based amount as a currency string.
|
||||||
|
* @param amount - Amount in cents (e.g. 1500 = $15.00)
|
||||||
|
* @param currency - ISO 4217 currency code (default: 'USD')
|
||||||
|
* @returns Formatted currency string like "$15.00"
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency: string = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount / 100);
|
||||||
|
}
|
||||||
|
|||||||
24
src/lib/getCurrentTeam.ts
Normal file
24
src/lib/getCurrentTeam.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { db } from '../db';
|
||||||
|
import { members } from '../db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type User = { id: string; [key: string]: any };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current team membership for a user based on the currentTeamId cookie.
|
||||||
|
* Returns the membership row, or null if the user has no memberships.
|
||||||
|
*/
|
||||||
|
export async function getCurrentTeam(user: User, currentTeamId?: string | null) {
|
||||||
|
const userMemberships = await db.select()
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.userId, user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (userMemberships.length === 0) return null;
|
||||||
|
|
||||||
|
const membership = currentTeamId
|
||||||
|
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||||
|
: userMemberships[0];
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
}
|
||||||
@@ -2,6 +2,30 @@ import { db } from "../db";
|
|||||||
import { clients, tags as tagsTable } from "../db/schema";
|
import { clients, tags as tagsTable } from "../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const MAX_LENGTHS = {
|
||||||
|
name: 255,
|
||||||
|
email: 320,
|
||||||
|
password: 128,
|
||||||
|
phone: 50,
|
||||||
|
address: 255, // street, city, state, zip, country
|
||||||
|
currency: 10,
|
||||||
|
invoiceNumber: 50,
|
||||||
|
invoiceNotes: 5000,
|
||||||
|
itemDescription: 2000,
|
||||||
|
description: 2000, // time entry description
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function exceedsLength(
|
||||||
|
field: string,
|
||||||
|
value: string | null | undefined,
|
||||||
|
maxLength: number,
|
||||||
|
): string | null {
|
||||||
|
if (value && value.length > maxLength) {
|
||||||
|
return `${field} must be ${maxLength} characters or fewer`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateTimeEntryResources({
|
export async function validateTimeEntryResources({
|
||||||
organizationId,
|
organizationId,
|
||||||
clientId,
|
clientId,
|
||||||
@@ -60,3 +84,9 @@ export function validateTimeRange(
|
|||||||
|
|
||||||
return { valid: true, startDate, endDate };
|
return { valid: true, startDate, endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
return EMAIL_REGEX.test(email) && email.length <= 320;
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,7 +65,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
console.error("Passkey authentication verification failed:", error);
|
||||||
|
return new Response(JSON.stringify({ error: "Verification failed" }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import type { APIRoute } from "astro";
|
|||||||
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
import { generateAuthenticationOptions } from "@simplewebauthn/server";
|
||||||
import { db } from "../../../../../db";
|
import { db } from "../../../../../db";
|
||||||
import { passkeyChallenges } from "../../../../../db/schema";
|
import { passkeyChallenges } from "../../../../../db/schema";
|
||||||
|
import { lte } from "drizzle-orm";
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export const GET: APIRoute = async ({ request }) => {
|
||||||
|
await db
|
||||||
|
.delete(passkeyChallenges)
|
||||||
|
.where(lte(passkeyChallenges.expiresAt, new Date()));
|
||||||
|
|
||||||
const options = await generateAuthenticationOptions({
|
const options = await generateAuthenticationOptions({
|
||||||
rpID: new URL(request.url).hostname,
|
rpID: new URL(request.url).hostname,
|
||||||
userVerification: "preferred",
|
userVerification: "preferred",
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
expectedRPID: new URL(request.url).hostname,
|
expectedRPID: new URL(request.url).hostname,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
console.error("Passkey registration verification failed:", error);
|
||||||
|
return new Response(JSON.stringify({ error: "Verification failed" }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
|
|||||||
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
import { generateRegistrationOptions } from "@simplewebauthn/server";
|
||||||
import { db } from "../../../../../db";
|
import { db } from "../../../../../db";
|
||||||
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
import { passkeys, passkeyChallenges } from "../../../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, lte } from "drizzle-orm";
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request, locals }) => {
|
export const GET: APIRoute = async ({ request, locals }) => {
|
||||||
const user = locals.user;
|
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({
|
const userPasskeys = await db.query.passkeys.findMany({
|
||||||
where: eq(passkeys.userId, user.id),
|
where: eq(passkeys.userId, user.id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
siteSettings,
|
siteSettings,
|
||||||
} from "../../../db/schema";
|
} from "../../../db/schema";
|
||||||
import { hashPassword, createSession } from "../../../lib/auth";
|
import { hashPassword, createSession } from "../../../lib/auth";
|
||||||
|
import { isValidEmail, MAX_LENGTHS } from "../../../lib/validation";
|
||||||
import { eq, count, sql } from "drizzle-orm";
|
import { eq, count, sql } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -37,6 +38,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
return redirect("/signup?error=missing_fields");
|
return redirect("/signup?error=missing_fields");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return redirect("/signup?error=invalid_email");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.length > MAX_LENGTHS.name) {
|
||||||
|
return redirect("/signup?error=name_too_long");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length > MAX_LENGTHS.password) {
|
||||||
|
return redirect("/signup?error=password_too_long");
|
||||||
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (password.length < 8) {
|
||||||
return redirect("/signup?error=password_too_short");
|
return redirect("/signup?error=password_too_short");
|
||||||
}
|
}
|
||||||
@@ -47,7 +60,7 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
|||||||
.where(eq(users.email, email))
|
.where(eq(users.email, email))
|
||||||
.get();
|
.get();
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return redirect("/signup?error=user_exists");
|
return redirect("/login?registered=true");
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
|||||||
return new Response("Not authorized", { status: 403 });
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Only owners and admins can delete clients" }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("Only owners and admins can delete clients", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||||
|
|
||||||
await db.delete(clients).where(eq(clients.id, id)).run();
|
await db.delete(clients).where(eq(clients.id, id)).run();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from "astro";
|
|||||||
import { db } from "../../../../db";
|
import { db } from "../../../../db";
|
||||||
import { clients, members } from "../../../../db/schema";
|
import { clients, members } from "../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -49,6 +50,25 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
return new Response("Client name is required", { status: 400 });
|
return new Response("Client name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||||
|
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||||
|
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||||
|
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||||
|
if (lengthError) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: lengthError }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = await db
|
const client = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -87,6 +107,17 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
return new Response("Not authorized", { status: 403 });
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Only owners and admins can update clients" }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("Only owners and admins can update clients", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../../../db";
|
|||||||
import { clients, members } from "../../../db/schema";
|
import { clients, members } from "../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -45,6 +46,25 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response("Name is required", { status: 400 });
|
return new Response("Name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||||
|
exceedsLength("Email", email, MAX_LENGTHS.email) ||
|
||||||
|
exceedsLength("Phone", phone, MAX_LENGTHS.phone) ||
|
||||||
|
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Country", country, MAX_LENGTHS.address);
|
||||||
|
if (lengthError) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: lengthError }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const userOrg = await db
|
const userOrg = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -55,6 +75,17 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response("No organization found", { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = userOrg.role === "owner" || userOrg.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Only owners and admins can create clients" }),
|
||||||
|
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("Only owners and admins can create clients", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const id = nanoid();
|
const id = nanoid();
|
||||||
|
|
||||||
await db.insert(clients).values({
|
await db.insert(clients).values({
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
return new Response("Only owners and admins can convert quotes", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lastInvoice = await db
|
const lastInvoice = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
|||||||
return new Response(buffer, {
|
return new Response(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/pdf",
|
"Content-Type": "application/pdf",
|
||||||
"Content-Disposition": `attachment; filename="${invoice.number}.pdf"`,
|
"Content-Disposition": `attachment; filename="${invoice.number.replace(/[^a-zA-Z0-9_\-\.]/g, "_")}.pdf"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -222,51 +222,52 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
return redirect(`/dashboard/invoices/${id}?error=no-entries`);
|
return redirect(`/dashboard/invoices/${id}?error=no-entries`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transaction-like operations
|
|
||||||
try {
|
try {
|
||||||
await db.insert(invoiceItems).values(newItems);
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(invoiceItems).values(newItems);
|
||||||
|
|
||||||
if (entryIdsToUpdate.length > 0) {
|
if (entryIdsToUpdate.length > 0) {
|
||||||
await db
|
await tx
|
||||||
.update(timeEntries)
|
.update(timeEntries)
|
||||||
.set({ invoiceId: invoice.id })
|
.set({ invoiceId: invoice.id })
|
||||||
.where(inArray(timeEntries.id, entryIdsToUpdate));
|
.where(inArray(timeEntries.id, entryIdsToUpdate));
|
||||||
}
|
|
||||||
|
|
||||||
const allItems = await db
|
|
||||||
.select()
|
|
||||||
.from(invoiceItems)
|
|
||||||
.where(eq(invoiceItems.invoiceId, invoice.id));
|
|
||||||
|
|
||||||
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
|
|
||||||
|
|
||||||
let discountAmount = 0;
|
|
||||||
if (invoice.discountType === "percentage") {
|
|
||||||
discountAmount = Math.round(
|
|
||||||
subtotal * ((invoice.discountValue || 0) / 100),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
|
||||||
if (invoice.discountValue && invoice.discountValue > 0) {
|
|
||||||
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
const allItems = await tx
|
||||||
const taxAmount = Math.round(
|
.select()
|
||||||
taxableAmount * ((invoice.taxRate || 0) / 100),
|
.from(invoiceItems)
|
||||||
);
|
.where(eq(invoiceItems.invoiceId, invoice.id));
|
||||||
const total = subtotal - discountAmount + taxAmount;
|
|
||||||
|
|
||||||
await db
|
const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0);
|
||||||
.update(invoices)
|
|
||||||
.set({
|
let discountAmount = 0;
|
||||||
subtotal,
|
if (invoice.discountType === "percentage") {
|
||||||
discountAmount,
|
discountAmount = Math.round(
|
||||||
taxAmount,
|
subtotal * ((invoice.discountValue || 0) / 100),
|
||||||
total,
|
);
|
||||||
})
|
} else {
|
||||||
.where(eq(invoices.id, invoice.id));
|
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||||
|
if (invoice.discountValue && invoice.discountValue > 0) {
|
||||||
|
discountAmount = Math.round((invoice.discountValue || 0) * 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxableAmount = Math.max(0, subtotal - discountAmount);
|
||||||
|
const taxAmount = Math.round(
|
||||||
|
taxableAmount * ((invoice.taxRate || 0) / 100),
|
||||||
|
);
|
||||||
|
const total = subtotal - discountAmount + taxAmount;
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(invoices)
|
||||||
|
.set({
|
||||||
|
subtotal,
|
||||||
|
discountAmount,
|
||||||
|
taxAmount,
|
||||||
|
total,
|
||||||
|
})
|
||||||
|
.where(eq(invoices.id, invoice.id));
|
||||||
|
});
|
||||||
|
|
||||||
return redirect(`/dashboard/invoices/${id}?success=imported`);
|
return redirect(`/dashboard/invoices/${id}?success=imported`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../../../../../db";
|
|||||||
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
import { invoiceItems, invoices, members } from "../../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
import { recalculateInvoiceTotals } from "../../../../../utils/invoice";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({
|
export const POST: APIRoute = async ({
|
||||||
request,
|
request,
|
||||||
@@ -61,6 +62,11 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Missing required fields", { status: 400 });
|
return new Response("Missing required fields", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError = exceedsLength("Description", description, MAX_LENGTHS.itemDescription);
|
||||||
|
if (lengthError) {
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const quantity = parseFloat(quantityStr);
|
const quantity = parseFloat(quantityStr);
|
||||||
const unitPriceMajor = parseFloat(unitPriceStr);
|
const unitPriceMajor = parseFloat(unitPriceStr);
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export const POST: APIRoute = async ({
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Destructive status changes require owner/admin
|
||||||
|
const destructiveStatuses = ["void"];
|
||||||
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
|
if (destructiveStatuses.includes(status) && !isAdminOrOwner) {
|
||||||
|
return new Response("Only owners and admins can void invoices", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "../../../../db";
|
|||||||
import { invoices, members } from "../../../../db/schema";
|
import { invoices, members } from "../../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -56,6 +57,14 @@ export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
|||||||
return new Response("Missing required fields", { status: 400 });
|
return new Response("Missing required fields", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Invoice number", number, MAX_LENGTHS.invoiceNumber) ||
|
||||||
|
exceedsLength("Currency", currency, MAX_LENGTHS.currency) ||
|
||||||
|
exceedsLength("Notes", notes, MAX_LENGTHS.invoiceNotes);
|
||||||
|
if (lengthError) {
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issueDate = new Date(issueDateStr);
|
const issueDate = new Date(issueDateStr);
|
||||||
const dueDate = new Date(dueDateStr);
|
const dueDate = new Date(dueDateStr);
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ export const POST: APIRoute = async ({ request, redirect, locals }) => {
|
|||||||
return new Response("Unauthorized", { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdminOrOwner = membership.role === "owner" || membership.role === "admin";
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
return new Response("Only owners and admins can delete invoices", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete invoice items first (manual cascade)
|
// Delete invoice items first (manual cascade)
|
||||||
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
await db.delete(invoiceItems).where(eq(invoiceItems.invoiceId, invoiceId));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "path";
|
|||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { organizations, members } from "../../../db/schema";
|
import { organizations, members } from "../../../db/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { MAX_LENGTHS, exceedsLength } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -29,6 +30,18 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lengthError =
|
||||||
|
exceedsLength("Name", name, MAX_LENGTHS.name) ||
|
||||||
|
exceedsLength("Street", street, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("City", city, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("State", state, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("ZIP", zip, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Country", country, MAX_LENGTHS.address) ||
|
||||||
|
exceedsLength("Currency", defaultCurrency, MAX_LENGTHS.currency);
|
||||||
|
if (lengthError) {
|
||||||
|
return new Response(lengthError, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify user is admin/owner of this organization
|
// Verify user is admin/owner of this organization
|
||||||
const membership = await db
|
const membership = await db
|
||||||
@@ -67,7 +80,9 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = logo.name.split(".").pop() || "png";
|
const rawExt = (logo.name.split(".").pop() || "png").toLowerCase().replace(/[^a-z]/g, "");
|
||||||
|
const allowedExtensions = ["png", "jpg", "jpeg"];
|
||||||
|
const ext = allowedExtensions.includes(rawExt) ? rawExt : "png";
|
||||||
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
||||||
const dataDir = process.env.DATA_DIR
|
const dataDir = process.env.DATA_DIR
|
||||||
? process.env.DATA_DIR
|
? process.env.DATA_DIR
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
"Tag",
|
"Tag",
|
||||||
"Description",
|
"Description",
|
||||||
];
|
];
|
||||||
|
const sanitizeCell = (value: string): string => {
|
||||||
|
if (/^[=+\-@\t\r]/.test(value)) {
|
||||||
|
return `\t${value}`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
const rows = entries.map((e) => {
|
const rows = entries.map((e) => {
|
||||||
const start = e.entry.startTime;
|
const start = e.entry.startTime;
|
||||||
const end = e.entry.endTime;
|
const end = e.entry.endTime;
|
||||||
@@ -144,10 +151,10 @@ export const GET: APIRoute = async ({ request, locals, cookies }) => {
|
|||||||
start.toLocaleTimeString(),
|
start.toLocaleTimeString(),
|
||||||
end ? end.toLocaleTimeString() : "",
|
end ? end.toLocaleTimeString() : "",
|
||||||
end ? duration.toFixed(2) : "Running",
|
end ? duration.toFixed(2) : "Running",
|
||||||
`"${(e.user.name || "").replace(/"/g, '""')}"`,
|
`"${sanitizeCell((e.user.name || "").replace(/"/g, '""'))}"`,
|
||||||
`"${(e.client.name || "").replace(/"/g, '""')}"`,
|
`"${sanitizeCell((e.client.name || "").replace(/"/g, '""'))}"`,
|
||||||
`"${tagsStr.replace(/"/g, '""')}"`,
|
`"${sanitizeCell(tagsStr.replace(/"/g, '""'))}"`,
|
||||||
`"${(e.entry.description || "").replace(/"/g, '""')}"`,
|
`"${sanitizeCell((e.entry.description || "").replace(/"/g, '""'))}"`,
|
||||||
].join(",");
|
].join(",");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
|
|||||||
import { db } from '../../../db';
|
import { db } from '../../../db';
|
||||||
import { users, members } from '../../../db/schema';
|
import { users, members } from '../../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { isValidEmail } from '../../../lib/validation';
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
@@ -26,6 +27,10 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response('Email is required', { status: 400 });
|
return new Response('Email is required', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
return new Response('Invalid email format', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
if (!['member', 'admin'].includes(role)) {
|
if (!['member', 'admin'].includes(role)) {
|
||||||
return new Response('Invalid role', { status: 400 });
|
return new Response('Invalid role', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
|
|||||||
import {
|
import {
|
||||||
validateTimeEntryResources,
|
validateTimeEntryResources,
|
||||||
validateTimeRange,
|
validateTimeRange,
|
||||||
|
MAX_LENGTHS,
|
||||||
} from "../../../lib/validation";
|
} from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
@@ -27,6 +28,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (description && description.length > MAX_LENGTHS.description) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Description must be ${MAX_LENGTHS.description} characters or fewer` }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!startTime) {
|
if (!startTime) {
|
||||||
return new Response(JSON.stringify({ error: "Start time is required" }), {
|
return new Response(JSON.stringify({ error: "Start time is required" }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from "../../../db";
|
|||||||
import { timeEntries, members } from "../../../db/schema";
|
import { timeEntries, members } from "../../../db/schema";
|
||||||
import { eq, and, isNull } from "drizzle-orm";
|
import { eq, and, isNull } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { validateTimeEntryResources } from "../../../lib/validation";
|
import { validateTimeEntryResources, MAX_LENGTHS } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||||
@@ -17,6 +17,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return new Response("Client is required", { status: 400 });
|
return new Response("Client is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (description && description.length > MAX_LENGTHS.description) {
|
||||||
|
return new Response(`Description must be ${MAX_LENGTHS.description} characters or fewer`, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const runningEntry = await db
|
const runningEntry = await db
|
||||||
.select()
|
.select()
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "../../../db";
|
import { db } from "../../../db";
|
||||||
import { users } from "../../../db/schema";
|
import { users, sessions } from "../../../db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { MAX_LENGTHS } from "../../../lib/validation";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect, cookies }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
const contentType = request.headers.get("content-type");
|
const contentType = request.headers.get("content-type");
|
||||||
const isJson = contentType?.includes("application/json");
|
const isJson = contentType?.includes("application/json");
|
||||||
@@ -53,6 +54,13 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
return new Response(msg, { status: 400 });
|
return new Response(msg, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentPassword.length > MAX_LENGTHS.password || newPassword.length > MAX_LENGTHS.password) {
|
||||||
|
const msg = `Password must be ${MAX_LENGTHS.password} characters or fewer`;
|
||||||
|
if (isJson)
|
||||||
|
return new Response(JSON.stringify({ error: msg }), { status: 400 });
|
||||||
|
return new Response(msg, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current user from database
|
// Get current user from database
|
||||||
const dbUser = await db
|
const dbUser = await db
|
||||||
@@ -90,6 +98,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
.where(eq(users.id, user.id))
|
.where(eq(users.id, user.id))
|
||||||
.run();
|
.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) {
|
if (isJson) {
|
||||||
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { clients, members } from '../../db/schema';
|
import { clients } from '../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const organizationId = userMembership.organizationId;
|
const organizationId = userMembership.organizationId;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { clients, members } from '../../../../db/schema';
|
import { clients } from '../../../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
@@ -11,20 +12,8 @@ if (!user) return Astro.redirect('/login');
|
|||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
if (!id) return Astro.redirect('/dashboard/clients');
|
if (!id) return Astro.redirect('/dashboard/clients');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const client = await db.select()
|
const client = await db.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../../db';
|
import { db } from '../../../../db';
|
||||||
import { clients, timeEntries, members, tags, users } from '../../../../db/schema';
|
import { clients, timeEntries, tags, users } from '../../../../db/schema';
|
||||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||||
import { formatTimeRange } from '../../../../lib/formatTime';
|
import { formatTimeRange } from '../../../../lib/formatTime';
|
||||||
|
import { getCurrentTeam } from '../../../../lib/getCurrentTeam';
|
||||||
|
import StatCard from '../../../../components/StatCard.astro';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
@@ -12,20 +14,8 @@ if (!user) return Astro.redirect('/login');
|
|||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
if (!id) return Astro.redirect('/dashboard/clients');
|
if (!id) return Astro.redirect('/dashboard/clients');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const client = await db.select()
|
const client = await db.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -132,23 +122,20 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<div class="stats shadow w-full">
|
<div class="stats shadow w-full">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-primary">
|
title="Total Time Tracked"
|
||||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
value={`${totalHours}h ${totalMinutes}m`}
|
||||||
</div>
|
description="Across all projects"
|
||||||
<div class="stat-title">Total Time Tracked</div>
|
icon="heroicons:clock"
|
||||||
<div class="stat-value text-primary">{totalHours}h {totalMinutes}m</div>
|
color="text-primary"
|
||||||
<div class="stat-desc">Across all projects</div>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
|
title="Total Entries"
|
||||||
<div class="stat">
|
value={String(totalEntriesCount)}
|
||||||
<div class="stat-figure text-secondary">
|
description="Recorded entries"
|
||||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
icon="heroicons:list-bullet"
|
||||||
</div>
|
color="text-secondary"
|
||||||
<div class="stat-title">Total Entries</div>
|
/>
|
||||||
<div class="stat-value text-secondary">{totalEntriesCount}</div>
|
|
||||||
<div class="stat-desc">Recorded entries</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import StatCard from '../../components/StatCard.astro';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
import { organizations, members, timeEntries, clients, tags } from '../../db/schema';
|
||||||
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
import { eq, desc, and, isNull, gte, sql } from 'drizzle-orm';
|
||||||
@@ -134,41 +135,38 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
<>
|
<>
|
||||||
<!-- Stats Overview -->
|
<!-- Stats Overview -->
|
||||||
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
|
<div class="stats stats-vertical lg:stats-horizontal shadow-lg w-full mb-8">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-primary">
|
title="This Week"
|
||||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
value={formatDuration(stats.totalTimeThisWeek)}
|
||||||
</div>
|
description="Total tracked time"
|
||||||
<div class="stat-title">This Week</div>
|
icon="heroicons:clock"
|
||||||
<div class="stat-value text-primary text-3xl">{formatDuration(stats.totalTimeThisWeek)}</div>
|
color="text-primary"
|
||||||
<div class="stat-desc">Total tracked time</div>
|
valueClass="text-3xl"
|
||||||
</div>
|
/>
|
||||||
|
<StatCard
|
||||||
<div class="stat">
|
title="This Month"
|
||||||
<div class="stat-figure text-secondary">
|
value={formatDuration(stats.totalTimeThisMonth)}
|
||||||
<Icon name="heroicons:calendar" class="w-8 h-8" />
|
description="Total tracked time"
|
||||||
</div>
|
icon="heroicons:calendar"
|
||||||
<div class="stat-title">This Month</div>
|
color="text-secondary"
|
||||||
<div class="stat-value text-secondary text-3xl">{formatDuration(stats.totalTimeThisMonth)}</div>
|
valueClass="text-3xl"
|
||||||
<div class="stat-desc">Total tracked time</div>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
|
title="Active Timers"
|
||||||
<div class="stat">
|
value={String(stats.activeTimers)}
|
||||||
<div class="stat-figure text-accent">
|
description="Currently running"
|
||||||
<Icon name="heroicons:play-circle" class="w-8 h-8" />
|
icon="heroicons:play-circle"
|
||||||
</div>
|
color="text-accent"
|
||||||
<div class="stat-title">Active Timers</div>
|
valueClass="text-3xl"
|
||||||
<div class="stat-value text-accent text-3xl">{stats.activeTimers}</div>
|
/>
|
||||||
<div class="stat-desc">Currently running</div>
|
<StatCard
|
||||||
</div>
|
title="Clients"
|
||||||
|
value={String(stats.totalClients)}
|
||||||
<div class="stat">
|
description="Total active"
|
||||||
<div class="stat-figure text-info">
|
icon="heroicons:building-office"
|
||||||
<Icon name="heroicons:building-office" class="w-8 h-8" />
|
color="text-info"
|
||||||
</div>
|
valueClass="text-3xl"
|
||||||
<div class="stat-title">Clients</div>
|
/>
|
||||||
<div class="stat-value text-info text-3xl">{stats.totalClients}</div>
|
|
||||||
<div class="stat-desc">Total active</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components';
|
|||||||
import { db } from '../../../db';
|
import { db } from '../../../db';
|
||||||
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
|
import { invoices, invoiceItems, clients, members, organizations } from '../../../db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { formatCurrency } from '../../../lib/formatTime';
|
||||||
|
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
@@ -49,13 +50,6 @@ const items = await db.select()
|
|||||||
.where(eq(invoiceItems.invoiceId, invoice.id))
|
.where(eq(invoiceItems.invoiceId, invoice.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: invoice.currency,
|
|
||||||
}).format(amount / 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDraft = invoice.status === 'draft';
|
const isDraft = invoice.status === 'draft';
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -235,8 +229,8 @@ const isDraft = invoice.status === 'draft';
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="py-4">{item.description}</td>
|
<td class="py-4">{item.description}</td>
|
||||||
<td class="py-4 text-right">{item.quantity}</td>
|
<td class="py-4 text-right">{item.quantity}</td>
|
||||||
<td class="py-4 text-right">{formatCurrency(item.unitPrice)}</td>
|
<td class="py-4 text-right">{formatCurrency(item.unitPrice, invoice.currency)}</td>
|
||||||
<td class="py-4 text-right font-medium">{formatCurrency(item.amount)}</td>
|
<td class="py-4 text-right font-medium">{formatCurrency(item.amount, invoice.currency)}</td>
|
||||||
{isDraft && (
|
{isDraft && (
|
||||||
<td class="py-4 text-right">
|
<td class="py-4 text-right">
|
||||||
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
|
<form method="POST" action={`/api/invoices/${invoice.id}/items/delete`}>
|
||||||
@@ -299,7 +293,7 @@ const isDraft = invoice.status === 'draft';
|
|||||||
<div class="w-64 space-y-3">
|
<div class="w-64 space-y-3">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-base-content/60">Subtotal</span>
|
<span class="text-base-content/60">Subtotal</span>
|
||||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
<span class="font-medium">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
{(invoice.discountAmount && invoice.discountAmount > 0) && (
|
{(invoice.discountAmount && invoice.discountAmount > 0) && (
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
@@ -307,7 +301,7 @@ const isDraft = invoice.status === 'draft';
|
|||||||
Discount
|
Discount
|
||||||
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
|
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
|
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount, invoice.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
||||||
@@ -320,13 +314,13 @@ const isDraft = invoice.status === 'draft';
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
<span class="font-medium">{formatCurrency(invoice.taxAmount, invoice.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div class="divider my-2"></div>
|
<div class="divider my-2"></div>
|
||||||
<div class="flex justify-between text-lg font-bold">
|
<div class="flex justify-between text-lg font-bold">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span class="text-primary">{formatCurrency(invoice.total)}</span>
|
<span class="text-primary">{formatCurrency(invoice.total, invoice.currency)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import StatCard from '../../../components/StatCard.astro';
|
||||||
import { db } from '../../../db';
|
import { db } from '../../../db';
|
||||||
import { invoices, clients, members } from '../../../db/schema';
|
import { invoices, clients } from '../../../db/schema';
|
||||||
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
|
import { eq, desc, and, gte, lte, sql } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||||
|
import { formatCurrency } from '../../../lib/formatTime';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const currentTeamIdResolved = userMembership.organizationId;
|
const currentTeamIdResolved = userMembership.organizationId;
|
||||||
|
|
||||||
@@ -96,13 +87,6 @@ const yearInvoices = allInvoicesRaw.filter(i => {
|
|||||||
return issueDate >= yearStart && issueDate <= yearEnd;
|
return issueDate >= yearStart && issueDate <= yearEnd;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (amount: number, currency: string) => {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
}).format(amount / 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid': return 'badge-success';
|
case 'paid': return 'badge-success';
|
||||||
@@ -130,40 +114,35 @@ const getStatusColor = (status: string) => {
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<div class="stats shadow bg-base-100 border border-base-200">
|
<div class="stats shadow bg-base-100 border border-base-200">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-primary">
|
title="Total Invoices"
|
||||||
<Icon name="heroicons:document-text" class="w-8 h-8" />
|
value={String(yearInvoices.filter(i => i.invoice.type === 'invoice').length)}
|
||||||
</div>
|
description={selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}
|
||||||
<div class="stat-title">Total Invoices</div>
|
icon="heroicons:document-text"
|
||||||
<div class="stat-value text-primary">{yearInvoices.filter(i => i.invoice.type === 'invoice').length}</div>
|
color="text-primary"
|
||||||
<div class="stat-desc">{selectedYear === 'current' ? `${currentYear} (YTD)` : selectedYear}</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats shadow bg-base-100 border border-base-200">
|
<div class="stats shadow bg-base-100 border border-base-200">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-secondary">
|
title="Open Quotes"
|
||||||
<Icon name="heroicons:clipboard-document-list" class="w-8 h-8" />
|
value={String(yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length)}
|
||||||
</div>
|
description="Waiting for approval"
|
||||||
<div class="stat-title">Open Quotes</div>
|
icon="heroicons:clipboard-document-list"
|
||||||
<div class="stat-value text-secondary">{yearInvoices.filter(i => i.invoice.type === 'quote' && i.invoice.status === 'sent').length}</div>
|
color="text-secondary"
|
||||||
<div class="stat-desc">Waiting for approval</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats shadow bg-base-100 border border-base-200">
|
<div class="stats shadow bg-base-100 border border-base-200">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-success">
|
title="Total Revenue"
|
||||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
value={formatCurrency(yearInvoices
|
||||||
</div>
|
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
||||||
<div class="stat-title">Total Revenue</div>
|
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
||||||
<div class="stat-value text-success">
|
description={`Paid invoices (${selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})`}
|
||||||
{formatCurrency(yearInvoices
|
icon="heroicons:currency-dollar"
|
||||||
.filter(i => i.invoice.type === 'invoice' && i.invoice.status === 'paid')
|
color="text-success"
|
||||||
.reduce((acc, curr) => acc + curr.invoice.total, 0), 'USD')}
|
/>
|
||||||
</div>
|
|
||||||
<div class="stat-desc">Paid invoices ({selectedYear === 'current' ? `${currentYear} YTD` : selectedYear})</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,15 @@
|
|||||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../db';
|
import { db } from '../../../db';
|
||||||
import { clients, members, invoices, organizations } from '../../../db/schema';
|
import { clients, invoices, organizations } from '../../../db/schema';
|
||||||
import { eq, desc, and } from 'drizzle-orm';
|
import { eq, desc, and } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const currentTeamIdResolved = userMembership.organizationId;
|
const currentTeamIdResolved = userMembership.organizationId;
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,21 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import StatCard from '../../components/StatCard.astro';
|
||||||
import TagChart from '../../components/TagChart.vue';
|
import TagChart from '../../components/TagChart.vue';
|
||||||
import ClientChart from '../../components/ClientChart.vue';
|
import ClientChart from '../../components/ClientChart.vue';
|
||||||
import MemberChart from '../../components/MemberChart.vue';
|
import MemberChart from '../../components/MemberChart.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
|
import { timeEntries, members, users, clients, tags, invoices } from '../../db/schema';
|
||||||
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
import { eq, and, gte, lte, sql, desc } from 'drizzle-orm';
|
||||||
import { formatDuration, formatTimeRange } from '../../lib/formatTime';
|
import { formatDuration, formatTimeRange, formatCurrency } from '../../lib/formatTime';
|
||||||
|
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const teamMembers = await db.select({
|
const teamMembers = await db.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
@@ -247,13 +237,6 @@ const revenueByClient = allClients.map(client => {
|
|||||||
};
|
};
|
||||||
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
|
}).filter(s => s.revenue > 0).sort((a, b) => b.revenue - a.revenue);
|
||||||
|
|
||||||
function formatCurrency(amount: number, currency: string = 'USD') {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency,
|
|
||||||
}).format(amount / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTimeRangeLabel(range: string) {
|
function getTimeRangeLabel(range: string) {
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case 'today': return 'Today';
|
case 'today': return 'Today';
|
||||||
@@ -383,46 +366,44 @@ function getTimeRangeLabel(range: string) {
|
|||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-primary">
|
title="Total Time"
|
||||||
<Icon name="heroicons:clock" class="w-8 h-8" />
|
value={formatDuration(totalTime)}
|
||||||
</div>
|
description={getTimeRangeLabel(timeRange)}
|
||||||
<div class="stat-title">Total Time</div>
|
icon="heroicons:clock"
|
||||||
<div class="stat-value text-primary">{formatDuration(totalTime)}</div>
|
color="text-primary"
|
||||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-secondary">
|
title="Total Entries"
|
||||||
<Icon name="heroicons:list-bullet" class="w-8 h-8" />
|
value={String(entries.length)}
|
||||||
</div>
|
description={getTimeRangeLabel(timeRange)}
|
||||||
<div class="stat-title">Total Entries</div>
|
icon="heroicons:list-bullet"
|
||||||
<div class="stat-value text-secondary">{entries.length}</div>
|
color="text-secondary"
|
||||||
<div class="stat-desc">{getTimeRangeLabel(timeRange)}</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-success">
|
title="Revenue"
|
||||||
<Icon name="heroicons:currency-dollar" class="w-8 h-8" />
|
value={formatCurrency(revenueStats.total)}
|
||||||
</div>
|
description={`${invoiceStats.paid} paid invoices`}
|
||||||
<div class="stat-title">Revenue</div>
|
icon="heroicons:currency-dollar"
|
||||||
<div class="stat-value text-success">{formatCurrency(revenueStats.total)}</div>
|
color="text-success"
|
||||||
<div class="stat-desc">{invoiceStats.paid} paid invoices</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats shadow border border-base-300">
|
<div class="stats shadow border border-base-300">
|
||||||
<div class="stat">
|
<StatCard
|
||||||
<div class="stat-figure text-accent">
|
title="Active Members"
|
||||||
<Icon name="heroicons:user-group" class="w-8 h-8" />
|
value={String(statsByMember.filter(s => s.entryCount > 0).length)}
|
||||||
</div>
|
description={`of ${teamMembers.length} total`}
|
||||||
<div class="stat-title">Active Members</div>
|
icon="heroicons:user-group"
|
||||||
<div class="stat-value text-accent">{statsByMember.filter(s => s.entryCount > 0).length}</div>
|
color="text-accent"
|
||||||
<div class="stat-desc">of {teamMembers.length} total</div>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ const userPasskeys = await db.select()
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<!-- Profile Information -->
|
<!-- Profile Information -->
|
||||||
<ProfileForm client:load user={user} />
|
<ProfileForm client:idle user={user} />
|
||||||
|
|
||||||
<!-- Change Password -->
|
<!-- Change Password -->
|
||||||
<PasswordForm client:load />
|
<PasswordForm client:idle />
|
||||||
|
|
||||||
<!-- Passkeys -->
|
<!-- Passkeys -->
|
||||||
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
|
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
|
||||||
|
|||||||
@@ -5,24 +5,13 @@ import { Icon } from 'astro-icon/components';
|
|||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { members, users } from '../../db/schema';
|
import { members, users } from '../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const teamMembers = await db.select({
|
const teamMembers = await db.select({
|
||||||
member: members,
|
member: members,
|
||||||
|
|||||||
@@ -2,26 +2,15 @@
|
|||||||
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
import { db } from '../../../db';
|
import { db } from '../../../db';
|
||||||
import { members, organizations, tags } from '../../../db/schema';
|
import { organizations, tags } from '../../../db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getCurrentTeam } from '../../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||||
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
if (!isAdmin) return Astro.redirect('/dashboard/team');
|
||||||
|
|||||||
@@ -4,27 +4,16 @@ import { Icon } from 'astro-icon/components';
|
|||||||
import Timer from '../../components/Timer.vue';
|
import Timer from '../../components/Timer.vue';
|
||||||
import ManualEntry from '../../components/ManualEntry.vue';
|
import ManualEntry from '../../components/ManualEntry.vue';
|
||||||
import { db } from '../../db';
|
import { db } from '../../db';
|
||||||
import { timeEntries, clients, members, tags, users } from '../../db/schema';
|
import { timeEntries, clients, tags, users } from '../../db/schema';
|
||||||
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
import { eq, desc, asc, and, sql, or, like } from 'drizzle-orm';
|
||||||
import { formatTimeRange } from '../../lib/formatTime';
|
import { formatTimeRange } from '../../lib/formatTime';
|
||||||
|
import { getCurrentTeam } from '../../lib/getCurrentTeam';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
// Get current team from cookie
|
const userMembership = await getCurrentTeam(user, Astro.cookies.get('currentTeamId')?.value);
|
||||||
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
if (!userMembership) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
const userMemberships = await db.select()
|
|
||||||
.from(members)
|
|
||||||
.where(eq(members.userId, user.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
|
||||||
|
|
||||||
// Use current team or fallback to first membership
|
|
||||||
const userMembership = currentTeamId
|
|
||||||
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
|
||||||
: userMemberships[0];
|
|
||||||
|
|
||||||
const organizationId = userMembership.organizationId;
|
const organizationId = userMembership.organizationId;
|
||||||
|
|
||||||
@@ -153,12 +142,12 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
<h1 class="text-3xl font-bold mb-6">Time Tracker</h1>
|
||||||
|
|
||||||
<!-- Tabs for Timer and Manual Entry -->
|
<!-- Tabs for Timer and Manual Entry -->
|
||||||
<div role="tablist" class="tabs tabs-lifted mb-6">
|
<div class="tabs tabs-lift mb-6">
|
||||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
|
<input type="radio" name="tracker_tabs" class="tab" aria-label="Timer" checked="checked" />
|
||||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||||
{allClients.length === 0 ? (
|
{allClients.length === 0 ? (
|
||||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
<Icon name="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>
|
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,11 +166,11 @@ const paginationPages = getPaginationPages(page, totalPages);
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
|
<input type="radio" name="tracker_tabs" class="tab" aria-label="Manual Entry" />
|
||||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
<div class="tab-content bg-base-100 border-base-300 p-6">
|
||||||
{allClients.length === 0 ? (
|
{allClients.length === 0 ? (
|
||||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
<Icon name="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>
|
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
|
||||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,10 +52,8 @@ export const GET: APIRoute = async ({ params }) => {
|
|||||||
case ".gif":
|
case ".gif":
|
||||||
contentType = "image/gif";
|
contentType = "image/gif";
|
||||||
break;
|
break;
|
||||||
case ".svg":
|
// SVG excluded to prevent stored XSS
|
||||||
contentType = "image/svg+xml";
|
// WebP omitted — not supported in PDF generation
|
||||||
break;
|
|
||||||
// WebP is intentionally omitted as it is not supported in PDF generation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(fileContent, {
|
return new Response(fileContent, {
|
||||||
|
|||||||
Reference in New Issue
Block a user