FINISHED
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m6s

This commit is contained in:
2026-01-17 15:56:25 -07:00
parent 3734b2693a
commit 0cd77677f2
36 changed files with 2012 additions and 202 deletions

View File

@@ -1,3 +1,4 @@
HOST=0.0.0.0 # Docker Configuration
PORT=4321 IMAGE=ghcr.io/atridad/chronus:latest
DATABASE_URL=chronus.db APP_PORT=4321
ROOT_DIR=./data

1
.gitignore vendored
View File

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

View File

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

View File

@@ -1,11 +1,24 @@
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
import fs from "fs";
import path from "path";
import * as dotenv from "dotenv";
dotenv.config();
const rootDir = process.env.ROOT_DIR || process.cwd();
if (process.env.ROOT_DIR && !fs.existsSync(rootDir)) {
fs.mkdirSync(rootDir, { recursive: true });
}
const dbUrl = `file:${path.join(rootDir, "chronus.db")}`;
export default defineConfig({ export default defineConfig({
schema: "./src/db/schema.ts", schema: "./src/db/schema.ts",
out: "./drizzle", out: "./drizzle",
dialect: "turso", dialect: "turso",
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL || "file:chronus.db", url: dbUrl,
authToken: process.env.DATABASE_AUTH_TOKEN, authToken: process.env.DATABASE_AUTH_TOKEN,
}, },
}); });

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "baea49b9-0dd5-4e46-9345-40acabf238c3", "id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"api_tokens": { "api_tokens": {
@@ -513,6 +513,13 @@
"notNull": true, "notNull": true,
"autoincrement": false "autoincrement": false
}, },
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"street": { "street": {
"name": "street", "name": "street",
"type": "text", "type": "text",

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -34,6 +34,7 @@
"@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",
"dotenv": "^17.2.3",
"drizzle-kit": "0.31.8" "drizzle-kit": "0.31.8"
} }
} }

9
pnpm-lock.yaml generated
View File

@@ -69,6 +69,9 @@ importers:
'@react-pdf/types': '@react-pdf/types':
specifier: ^2.9.2 specifier: ^2.9.2
version: 2.9.2 version: 2.9.2
dotenv:
specifier: ^17.2.3
version: 17.2.3
drizzle-kit: drizzle-kit:
specifier: 0.31.8 specifier: 0.31.8
version: 0.31.8 version: 0.31.8
@@ -1722,6 +1725,10 @@ packages:
domutils@3.2.2: domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
drizzle-kit@0.31.8: drizzle-kit@0.31.8:
resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==}
hasBin: true hasBin: true
@@ -5379,6 +5386,8 @@ snapshots:
domelementtype: 2.3.0 domelementtype: 2.3.0
domhandler: 5.0.3 domhandler: 5.0.3
dotenv@17.2.3: {}
drizzle-kit@0.31.8: drizzle-kit@0.31.8:
dependencies: dependencies:
'@drizzle-team/brocli': 0.10.2 '@drizzle-team/brocli': 0.10.2

View File

@@ -2,23 +2,20 @@ import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator"; import { migrate } from "drizzle-orm/libsql/migrator";
import { createClient } from "@libsql/client"; import { createClient } from "@libsql/client";
import path from "path"; import path from "path";
import fs from "fs";
async function runMigrate() { async function runMigrate() {
console.log("Running migrations..."); console.log("Running migrations...");
let url = process.env.DATABASE_URL; const rootDir = process.env.ROOT_DIR || process.cwd();
if (!url) {
url = `file:${path.resolve(process.cwd(), "chronus.db")}`; if (process.env.ROOT_DIR && !fs.existsSync(rootDir)) {
console.log(`No DATABASE_URL found, using default: ${url}`); fs.mkdirSync(rootDir, { recursive: true });
} else if (
!url.startsWith("file:") &&
!url.startsWith("libsql:") &&
!url.startsWith("http:") &&
!url.startsWith("https:")
) {
url = `file:${url}`;
} }
const url = `file:${path.join(rootDir, "chronus.db")}`;
console.log(`Using database: ${url}`);
const authToken = process.env.DATABASE_AUTH_TOKEN; const authToken = process.env.DATABASE_AUTH_TOKEN;
const client = createClient({ const client = createClient({

View File

@@ -2,6 +2,7 @@ import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client"; import { createClient } from "@libsql/client";
import * as schema from "./schema"; import * as schema from "./schema";
import path from "path"; import path from "path";
import fs from "fs";
// Define the database type based on the schema // Define the database type based on the schema
type Database = ReturnType<typeof drizzle<typeof schema>>; type Database = ReturnType<typeof drizzle<typeof schema>>;
@@ -10,17 +11,16 @@ let _db: Database | null = null;
function initDb(): Database { function initDb(): Database {
if (!_db) { if (!_db) {
let url = process.env.DATABASE_URL; const envRootDir = process.env.ROOT_DIR
if (!url) { ? process.env.ROOT_DIR
url = `file:${path.resolve(process.cwd(), "chronus.db")}`; : import.meta.env.ROOT_DIR;
} else if ( const rootDir = envRootDir || process.cwd();
!url.startsWith("file:") &&
!url.startsWith("libsql:") && if (envRootDir && !fs.existsSync(rootDir)) {
!url.startsWith("http:") && fs.mkdirSync(rootDir, { recursive: true });
!url.startsWith("https:")
) {
url = `file:${url}`;
} }
const url = `file:${path.join(rootDir, "chronus.db")}`;
const authToken = process.env.DATABASE_AUTH_TOKEN; const authToken = process.env.DATABASE_AUTH_TOKEN;
const client = createClient({ const client = createClient({

View File

@@ -26,6 +26,7 @@ export const organizations = sqliteTable("organizations", {
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
name: text("name").notNull(), name: text("name").notNull(),
logoUrl: text("logo_url"),
street: text("street"), street: text("street"),
city: text("city"), city: text("city"),
state: text("state"), state: text("state"),
@@ -68,6 +69,12 @@ export const clients = sqliteTable(
organizationId: text("organization_id").notNull(), organizationId: text("organization_id").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
email: text("email"), email: text("email"),
phone: text("phone"),
street: text("street"),
city: text("city"),
state: text("state"),
zip: text("zip"),
country: text("country"),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),

View File

@@ -181,8 +181,8 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
</li> </li>
<li> <li>
<form action="/api/auth/logout" method="POST"> <form action="/api/auth/logout" method="POST" class="contents">
<button type="submit" class="w-full text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!"> <button type="submit" class="flex w-full items-center gap-2 py-2 px-4 text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" /> <Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
Logout Logout
</button> </button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ if (!user) return Astro.redirect('/login');
id="name" id="name"
name="name" name="name"
placeholder="Acme Corp" placeholder="Acme Corp"
class="input input-bordered" class="input input-bordered w-full"
required required
/> />
</div> </div>
@@ -33,11 +33,95 @@ if (!user) return Astro.redirect('/login');
type="email" type="email"
id="email" id="email"
name="email" name="email"
placeholder="contact@acme.com" placeholder="jason.borne@cia.com"
class="input input-bordered" class="input input-bordered w-full"
/> />
</div> </div>
<div class="form-control">
<label class="label" for="phone">
<span class="label-text">Phone (optional)</span>
</label>
<input
type="tel"
id="phone"
name="phone"
placeholder="+1 (780) 420-1337"
class="input input-bordered w-full"
/>
</div>
<div class="divider">Address Details</div>
<div class="form-control">
<label class="label" for="street">
<span class="label-text">Street Address (optional)</span>
</label>
<input
type="text"
id="street"
name="street"
placeholder="123 Business Rd"
class="input input-bordered w-full"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="city">
<span class="label-text">City (optional)</span>
</label>
<input
type="text"
id="city"
name="city"
placeholder="Edmonton"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="state">
<span class="label-text">State / Province (optional)</span>
</label>
<input
type="text"
id="state"
name="state"
placeholder="AB"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="zip">
<span class="label-text">Zip / Postal Code (optional)</span>
</label>
<input
type="text"
id="zip"
name="zip"
placeholder="10001"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="country">
<span class="label-text">Country (optional)</span>
</label>
<input
type="text"
id="country"
name="country"
placeholder="Canada"
class="input input-bordered w-full"
/>
</div>
</div>
<div class="card-actions justify-end mt-6"> <div class="card-actions justify-end mt-6">
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a> <a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
<button type="submit" class="btn btn-primary">Create Client</button> <button type="submit" class="btn btn-primary">Create Client</button>

View File

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

View File

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

View File

@@ -109,7 +109,7 @@ const getStatusColor = (status: string) => {
<div class="card bg-base-100 shadow-xl border border-base-200"> <div class="card bg-base-100 shadow-xl border border-base-200">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="overflow-x-auto"> <div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr class="bg-base-200/50"> <tr class="bg-base-200/50">

View File

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

View File

@@ -67,9 +67,51 @@ const successType = url.searchParams.get('success');
</div> </div>
)} )}
<form action="/api/organizations/update-name" method="POST" class="space-y-4"> <form
action="/api/organizations/update-name"
method="POST"
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="organizationId" value={organization.id} /> <input type="hidden" name="organizationId" value={organization.id} />
<div class="form-control">
<div class="label">
<span class="label-text font-medium">Team Logo</span>
</div>
<div class="flex items-center gap-6">
<div class="avatar placeholder">
<div class="bg-base-200 text-neutral-content rounded-xl w-24 border border-base-300 flex items-center justify-center overflow-hidden">
{organization.logoUrl ? (
<img
src={organization.logoUrl}
alt={organization.name}
class="w-full h-full object-cover"
/>
) : (
<Icon
name="heroicons:photo"
class="w-8 h-8 opacity-40 text-base-content"
/>
)}
</div>
</div>
<div>
<input
type="file"
name="logo"
accept="image/png, image/jpeg"
class="file-input file-input-bordered w-full max-w-xs"
/>
<div class="text-xs text-base-content/60 mt-2">
Upload a company logo (PNG, JPG).
<br />
Will be displayed on invoices and quotes.
</div>
</div>
</div>
</div>
<label class="form-control"> <label class="form-control">
<div class="label"> <div class="label">
<span class="label-text font-medium">Team Name</span> <span class="label-text font-medium">Team Name</span>

View File

@@ -1,9 +1,18 @@
--- ---
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
if (Astro.locals.user) { if (Astro.locals.user) {
return Astro.redirect('/dashboard'); return Astro.redirect('/dashboard');
} }
const error = Astro.url.searchParams.get('error');
const errorMessage =
error === 'invalid_credentials'
? 'Invalid email or password'
: error === 'missing_fields'
? 'Please fill in all fields'
: null;
--- ---
<Layout title="Login - Chronus"> <Layout title="Login - Chronus">
@@ -14,6 +23,13 @@ if (Astro.locals.user) {
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2> <h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p> <p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
{errorMessage && (
<div role="alert" class="alert alert-error mb-4">
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{errorMessage}</span>
</div>
)}
<form action="/api/auth/login" method="POST" class="space-y-4"> <form action="/api/auth/login" method="POST" class="space-y-4">
<label class="form-control"> <label class="form-control">
<div class="label"> <div class="label">

View File

@@ -20,6 +20,16 @@ if (!isFirstUser) {
.get(); .get();
registrationDisabled = registrationSetting?.value !== 'true'; registrationDisabled = registrationSetting?.value !== 'true';
} }
const error = Astro.url.searchParams.get('error');
const errorMessage =
error === 'user_exists'
? 'An account with this email already exists'
: error === 'missing_fields'
? 'Please fill in all fields'
: error === 'registration_disabled'
? 'Registration is currently disabled'
: null;
--- ---
<Layout title="Sign Up - Chronus"> <Layout title="Sign Up - Chronus">
@@ -30,6 +40,13 @@ if (!isFirstUser) {
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2> <h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p> <p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
{errorMessage && (
<div role="alert" class="alert alert-error mb-4">
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{errorMessage}</span>
</div>
)}
{registrationDisabled ? ( {registrationDisabled ? (
<> <>
<div class="alert alert-warning"> <div class="alert alert-warning">

View File

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

View File

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