This commit is contained in:
@@ -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
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
|
data/
|
||||||
# generated types
|
# generated types
|
||||||
.astro/
|
.astro/
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
6
drizzle/0001_lazy_roughhouse.sql
Normal file
6
drizzle/0001_lazy_roughhouse.sql
Normal 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;
|
||||||
@@ -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",
|
||||||
|
|||||||
1029
drizzle/meta/0001_snapshot.json
Normal file
1029
drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
9
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
97
src/pages/api/invoices/[id]/convert.ts
Normal file
97
src/pages/api/invoices/[id]/convert.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
79
src/pages/api/invoices/[id]/update-tax.ts
Normal file
79
src/pages/api/invoices/[id]/update-tax.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
71
src/pages/uploads/[...path].ts
Normal file
71
src/pages/uploads/[...path].ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in New Issue
Block a user