Compare commits
21 Commits
5aa9388678
...
2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
1063bf99f1
|
|||
|
ea0a83f44d
|
|||
|
fa2c92644a
|
|||
|
3d4b8762e5
|
|||
|
5e70dd6bb8
|
|||
|
ce47de9e56
|
|||
|
db1d180afc
|
|||
|
82e1b8a626
|
|||
|
253c24c89b
|
|||
|
39c51b1115
|
|||
|
091766d6e4
|
|||
|
0cd77677f2
|
|||
|
3734b2693a
|
|||
|
996092d14e
|
|||
|
aae8693dd3
|
|||
|
bebc4b2743
|
|||
|
7026435cd3
|
|||
|
85750a5c79
|
|||
|
6aa4548a38
|
|||
|
42fbea6ae7
|
|||
|
c4ecc0b899
|
@@ -1,3 +1,4 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=4321
|
||||
DATABASE_URL=chronus.db
|
||||
DATA_DIR=./data
|
||||
ROOT_DIR=./data
|
||||
APP_PORT=4321
|
||||
IMAGE=git.atri.dad/atash/chronus:latest
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# build output
|
||||
dist/
|
||||
data/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
|
||||
30
Dockerfile
30
Dockerfile
@@ -1,33 +1,35 @@
|
||||
FROM node:lts-alpine AS builder
|
||||
FROM node:lts-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm i -g pnpm
|
||||
|
||||
FROM base AS prod-deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
RUN pnpm install
|
||||
FROM base AS build-deps
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM build-deps AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:lts-alpine AS runtime
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm i -g pnpm
|
||||
|
||||
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/drizzle ./drizzle
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN pnpm install --prod
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
COPY package.json ./
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV DATABASE_URL=/app/data/chronus.db
|
||||
EXPOSE 4321
|
||||
|
||||
CMD ["sh", "-c", "pnpm run migrate && node ./dist/server/entry.mjs"]
|
||||
CMD ["sh", "-c", "npm run migrate && node ./dist/server/entry.mjs"]
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
# Chronus
|
||||
A modern time tracking application built with Astro, Vue, and DaisyUI.
|
||||
|
||||
## Stack
|
||||
- Framework: Astro
|
||||
- Runtime: Node
|
||||
- UI Library: Vue 3
|
||||
- CSS and Styles: DaisyUI + Tailwind CSS
|
||||
- Database: libSQL
|
||||
- ORM: Drizzle ORM
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import vue from '@astrojs/vue';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import icon from 'astro-icon';
|
||||
import { defineConfig } from "astro/config";
|
||||
import vue from "@astrojs/vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import icon from "astro-icon";
|
||||
|
||||
import node from '@astrojs/node';
|
||||
import node from "@astrojs/node";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
output: "server",
|
||||
integrations: [vue(), icon()],
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
ssr: {
|
||||
external: ['better-sqlite3'],
|
||||
},
|
||||
},
|
||||
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
mode: "standalone",
|
||||
}),
|
||||
});
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- HOST=0.0.0.0
|
||||
- PORT=4321
|
||||
- DATABASE_URL=/app/data/chronus.db
|
||||
- DATA_DIR=/app/data
|
||||
volumes:
|
||||
- ${ROOT_DIR}:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
throw new Error("DATA_DIR environment variable is not set");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const dbUrl = `file:${path.join(dataDir, "chronus.db")}`;
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/db/schema.ts',
|
||||
out: './drizzle',
|
||||
dialect: 'sqlite',
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "turso",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'chronus.db',
|
||||
url: dbUrl,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ CREATE TABLE `members` (
|
||||
CREATE TABLE `organizations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`logo_url` text,
|
||||
`street` text,
|
||||
`city` text,
|
||||
`state` text,
|
||||
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;
|
||||
16
drizzle/0002_chilly_cyclops.sql
Normal file
16
drizzle/0002_chilly_cyclops.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE INDEX `api_tokens_user_id_idx` ON `api_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `categories_organization_id_idx` ON `categories` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `clients_organization_id_idx` ON `clients` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `invoice_items_invoice_id_idx` ON `invoice_items` (`invoice_id`);--> statement-breakpoint
|
||||
CREATE INDEX `invoices_organization_id_idx` ON `invoices` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `invoices_client_id_idx` ON `invoices` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `members_user_id_idx` ON `members` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `members_organization_id_idx` ON `members` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `sessions_user_id_idx` ON `sessions` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `tags_organization_id_idx` ON `tags` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `time_entries_user_id_idx` ON `time_entries` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `time_entries_organization_id_idx` ON `time_entries` (`organization_id`);--> statement-breakpoint
|
||||
CREATE INDEX `time_entries_client_id_idx` ON `time_entries` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `time_entries_start_time_idx` ON `time_entries` (`start_time`);--> statement-breakpoint
|
||||
CREATE INDEX `time_entry_tags_time_entry_id_idx` ON `time_entry_tags` (`time_entry_id`);--> statement-breakpoint
|
||||
CREATE INDEX `time_entry_tags_tag_id_idx` ON `time_entry_tags` (`tag_id`);
|
||||
3
drizzle/0003_amusing_wendigo.sql
Normal file
3
drizzle/0003_amusing_wendigo.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE `invoices` ADD `discount_value` real DEFAULT 0;--> statement-breakpoint
|
||||
ALTER TABLE `invoices` ADD `discount_type` text DEFAULT 'percentage';--> statement-breakpoint
|
||||
ALTER TABLE `invoices` ADD `discount_amount` integer DEFAULT 0;
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "cfa98c92-215e-4dbc-b8d4-23a655684d1b",
|
||||
"id": "e1e0fee4-786a-4f9f-9ebe-659aae0a55be",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"api_tokens": {
|
||||
@@ -513,6 +513,13 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo_url": {
|
||||
"name": "logo_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"street": {
|
||||
"name": "street",
|
||||
"type": "text",
|
||||
|
||||
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
1150
drizzle/meta/0002_snapshot.json
Normal file
1150
drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1174
drizzle/meta/0003_snapshot.json
Normal file
1174
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,29 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1768609277648,
|
||||
"tag": "0000_mixed_morlocks",
|
||||
"when": 1768688193284,
|
||||
"tag": "0000_motionless_king_cobra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1768690333269,
|
||||
"tag": "0001_lazy_roughhouse",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1768773436601,
|
||||
"tag": "0002_chilly_cyclops",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1768842088321,
|
||||
"tag": "0003_amusing_wendigo",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chronus",
|
||||
"type": "module",
|
||||
"version": "1.2.0",
|
||||
"version": "2.1.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
@@ -16,25 +16,26 @@
|
||||
"@astrojs/node": "^9.5.2",
|
||||
"@astrojs/vue": "^5.1.4",
|
||||
"@ceereals/vue-pdf": "^0.2.1",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"astro": "^5.16.11",
|
||||
"astro-icon": "^1.1.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.6.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"daisyui": "^5.5.14",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.26",
|
||||
"vue": "^3.5.27",
|
||||
"vue-chartjs": "^5.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@catppuccin/daisyui": "^2.1.1",
|
||||
"@iconify-json/heroicons": "^1.2.3",
|
||||
"@react-pdf/types": "^2.9.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"drizzle-kit": "0.31.8"
|
||||
}
|
||||
}
|
||||
|
||||
778
pnpm-lock.yaml
generated
778
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,45 +1,40 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { createClient } from "@libsql/client";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
const runMigrations = () => {
|
||||
console.log("Starting database migrations...");
|
||||
async function runMigrate() {
|
||||
console.log("Running migrations...");
|
||||
|
||||
const dbUrl =
|
||||
process.env.DATABASE_URL || path.resolve(process.cwd(), "chronus.db");
|
||||
const dbDir = path.dirname(dbUrl);
|
||||
const dataDir = process.env.DATA_DIR;
|
||||
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
console.log(`Creating directory for database: ${dbDir}`);
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
if (!dataDir) {
|
||||
throw new Error("DATA_DIR environment variable is not set");
|
||||
}
|
||||
|
||||
console.log(`Using database at: ${dbUrl}`);
|
||||
|
||||
const sqlite = new Database(dbUrl);
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
const migrationsFolder = path.resolve(process.cwd(), "drizzle");
|
||||
|
||||
if (!fs.existsSync(migrationsFolder)) {
|
||||
console.error(`Migrations folder not found at: ${migrationsFolder}`);
|
||||
console.error(
|
||||
"Did you run `drizzle-kit generate` and copy the folder to the container?",
|
||||
);
|
||||
process.exit(1);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const url = `file:${path.join(dataDir, "chronus.db")}`;
|
||||
console.log(`Using database: ${url}`);
|
||||
|
||||
const client = createClient({
|
||||
url,
|
||||
});
|
||||
|
||||
const db = drizzle(client);
|
||||
|
||||
try {
|
||||
migrate(db, { migrationsFolder });
|
||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||
console.log("Migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
};
|
||||
|
||||
runMigrations();
|
||||
runMigrate();
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<footer class="footer footer-center p-4 bg-base-200 text-base-content border-t border-base-300">
|
||||
<aside>
|
||||
<p class="text-sm">
|
||||
Made with <span class="text-red-500">❤️</span> by <a href="https://github.com/atridad" target="_blank" rel="noopener noreferrer" class="link link-hover font-semibold">Atridad Lahiji</a>
|
||||
</p>
|
||||
</aside>
|
||||
</footer>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
initialRunningEntry: {
|
||||
@@ -224,14 +225,16 @@ async function stopTimer() {
|
||||
@click="startTimer"
|
||||
class="btn btn-primary btn-lg min-w-40 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all"
|
||||
>
|
||||
▶️ Start Timer
|
||||
<Icon icon="heroicons:play" class="w-5 h-5" />
|
||||
Start Timer
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="stopTimer"
|
||||
class="btn btn-error btn-lg min-w-40 shadow-lg shadow-error/20 hover:shadow-xl hover:shadow-error/30 transition-all"
|
||||
>
|
||||
⏹️ Stop Timer
|
||||
<Icon icon="heroicons:stop" class="w-5 h-5" />
|
||||
Stop Timer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,42 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema';
|
||||
import path from 'path';
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { createClient } from "@libsql/client";
|
||||
import * as schema from "./schema";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let _db: ReturnType<typeof drizzle> | null = null;
|
||||
// Define the database type based on the schema
|
||||
type Database = ReturnType<typeof drizzle<typeof schema>>;
|
||||
|
||||
function initDb() {
|
||||
let _db: Database | null = null;
|
||||
|
||||
function initDb(): Database {
|
||||
if (!_db) {
|
||||
const dbUrl = process.env.DATABASE_URL || path.resolve(process.cwd(), 'chronus.db');
|
||||
const sqlite = new Database(dbUrl, { readonly: false });
|
||||
_db = drizzle(sqlite, { schema });
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: import.meta.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
throw new Error("DATA_DIR environment variable is not set");
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const url = `file:${path.join(dataDir, "chronus.db")}`;
|
||||
|
||||
const client = createClient({
|
||||
url,
|
||||
});
|
||||
|
||||
_db = drizzle(client, { schema });
|
||||
}
|
||||
return _db;
|
||||
}
|
||||
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
export const db = new Proxy({} as Database, {
|
||||
get(_target, prop) {
|
||||
const database = initDb();
|
||||
return database[prop as keyof typeof database];
|
||||
}
|
||||
return database[prop as keyof Database];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
real,
|
||||
primaryKey,
|
||||
foreignKey,
|
||||
index,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -26,6 +27,7 @@ export const organizations = sqliteTable("organizations", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
logoUrl: text("logo_url"),
|
||||
street: text("street"),
|
||||
city: text("city"),
|
||||
state: text("state"),
|
||||
@@ -56,6 +58,10 @@ export const members = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
userIdIdx: index("members_user_id_idx").on(table.userId),
|
||||
organizationIdIdx: index("members_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -68,6 +74,12 @@ export const clients = sqliteTable(
|
||||
organizationId: text("organization_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email"),
|
||||
phone: text("phone"),
|
||||
street: text("street"),
|
||||
city: text("city"),
|
||||
state: text("state"),
|
||||
zip: text("zip"),
|
||||
country: text("country"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date(),
|
||||
),
|
||||
@@ -77,6 +89,9 @@ export const clients = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
organizationIdIdx: index("clients_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -98,6 +113,9 @@ export const categories = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
organizationIdIdx: index("categories_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -136,6 +154,12 @@ export const timeEntries = sqliteTable(
|
||||
columns: [table.categoryId],
|
||||
foreignColumns: [categories.id],
|
||||
}),
|
||||
userIdIdx: index("time_entries_user_id_idx").on(table.userId),
|
||||
organizationIdIdx: index("time_entries_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
clientIdIdx: index("time_entries_client_id_idx").on(table.clientId),
|
||||
startTimeIdx: index("time_entries_start_time_idx").on(table.startTime),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -157,6 +181,9 @@ export const tags = sqliteTable(
|
||||
columns: [table.organizationId],
|
||||
foreignColumns: [organizations.id],
|
||||
}),
|
||||
organizationIdIdx: index("tags_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -176,6 +203,10 @@ export const timeEntryTags = sqliteTable(
|
||||
columns: [table.tagId],
|
||||
foreignColumns: [tags.id],
|
||||
}),
|
||||
timeEntryIdIdx: index("time_entry_tags_time_entry_id_idx").on(
|
||||
table.timeEntryId,
|
||||
),
|
||||
tagIdIdx: index("time_entry_tags_tag_id_idx").on(table.tagId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -191,6 +222,7 @@ export const sessions = sqliteTable(
|
||||
columns: [table.userId],
|
||||
foreignColumns: [users.id],
|
||||
}),
|
||||
userIdIdx: index("sessions_user_id_idx").on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -225,6 +257,7 @@ export const apiTokens = sqliteTable(
|
||||
columns: [table.userId],
|
||||
foreignColumns: [users.id],
|
||||
}),
|
||||
userIdIdx: index("api_tokens_user_id_idx").on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -244,6 +277,9 @@ export const invoices = sqliteTable(
|
||||
notes: text("notes"),
|
||||
currency: text("currency").default("USD").notNull(),
|
||||
subtotal: integer("subtotal").notNull().default(0), // in cents
|
||||
discountValue: real("discount_value").default(0),
|
||||
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed'
|
||||
discountAmount: integer("discount_amount").default(0), // in cents
|
||||
taxRate: real("tax_rate").default(0), // percentage
|
||||
taxAmount: integer("tax_amount").notNull().default(0), // in cents
|
||||
total: integer("total").notNull().default(0), // in cents
|
||||
@@ -260,6 +296,10 @@ export const invoices = sqliteTable(
|
||||
columns: [table.clientId],
|
||||
foreignColumns: [clients.id],
|
||||
}),
|
||||
organizationIdIdx: index("invoices_organization_id_idx").on(
|
||||
table.organizationId,
|
||||
),
|
||||
clientIdIdx: index("invoices_client_id_idx").on(table.clientId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -280,5 +320,6 @@ export const invoiceItems = sqliteTable(
|
||||
columns: [table.invoiceId],
|
||||
foreignColumns: [invoices.id],
|
||||
}),
|
||||
invoiceIdIdx: index("invoice_items_invoice_id_idx").on(table.invoiceId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Icon } from 'astro-icon/components';
|
||||
import { db } from '../db';
|
||||
import { members, organizations } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Avatar from '../components/Avatar.astro';
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
@@ -57,7 +56,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 px-2 flex items-center gap-2">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||
<img src="/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +72,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
<!-- Sidebar content here -->
|
||||
<li class="mb-6">
|
||||
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary hover:bg-transparent">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||
<img src="/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||
Chronus
|
||||
</a>
|
||||
</li>
|
||||
@@ -181,8 +180,8 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<form action="/api/auth/logout" method="POST">
|
||||
<button type="submit" class="w-full text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
|
||||
<form action="/api/auth/logout" method="POST" class="contents">
|
||||
<button type="submit" class="flex w-full items-center gap-2 py-2 px-4 text-error hover:bg-error/10 rounded-lg transition-colors active:bg-base-300/50!">
|
||||
<Icon name="heroicons:arrow-right-on-rectangle" class="w-5 h-5" />
|
||||
Logout
|
||||
</button>
|
||||
@@ -192,6 +191,5 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { ClientRouter } from "astro:transitions";
|
||||
|
||||
interface Props {
|
||||
@@ -21,10 +20,9 @@ const { title } = Astro.props;
|
||||
<title>{title}</title>
|
||||
<ClientRouter />
|
||||
</head>
|
||||
<body class="h-screen bg-base-100 text-base-content flex flex-col overflow-auto">
|
||||
<div class="flex-1 overflow-auto">
|
||||
<body class="min-h-screen bg-base-100 text-base-content flex flex-col">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,26 +4,15 @@
|
||||
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
|
||||
// Calculate rounded version for easy reading
|
||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||
const roundedHours = Math.floor(totalMinutes / 60);
|
||||
const roundedMinutes = totalMinutes % 60;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
let roundedStr = '';
|
||||
if (roundedHours > 0) {
|
||||
roundedStr = roundedMinutes > 0 ? `${roundedHours}h ${roundedMinutes}m` : `${roundedHours}h`;
|
||||
} else {
|
||||
roundedStr = `${roundedMinutes}m`;
|
||||
if (hours > 0) {
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
return `${timeStr} (${roundedStr})`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +22,7 @@ export function formatDuration(ms: number): string {
|
||||
* @returns Formatted duration string or "Running..."
|
||||
*/
|
||||
export function formatTimeRange(start: Date, end: Date | null): string {
|
||||
if (!end) return 'Running...';
|
||||
if (!end) return "Running...";
|
||||
const ms = end.getTime() - start.getTime();
|
||||
return formatDuration(ms);
|
||||
}
|
||||
|
||||
83
src/lib/validation.ts
Normal file
83
src/lib/validation.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { db } from "../db";
|
||||
import { clients, categories, tags as tagsTable } from "../db/schema";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
|
||||
export async function validateTimeEntryResources({
|
||||
organizationId,
|
||||
clientId,
|
||||
categoryId,
|
||||
tagIds,
|
||||
}: {
|
||||
organizationId: string;
|
||||
clientId: string;
|
||||
categoryId: string;
|
||||
tagIds?: string[];
|
||||
}) {
|
||||
const [client, category] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.id, clientId),
|
||||
eq(clients.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.get(),
|
||||
db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.get(),
|
||||
]);
|
||||
|
||||
if (!client) {
|
||||
return { valid: false, error: "Invalid client" };
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
return { valid: false, error: "Invalid category" };
|
||||
}
|
||||
|
||||
if (tagIds && tagIds.length > 0) {
|
||||
const validTags = await db
|
||||
.select()
|
||||
.from(tagsTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(tagsTable.id, tagIds),
|
||||
eq(tagsTable.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.all();
|
||||
|
||||
if (validTags.length !== tagIds.length) {
|
||||
return { valid: false, error: "Invalid tags" };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export function validateTimeRange(
|
||||
start: string | number | Date,
|
||||
end: string | number | Date,
|
||||
) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return { valid: false, error: "Invalid date format" };
|
||||
}
|
||||
|
||||
if (endDate <= startDate) {
|
||||
return { valid: false, error: "End time must be after start time" };
|
||||
}
|
||||
|
||||
return { valid: true, startDate, endDate };
|
||||
}
|
||||
@@ -41,7 +41,7 @@ const allUsers = await db.select().from(users).all();
|
||||
<form method="POST" action="/api/admin/settings">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text">
|
||||
<span class="label-text flex-1 min-w-0 pr-4">
|
||||
<div class="font-semibold">Allow New Registrations</div>
|
||||
<div class="text-sm text-gray-500">When disabled, only existing users can log in</div>
|
||||
</span>
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users } from '../../../db/schema';
|
||||
import { verifyPassword, createSession } from '../../../lib/auth';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { users } from "../../../db/schema";
|
||||
import { verifyPassword, createSession } from "../../../lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email')?.toString();
|
||||
const password = formData.get('password')?.toString();
|
||||
const email = formData.get("email")?.toString();
|
||||
const password = formData.get("password")?.toString();
|
||||
|
||||
if (!email || !password) {
|
||||
return new Response('Missing fields', { status: 400 });
|
||||
return redirect("/login?error=missing_fields");
|
||||
}
|
||||
|
||||
const user = await db.select().from(users).where(eq(users.email, email)).get();
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
|
||||
if (!user || !(await verifyPassword(password, user.passwordHash))) {
|
||||
return new Response('Invalid email or password', { status: 400 });
|
||||
return redirect("/login?error=invalid_credentials");
|
||||
}
|
||||
|
||||
const { sessionId, expiresAt } = await createSession(user.id);
|
||||
|
||||
cookies.set('session_id', sessionId, {
|
||||
path: '/',
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'lax',
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
return redirect('/dashboard');
|
||||
return redirect("/dashboard");
|
||||
};
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { users, organizations, members, siteSettings } from '../../../db/schema';
|
||||
import { hashPassword, createSession } from '../../../lib/auth';
|
||||
import { eq, count, sql } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import {
|
||||
users,
|
||||
organizations,
|
||||
members,
|
||||
siteSettings,
|
||||
} from "../../../db/schema";
|
||||
import { hashPassword, createSession } from "../../../lib/auth";
|
||||
import { eq, count, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
const userCountResult = await db.select({ count: count() }).from(users).get();
|
||||
const isFirstUser = userCountResult ? userCountResult.count === 0 : true;
|
||||
|
||||
if (!isFirstUser) {
|
||||
const registrationSetting = await db.select()
|
||||
const registrationSetting = await db
|
||||
.select()
|
||||
.from(siteSettings)
|
||||
.where(eq(siteSettings.key, 'registration_enabled'))
|
||||
.where(eq(siteSettings.key, "registration_enabled"))
|
||||
.get();
|
||||
|
||||
const registrationEnabled = registrationSetting?.value === 'true';
|
||||
const registrationEnabled = registrationSetting?.value === "true";
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return new Response('Registration is currently disabled', { status: 403 });
|
||||
return redirect("/signup?error=registration_disabled");
|
||||
}
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name')?.toString();
|
||||
const email = formData.get('email')?.toString();
|
||||
const password = formData.get('password')?.toString();
|
||||
const name = formData.get("name")?.toString();
|
||||
const email = formData.get("email")?.toString();
|
||||
const password = formData.get("password")?.toString();
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return new Response('Missing fields', { status: 400 });
|
||||
return redirect("/signup?error=missing_fields");
|
||||
}
|
||||
|
||||
const existingUser = await db.select().from(users).where(eq(users.email, email)).get();
|
||||
if (password.length < 8) {
|
||||
return redirect("/signup?error=password_too_short");
|
||||
}
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
if (existingUser) {
|
||||
return new Response('User already exists', { status: 400 });
|
||||
return redirect("/signup?error=user_exists");
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
@@ -56,18 +70,18 @@ export const POST: APIRoute = async ({ request, cookies, redirect }) => {
|
||||
await db.insert(members).values({
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
role: 'owner',
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
const { sessionId, expiresAt } = await createSession(userId);
|
||||
|
||||
cookies.set('session_id', sessionId, {
|
||||
path: '/',
|
||||
cookies.set("session_id", sessionId, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: import.meta.env.PROD,
|
||||
sameSite: 'lax',
|
||||
sameSite: "lax",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
return redirect('/dashboard');
|
||||
return redirect("/dashboard");
|
||||
};
|
||||
|
||||
@@ -16,15 +16,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
|
||||
let name: string | undefined;
|
||||
let email: string | undefined;
|
||||
let phone: string | undefined;
|
||||
let street: string | undefined;
|
||||
let city: string | undefined;
|
||||
let state: string | undefined;
|
||||
let zip: string | undefined;
|
||||
let country: string | undefined;
|
||||
|
||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
email = body.email;
|
||||
phone = body.phone;
|
||||
street = body.street;
|
||||
city = body.city;
|
||||
state = body.state;
|
||||
zip = body.zip;
|
||||
country = body.country;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
email = formData.get("email")?.toString();
|
||||
phone = formData.get("phone")?.toString();
|
||||
street = formData.get("street")?.toString();
|
||||
city = formData.get("city")?.toString();
|
||||
state = formData.get("state")?.toString();
|
||||
zip = formData.get("zip")?.toString();
|
||||
country = formData.get("country")?.toString();
|
||||
}
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
@@ -74,6 +92,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
.set({
|
||||
name: name.trim(),
|
||||
email: email?.trim() || null,
|
||||
phone: phone?.trim() || null,
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
})
|
||||
.where(eq(clients.id, id))
|
||||
.run();
|
||||
@@ -85,6 +109,12 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
||||
id,
|
||||
name: name.trim(),
|
||||
email: email?.trim() || null,
|
||||
phone: phone?.trim() || null,
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
|
||||
@@ -12,15 +12,33 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
|
||||
let name: string | undefined;
|
||||
let email: string | undefined;
|
||||
let phone: string | undefined;
|
||||
let street: string | undefined;
|
||||
let city: string | undefined;
|
||||
let state: string | undefined;
|
||||
let zip: string | undefined;
|
||||
let country: string | undefined;
|
||||
|
||||
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
name = body.name;
|
||||
email = body.email;
|
||||
phone = body.phone;
|
||||
street = body.street;
|
||||
city = body.city;
|
||||
state = body.state;
|
||||
zip = body.zip;
|
||||
country = body.country;
|
||||
} else {
|
||||
const formData = await request.formData();
|
||||
name = formData.get("name")?.toString();
|
||||
email = formData.get("email")?.toString();
|
||||
phone = formData.get("phone")?.toString();
|
||||
street = formData.get("street")?.toString();
|
||||
city = formData.get("city")?.toString();
|
||||
state = formData.get("state")?.toString();
|
||||
zip = formData.get("zip")?.toString();
|
||||
country = formData.get("country")?.toString();
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
@@ -44,13 +62,32 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
organizationId: userOrg.organizationId,
|
||||
name,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
street: street || null,
|
||||
city: city || null,
|
||||
state: state || null,
|
||||
zip: zip || null,
|
||||
country: country || null,
|
||||
});
|
||||
|
||||
if (locals.scopes) {
|
||||
return new Response(JSON.stringify({ id, name, email: email || null }), {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id,
|
||||
name,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
street: street || null,
|
||||
city: city || null,
|
||||
state: state || null,
|
||||
zip: zip || null,
|
||||
country: country || null,
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return redirect("/dashboard/clients");
|
||||
|
||||
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
|
||||
// Suppress verbose logging from PDF renderer
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
|
||||
try {
|
||||
const pdfDocument = createInvoiceDocument({
|
||||
@@ -83,6 +85,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
|
||||
// Restore console.log
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
|
||||
const filename = `${invoice.type}_${invoice.number.replace(/[^a-zA-Z0-9]/g, "_")}.pdf`;
|
||||
|
||||
@@ -95,6 +98,7 @@ export const GET: APIRoute = async ({ params, locals }) => {
|
||||
} catch (pdfError) {
|
||||
// Restore console.log on error
|
||||
console.log = originalConsoleLog;
|
||||
console.warn = originalConsoleWarn;
|
||||
throw pdfError;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -4,12 +4,7 @@ import { invoices, members } from "../../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { recalculateInvoiceTotals } from "../../../../utils/invoice";
|
||||
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
params,
|
||||
}) => {
|
||||
export const POST: APIRoute = async ({ request, redirect, locals, params }) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
@@ -38,8 +33,8 @@ export const POST: APIRoute = async ({
|
||||
.where(
|
||||
and(
|
||||
eq(members.userId, user.id),
|
||||
eq(members.organizationId, invoice.organizationId)
|
||||
)
|
||||
eq(members.organizationId, invoice.organizationId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
@@ -53,6 +48,8 @@ export const POST: APIRoute = async ({
|
||||
const issueDateStr = formData.get("issueDate") as string;
|
||||
const dueDateStr = formData.get("dueDate") as string;
|
||||
const taxRateStr = formData.get("taxRate") as string;
|
||||
const discountType = (formData.get("discountType") as string) || "percentage";
|
||||
const discountValueStr = formData.get("discountValue") as string;
|
||||
const notes = formData.get("notes") as string;
|
||||
|
||||
if (!number || !currency || !issueDateStr || !dueDateStr) {
|
||||
@@ -64,6 +61,11 @@ export const POST: APIRoute = async ({
|
||||
const dueDate = new Date(dueDateStr);
|
||||
const taxRate = taxRateStr ? parseFloat(taxRateStr) : 0;
|
||||
|
||||
let discountValue = discountValueStr ? parseFloat(discountValueStr) : 0;
|
||||
if (discountType === "fixed") {
|
||||
discountValue = Math.round(discountValue * 100);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
@@ -72,6 +74,8 @@ export const POST: APIRoute = async ({
|
||||
issueDate,
|
||||
dueDate,
|
||||
taxRate,
|
||||
discountType: discountType as "percentage" | "fixed",
|
||||
discountValue,
|
||||
notes: notes || null,
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
|
||||
@@ -3,7 +3,12 @@ import { db } from "../../../db";
|
||||
import { invoices, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export const POST: APIRoute = async ({ request, redirect, locals, cookies }) => {
|
||||
export const POST: APIRoute = async ({
|
||||
request,
|
||||
redirect,
|
||||
locals,
|
||||
cookies,
|
||||
}) => {
|
||||
const user = locals.user;
|
||||
if (!user) {
|
||||
return redirect("/login");
|
||||
@@ -36,7 +41,8 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
|
||||
}
|
||||
|
||||
const membership = currentTeamId
|
||||
? userMemberships.find((m) => m.organizationId === currentTeamId)
|
||||
? userMemberships.find((m) => m.organizationId === currentTeamId) ||
|
||||
userMemberships[0]
|
||||
: userMemberships[0];
|
||||
|
||||
if (!membership) {
|
||||
@@ -72,3 +78,7 @@ export const POST: APIRoute = async ({ request, redirect, locals, cookies }) =>
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ redirect }) => {
|
||||
return redirect("/dashboard/invoices/new");
|
||||
};
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { db } from "../../../db";
|
||||
import { organizations, members } from "../../../db/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -17,6 +19,7 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
const state = formData.get("state") as string | null;
|
||||
const zip = formData.get("zip") as string | null;
|
||||
const country = formData.get("country") as string | null;
|
||||
const logo = formData.get("logo") as File | null;
|
||||
|
||||
if (!organizationId || !name || name.trim().length === 0) {
|
||||
return new Response("Organization ID and name are required", {
|
||||
@@ -49,17 +52,59 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||
);
|
||||
}
|
||||
|
||||
let logoUrl: string | undefined;
|
||||
|
||||
if (logo && logo.size > 0) {
|
||||
const allowedTypes = ["image/png", "image/jpeg"];
|
||||
if (!allowedTypes.includes(logo.type)) {
|
||||
return new Response(
|
||||
"Invalid file type. Only PNG and JPG are allowed.",
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ext = logo.name.split(".").pop() || "png";
|
||||
const filename = `${organizationId}-${Date.now()}.${ext}`;
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: import.meta.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
throw new Error("DATA_DIR environment variable is not set");
|
||||
}
|
||||
|
||||
const uploadDir = path.join(dataDir, "uploads");
|
||||
|
||||
try {
|
||||
await fs.access(uploadDir);
|
||||
} catch {
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await logo.arrayBuffer());
|
||||
await fs.writeFile(path.join(uploadDir, filename), buffer);
|
||||
logoUrl = `/uploads/${filename}`;
|
||||
}
|
||||
|
||||
// Update organization information
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
const updateData: any = {
|
||||
name: name.trim(),
|
||||
street: street?.trim() || null,
|
||||
city: city?.trim() || null,
|
||||
state: state?.trim() || null,
|
||||
zip: zip?.trim() || null,
|
||||
country: country?.trim() || null,
|
||||
})
|
||||
};
|
||||
|
||||
if (logoUrl) {
|
||||
updateData.logoUrl = logoUrl;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(organizations)
|
||||
.set(updateData)
|
||||
.where(eq(organizations.id, organizationId))
|
||||
.run();
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db } from '../../../db';
|
||||
import { timeEntries, members, timeEntryTags, categories, clients } from '../../../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
validateTimeEntryResources,
|
||||
validateTimeRange,
|
||||
} from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
@@ -20,67 +21,47 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
// Validation
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Client is required' }),
|
||||
{
|
||||
return new Response(JSON.stringify({ error: "Client is required" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!categoryId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Category is required' }),
|
||||
{
|
||||
return new Response(JSON.stringify({ error: "Category is required" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Start time is required' }),
|
||||
{
|
||||
return new Response(JSON.stringify({ error: "Start time is required" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!endTime) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'End time is required' }),
|
||||
{
|
||||
return new Response(JSON.stringify({ error: "End time is required" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const startDate = new Date(startTime);
|
||||
const endDate = new Date(endTime);
|
||||
const timeValidation = validateTimeRange(startTime, endTime);
|
||||
|
||||
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid date format' }),
|
||||
{
|
||||
if (
|
||||
!timeValidation.valid ||
|
||||
!timeValidation.startDate ||
|
||||
!timeValidation.endDate
|
||||
) {
|
||||
return new Response(JSON.stringify({ error: timeValidation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
if (endDate <= startDate) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'End time must be after start time' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
const { startDate, endDate } = timeValidation;
|
||||
|
||||
// Get user's organization
|
||||
const member = await db
|
||||
@@ -91,57 +72,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
.get();
|
||||
|
||||
if (!member) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No organization found' }),
|
||||
{
|
||||
return new Response(JSON.stringify({ error: "No organization found" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Verify category belongs to organization
|
||||
const category = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, member.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
const resourceValidation = await validateTimeEntryResources({
|
||||
organizationId: member.organizationId,
|
||||
clientId,
|
||||
categoryId,
|
||||
tagIds: Array.isArray(tags) ? tags : undefined,
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid category' }),
|
||||
{
|
||||
if (!resourceValidation.valid) {
|
||||
return new Response(JSON.stringify({ error: resourceValidation.error }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Verify client belongs to organization
|
||||
const client = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.id, clientId),
|
||||
eq(clients.organizationId, member.organizationId)
|
||||
)
|
||||
)
|
||||
.get();
|
||||
|
||||
if (!client) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid client' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
@@ -166,7 +114,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
tags.map((tagId: string) => ({
|
||||
timeEntryId: id,
|
||||
tagId,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,17 +127,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
}),
|
||||
{
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating manual time entry:', error);
|
||||
console.error("Error creating manual time entry:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create time entry' }),
|
||||
JSON.stringify({ error: "Failed to create time entry" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db } from "../../../db";
|
||||
import {
|
||||
timeEntries,
|
||||
members,
|
||||
timeEntryTags,
|
||||
categories,
|
||||
} from "../../../db/schema";
|
||||
import { timeEntries, members, timeEntryTags } from "../../../db/schema";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { validateTimeEntryResources } from "../../../lib/validation";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (!locals.user) return new Response("Unauthorized", { status: 401 });
|
||||
@@ -48,19 +44,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return new Response("No organization found", { status: 400 });
|
||||
}
|
||||
|
||||
const category = await db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(categories.id, categoryId),
|
||||
eq(categories.organizationId, member.organizationId),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
const validation = await validateTimeEntryResources({
|
||||
organizationId: member.organizationId,
|
||||
clientId,
|
||||
categoryId,
|
||||
tagIds: tags,
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
return new Response("Invalid category", { status: 400 });
|
||||
if (!validation.valid) {
|
||||
return new Response(validation.error, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = new Date();
|
||||
|
||||
@@ -58,7 +58,7 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
name="name"
|
||||
value={client.name}
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -72,11 +72,101 @@ if (!client) return Astro.redirect('/dashboard/clients');
|
||||
id="email"
|
||||
name="email"
|
||||
value={client.email || ''}
|
||||
placeholder="contact@acme.com"
|
||||
class="input input-bordered"
|
||||
placeholder="jason.borne@cia.com"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="phone">
|
||||
<span class="label-text">Phone (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={client.phone || ''}
|
||||
placeholder="+1 (780) 420-1337"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="divider">Address Details</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="street">
|
||||
<span class="label-text">Street Address (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
value={client.street || ''}
|
||||
placeholder="123 Business Rd"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="city">
|
||||
<span class="label-text">City (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={client.city || ''}
|
||||
placeholder="Edmonton"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="state">
|
||||
<span class="label-text">State / Province (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
value={client.state || ''}
|
||||
placeholder="AB"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="zip">
|
||||
<span class="label-text">Zip / Postal Code (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
value={client.zip || ''}
|
||||
placeholder="10001"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="country">
|
||||
<span class="label-text">Country (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={client.country || ''}
|
||||
placeholder="Canada"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-between mt-6">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -86,12 +86,34 @@ const totalEntriesCount = totalEntriesResult?.count || 0;
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl mb-1">{client.name}</h2>
|
||||
<div class="space-y-2 mb-4">
|
||||
{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" />
|
||||
<a href={`mailto:${client.email}`} class="link link-hover">{client.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div class="flex items-center gap-2 text-base-content/70">
|
||||
<Icon name="heroicons:phone" class="w-4 h-4" />
|
||||
<a href={`tel:${client.phone}`} class="link link-hover">{client.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
{(client.street || client.city || client.state || client.zip || client.country) && (
|
||||
<div class="flex items-start gap-2 text-base-content/70">
|
||||
<Icon name="heroicons:map-pin" class="w-4 h-4 mt-0.5" />
|
||||
<div class="text-sm space-y-0.5">
|
||||
{client.street && <div>{client.street}</div>}
|
||||
{(client.city || client.state || client.zip) && (
|
||||
<div>
|
||||
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{client.country && <div>{client.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={`/dashboard/clients/${client.id}/edit`} class="btn btn-primary btn-sm">
|
||||
|
||||
@@ -20,7 +20,7 @@ if (!user) return Astro.redirect('/login');
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Acme Corp"
|
||||
class="input input-bordered"
|
||||
class="input input-bordered w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -33,11 +33,95 @@ if (!user) return Astro.redirect('/login');
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="contact@acme.com"
|
||||
class="input input-bordered"
|
||||
placeholder="jason.borne@cia.com"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="phone">
|
||||
<span class="label-text">Phone (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
placeholder="+1 (780) 420-1337"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="divider">Address Details</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="street">
|
||||
<span class="label-text">Street Address (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
placeholder="123 Business Rd"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="city">
|
||||
<span class="label-text">City (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
placeholder="Edmonton"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="state">
|
||||
<span class="label-text">State / Province (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
placeholder="AB"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="zip">
|
||||
<span class="label-text">Zip / Postal Code (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="zip"
|
||||
name="zip"
|
||||
placeholder="10001"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="country">
|
||||
<span class="label-text">Country (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
placeholder="Canada"
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<a href="/dashboard/clients" class="btn btn-ghost">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Create Client</button>
|
||||
|
||||
@@ -41,52 +41,48 @@ if (currentOrg) {
|
||||
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const weekEntries = await db.select()
|
||||
const weekStats = await db.select({
|
||||
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||
})
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||
gte(timeEntries.startTime, weekAgo)
|
||||
gte(timeEntries.startTime, weekAgo),
|
||||
sql`${timeEntries.endTime} IS NOT NULL`
|
||||
))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.totalTimeThisWeek = weekEntries.reduce((sum, e) => {
|
||||
if (e.endTime) {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
stats.totalTimeThisWeek = weekStats?.totalDuration || 0;
|
||||
|
||||
const monthEntries = await db.select()
|
||||
const monthStats = await db.select({
|
||||
totalDuration: sql<number>`sum(${timeEntries.endTime} - ${timeEntries.startTime})`
|
||||
})
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||
gte(timeEntries.startTime, monthAgo)
|
||||
gte(timeEntries.startTime, monthAgo),
|
||||
sql`${timeEntries.endTime} IS NOT NULL`
|
||||
))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.totalTimeThisMonth = monthEntries.reduce((sum, e) => {
|
||||
if (e.endTime) {
|
||||
return sum + (e.endTime.getTime() - e.startTime.getTime());
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
stats.totalTimeThisMonth = monthStats?.totalDuration || 0;
|
||||
|
||||
const activeCount = await db.select()
|
||||
const activeCount = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(timeEntries)
|
||||
.where(and(
|
||||
eq(timeEntries.organizationId, currentOrg.organizationId),
|
||||
isNull(timeEntries.endTime)
|
||||
))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.activeTimers = activeCount.length;
|
||||
stats.activeTimers = activeCount?.count || 0;
|
||||
|
||||
const clientCount = await db.select()
|
||||
const clientCount = await db.select({ count: sql<number>`count(*)` })
|
||||
.from(clients)
|
||||
.where(eq(clients.organizationId, currentOrg.organizationId))
|
||||
.all();
|
||||
.get();
|
||||
|
||||
stats.totalClients = clientCount.length;
|
||||
stats.totalClients = clientCount?.count || 0;
|
||||
|
||||
stats.recentEntries = await db.select({
|
||||
entry: timeEntries,
|
||||
@@ -107,7 +103,7 @@ const hasMembership = userOrgs.length > 0;
|
||||
---
|
||||
|
||||
<DashboardLayout title="Dashboard - Chronus">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-0 mb-8">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-primary mb-2">
|
||||
Dashboard
|
||||
|
||||
@@ -90,24 +90,32 @@ const isDraft = invoice.status === 'draft';
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.status === 'sent' && invoice.type === 'invoice') && (
|
||||
{(invoice.status !== 'paid' && invoice.status !== 'void' && invoice.type === 'invoice') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="paid" />
|
||||
<button type="submit" class="btn btn-success text-white">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Mark Paid
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.status === 'sent' && invoice.type === 'quote') && (
|
||||
{(invoice.status !== 'accepted' && invoice.status !== 'declined' && invoice.status !== 'void' && invoice.type === 'quote') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
<input type="hidden" name="status" value="accepted" />
|
||||
<button type="submit" class="btn btn-success text-white">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Mark Accepted
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(invoice.type === 'quote' && invoice.status === 'accepted') && (
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/convert`}>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<Icon name="heroicons:document-duplicate" class="w-5 h-5" />
|
||||
Convert to Invoice
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div class="dropdown dropdown-end">
|
||||
<div role="button" tabindex="0" class="btn btn-square btn-ghost border border-base-300">
|
||||
<Icon name="heroicons:ellipsis-horizontal" class="w-6 h-6" />
|
||||
@@ -125,12 +133,6 @@ const isDraft = invoice.status === 'draft';
|
||||
Download PDF
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" onclick="window.print()">
|
||||
<Icon name="heroicons:printer" class="w-4 h-4" />
|
||||
Print
|
||||
</button>
|
||||
</li>
|
||||
{invoice.status !== 'void' && invoice.status !== 'draft' && (
|
||||
<li>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/status`}>
|
||||
@@ -196,7 +198,19 @@ const isDraft = invoice.status === 'draft';
|
||||
{client ? (
|
||||
<div>
|
||||
<div class="font-bold text-lg">{client.name}</div>
|
||||
<div class="text-base-content/70">{client.email}</div>
|
||||
{client.email && <div class="text-base-content/70">{client.email}</div>}
|
||||
{client.phone && <div class="text-base-content/70">{client.phone}</div>}
|
||||
{(client.street || client.city || client.state || client.zip || client.country) && (
|
||||
<div class="text-sm text-base-content/70 mt-2 space-y-0.5">
|
||||
{client.street && <div>{client.street}</div>}
|
||||
{(client.city || client.state || client.zip) && (
|
||||
<div>
|
||||
{[client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{client.country && <div>{client.country}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div class="italic text-base-content/40">Client deleted</div>
|
||||
@@ -205,7 +219,8 @@ const isDraft = invoice.status === 'draft';
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="mb-8">
|
||||
<table class="w-full">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-150">
|
||||
<thead>
|
||||
<tr class="border-b-2 border-base-200 text-left text-xs font-bold uppercase tracking-wider text-base-content/40">
|
||||
<th class="py-3">Description</th>
|
||||
@@ -244,6 +259,7 @@ const isDraft = invoice.status === 'draft';
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Item Form (Only if Draft) -->
|
||||
{isDraft && (
|
||||
@@ -278,9 +294,25 @@ const isDraft = invoice.status === 'draft';
|
||||
<span class="text-base-content/60">Subtotal</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
|
||||
</div>
|
||||
{(invoice.taxRate ?? 0) > 0 && (
|
||||
{(invoice.discountAmount > 0) && (
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-base-content/60">Tax ({invoice.taxRate}%)</span>
|
||||
<span class="text-base-content/60">
|
||||
Discount
|
||||
{invoice.discountType === 'percentage' && ` (${invoice.discountValue}%)`}
|
||||
</span>
|
||||
<span class="font-medium text-success">-{formatCurrency(invoice.discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{((invoice.taxRate ?? 0) > 0 || isDraft) && (
|
||||
<div class="flex justify-between text-sm items-center group">
|
||||
<span class="text-base-content/60 flex items-center gap-2">
|
||||
Tax ({invoice.taxRate ?? 0}%)
|
||||
{isDraft && (
|
||||
<button type="button" onclick="document.getElementById('tax_modal').showModal()" class="btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity" title="Edit Tax Rate">
|
||||
<Icon name="heroicons:pencil" class="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<span class="font-medium">{formatCurrency(invoice.taxAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -303,10 +335,42 @@ const isDraft = invoice.status === 'draft';
|
||||
{/* Edit Notes (Draft Only) - Simplistic approach */}
|
||||
{isDraft && !invoice.notes && (
|
||||
<div class="mt-8 text-center">
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-ghost">Add Notes</a>
|
||||
<a href={`/dashboard/invoices/${invoice.id}/edit`} class="btn btn-sm btn-primary">Edit Details</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax Modal -->
|
||||
<dialog id="tax_modal" class="modal">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">Update Tax Rate</h3>
|
||||
<p class="py-4">Enter the tax percentage to apply to the subtotal.</p>
|
||||
<form method="POST" action={`/api/invoices/${invoice.id}/update-tax`}>
|
||||
<div class="form-control mb-6">
|
||||
<label class="label">
|
||||
<span class="label-text">Tax Rate (%)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="taxRate"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
class="input input-bordered w-full"
|
||||
value={invoice.taxRate ?? 0}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" onclick="document.getElementById('tax_modal').close()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Update</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -38,6 +38,10 @@ if (!membership) {
|
||||
// Format dates for input[type="date"]
|
||||
const issueDateStr = invoice.issueDate.toISOString().split('T')[0];
|
||||
const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
|
||||
const discountValueDisplay = invoice.discountType === 'fixed'
|
||||
? (invoice.discountValue || 0) / 100
|
||||
: (invoice.discountValue || 0);
|
||||
---
|
||||
|
||||
<DashboardLayout title={`Edit ${invoice.number} - Chronus`}>
|
||||
@@ -99,7 +103,9 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
<!-- Due Date -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Due Date</span>
|
||||
<span class="label-text font-semibold">
|
||||
{invoice.type === 'quote' ? 'Valid Until' : 'Due Date'}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -110,6 +116,27 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Discount -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Discount</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<select name="discountType" class="select select-bordered join-item">
|
||||
<option value="percentage" selected={!invoice.discountType || invoice.discountType === 'percentage'}>%</option>
|
||||
<option value="fixed" selected={invoice.discountType === 'fixed'}>Fixed</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
name="discountValue"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="input input-bordered join-item w-full"
|
||||
value={discountValueDisplay}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax Rate -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -128,7 +155,7 @@ const dueDateStr = invoice.dueDate.toISOString().split('T')[0];
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<div class="form-control flex flex-col">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Notes / Terms</span>
|
||||
</label>
|
||||
|
||||
@@ -109,6 +109,7 @@ const getStatusColor = (status: string) => {
|
||||
|
||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto md:overflow-visible pb-32 md:pb-0">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr class="bg-base-200/50">
|
||||
@@ -162,7 +163,7 @@ const getStatusColor = (status: string) => {
|
||||
<div role="button" tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||
<Icon name="heroicons:ellipsis-vertical" class="w-5 h-5" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-[100]">
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-52 border border-base-200 z-100">
|
||||
<li>
|
||||
<a href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Icon name="heroicons:eye" class="w-4 h-4" />
|
||||
@@ -212,4 +213,5 @@ const getStatusColor = (status: string) => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -47,7 +47,9 @@ if (lastInvoice) {
|
||||
const match = lastInvoice.number.match(/(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]) + 1;
|
||||
const prefix = lastInvoice.number.replace(match[0], '');
|
||||
let prefix = lastInvoice.number.replace(match[0], '');
|
||||
// Ensure we don't carry over an EST- prefix to an invoice
|
||||
if (prefix === 'EST-') prefix = 'INV-';
|
||||
nextInvoiceNumber = prefix + num.toString().padStart(match[0].length, '0');
|
||||
}
|
||||
}
|
||||
@@ -68,7 +70,9 @@ if (lastQuote) {
|
||||
const match = lastQuote.number.match(/(\d+)$/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1]) + 1;
|
||||
const prefix = lastQuote.number.replace(match[0], '');
|
||||
let prefix = lastQuote.number.replace(match[0], '');
|
||||
// Ensure we don't carry over an INV- prefix to a quote
|
||||
if (prefix === 'INV-') prefix = 'EST-';
|
||||
nextQuoteNumber = prefix + num.toString().padStart(match[0].length, '0');
|
||||
}
|
||||
}
|
||||
@@ -167,7 +171,7 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
|
||||
<!-- Due Date -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Due Date</span>
|
||||
<span class="label-text font-semibold" id="dueDateLabel">Due Date</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -212,14 +216,15 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
|
||||
// Update number based on document type
|
||||
const typeRadios = document.querySelectorAll('input[name="type"]');
|
||||
const numberInput = document.getElementById('documentNumber') as HTMLInputElement | null;
|
||||
const dueDateLabel = document.getElementById('dueDateLabel');
|
||||
|
||||
if (numberInput) {
|
||||
const invoiceNumber = numberInput.dataset.invoiceNumber || 'INV-001';
|
||||
const quoteNumber = numberInput.dataset.quoteNumber || 'EST-001';
|
||||
const invoiceNumber = numberInput?.dataset.invoiceNumber || 'INV-001';
|
||||
const quoteNumber = numberInput?.dataset.quoteNumber || 'EST-001';
|
||||
|
||||
typeRadios.forEach(radio => {
|
||||
radio.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
||||
if (numberInput) {
|
||||
if (target.value === 'quote') {
|
||||
numberInput.value = quoteNumber;
|
||||
@@ -227,7 +232,10 @@ const defaultDueDate = nextMonth.toISOString().split('T')[0];
|
||||
numberInput.value = invoiceNumber;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (dueDateLabel) {
|
||||
dueDateLabel.textContent = target.value === 'quote' ? 'Valid Until' : 'Due Date';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -318,6 +318,20 @@ function getTimeRangeLabel(range: string) {
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
form {
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const isAdmin = currentUserMember?.member.role === 'owner' || currentUserMember?
|
||||
---
|
||||
|
||||
<DashboardLayout title="Team - Chronus">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-3xl font-bold">Team Members</h1>
|
||||
<div class="flex gap-2">
|
||||
{isAdmin && (
|
||||
|
||||
@@ -67,9 +67,51 @@ const successType = url.searchParams.get('success');
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action="/api/organizations/update-name" method="POST" class="space-y-4">
|
||||
<form
|
||||
action="/api/organizations/update-name"
|
||||
method="POST"
|
||||
class="space-y-4"
|
||||
enctype="multipart/form-data"
|
||||
>
|
||||
<input type="hidden" name="organizationId" value={organization.id} />
|
||||
|
||||
<div class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Team Logo</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-base-200 text-neutral-content rounded-xl w-24 border border-base-300 flex items-center justify-center overflow-hidden">
|
||||
{organization.logoUrl ? (
|
||||
<img
|
||||
src={organization.logoUrl}
|
||||
alt={organization.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
name="heroicons:photo"
|
||||
class="w-8 h-8 opacity-40 text-base-content"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
name="logo"
|
||||
accept="image/png, image/jpeg"
|
||||
class="file-input file-input-bordered w-full max-w-xs"
|
||||
/>
|
||||
<div class="text-xs text-base-content/60 mt-2">
|
||||
Upload a company logo (PNG, JPG).
|
||||
<br />
|
||||
Will be displayed on invoices and quotes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
<span class="label-text font-medium">Team Name</span>
|
||||
@@ -158,14 +200,12 @@ const successType = url.searchParams.get('success');
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4 mt-6">
|
||||
<span class="text-xs text-base-content/60 text-center sm:text-left">
|
||||
Address information appears on invoices and quotes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
@@ -164,14 +164,16 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Timer" checked />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<span>You need to create a client before tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a client before tracking time.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
) : allCategories.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<span>You need to create a category before tracking time.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a category before tracking time.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
|
||||
</div>
|
||||
) : (
|
||||
<Timer
|
||||
@@ -192,14 +194,16 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<input type="radio" name="tracker_tabs" role="tab" class="tab text-base font-medium gap-2" aria-label="Manual Entry" />
|
||||
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6">
|
||||
{allClients.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<span>You need to create a client before adding time entries.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary">Add Client</a>
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a client before adding time entries.</span>
|
||||
<a href="/dashboard/clients/new" class="btn btn-sm btn-primary whitespace-nowrap">Add Client</a>
|
||||
</div>
|
||||
) : allCategories.length === 0 ? (
|
||||
<div class="alert alert-warning">
|
||||
<span>You need to create a category before adding time entries.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary">Team Settings</a>
|
||||
<div class="alert alert-warning flex flex-col sm:flex-row items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||
<span class="flex-1 text-center sm:text-left">You need to create a category before adding time entries.</span>
|
||||
<a href="/dashboard/team/settings" class="btn btn-sm btn-primary whitespace-nowrap">Team Settings</a>
|
||||
</div>
|
||||
) : (
|
||||
<ManualEntry
|
||||
@@ -228,7 +232,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search descriptions..."
|
||||
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors"
|
||||
class="input input-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full"
|
||||
value={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
@@ -237,7 +241,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Client</span>
|
||||
</label>
|
||||
<select name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<select name="client" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<option value="">All Clients</option>
|
||||
{allClients.map(client => (
|
||||
<option value={client.id} selected={filterClient === client.id}>
|
||||
@@ -251,7 +255,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Category</span>
|
||||
</label>
|
||||
<select name="category" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<select name="category" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<option value="">All Categories</option>
|
||||
{allCategories.map(category => (
|
||||
<option value={category.id} selected={filterCategory === category.id}>
|
||||
@@ -265,7 +269,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Status</span>
|
||||
</label>
|
||||
<select name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<select name="status" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<option value="" selected={filterStatus === ''}>All Entries</option>
|
||||
<option value="completed" selected={filterStatus === 'completed'}>Completed</option>
|
||||
<option value="running" selected={filterStatus === 'running'}>Running</option>
|
||||
@@ -276,7 +280,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Entry Type</span>
|
||||
</label>
|
||||
<select name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<select name="type" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<option value="" selected={filterType === ''}>All Types</option>
|
||||
<option value="timed" selected={filterType === 'timed'}>Timed</option>
|
||||
<option value="manual" selected={filterType === 'manual'}>Manual</option>
|
||||
@@ -287,7 +291,7 @@ const paginationPages = getPaginationPages(page, totalPages);
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Sort By</span>
|
||||
</label>
|
||||
<select name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors" onchange="this.form.submit()">
|
||||
<select name="sort" class="select select-bordered bg-base-300/50 hover:bg-base-300 focus:bg-base-300 border-base-300/50 focus:border-primary transition-colors w-full" onchange="this.form.submit()">
|
||||
<option value="start-desc" selected={sortBy === 'start-desc'}>Newest First</option>
|
||||
<option value="start-asc" selected={sortBy === 'start-asc'}>Oldest First</option>
|
||||
<option value="duration-desc" selected={sortBy === 'duration-desc'}>Longest Duration</option>
|
||||
|
||||
@@ -7,10 +7,10 @@ if (Astro.locals.user) {
|
||||
---
|
||||
|
||||
<Layout title="Chronus - Time Tracking">
|
||||
<div class="hero h-full bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
|
||||
<div class="hero flex-1 bg-linear-to-br from-base-100 via-base-200 to-base-300 flex items-center justify-center py-12">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-4xl">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
||||
<img src="/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
||||
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
|
||||
Chronus
|
||||
</h1>
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { Icon } from 'astro-icon/components';
|
||||
|
||||
if (Astro.locals.user) {
|
||||
return Astro.redirect('/dashboard');
|
||||
}
|
||||
|
||||
const error = Astro.url.searchParams.get('error');
|
||||
const errorMessage =
|
||||
error === 'invalid_credentials'
|
||||
? 'Invalid email or password'
|
||||
: error === 'missing_fields'
|
||||
? 'Please fill in all fields'
|
||||
: null;
|
||||
---
|
||||
|
||||
<Layout title="Login - Chronus">
|
||||
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
||||
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||
<div class="card-body">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
||||
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div role="alert" class="alert alert-error mb-4">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action="/api/auth/login" method="POST" class="space-y-4">
|
||||
<label class="form-control">
|
||||
<div class="label">
|
||||
|
||||
@@ -20,16 +20,33 @@ if (!isFirstUser) {
|
||||
.get();
|
||||
registrationDisabled = registrationSetting?.value !== 'true';
|
||||
}
|
||||
|
||||
const error = Astro.url.searchParams.get('error');
|
||||
const errorMessage =
|
||||
error === 'user_exists'
|
||||
? 'An account with this email already exists'
|
||||
: error === 'missing_fields'
|
||||
? 'Please fill in all fields'
|
||||
: error === 'registration_disabled'
|
||||
? 'Registration is currently disabled'
|
||||
: null;
|
||||
---
|
||||
|
||||
<Layout title="Sign Up - Chronus">
|
||||
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
||||
<div class="flex justify-center items-center flex-1 bg-base-100">
|
||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||
<div class="card-body">
|
||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<img src="/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
||||
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div role="alert" class="alert alert-error mb-4">
|
||||
<Icon name="heroicons:exclamation-circle" class="w-6 h-6" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationDisabled ? (
|
||||
<>
|
||||
<div class="alert alert-warning">
|
||||
|
||||
70
src/pages/uploads/[...path].ts
Normal file
70
src/pages/uploads/[...path].ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { promises as fs, constants } from "fs";
|
||||
import path from "path";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const filePathParam = params.path;
|
||||
|
||||
if (!filePathParam) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: import.meta.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
return new Response("DATA_DIR environment variable is not set", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const uploadDir = path.join(dataDir, "uploads");
|
||||
|
||||
const safePath = path.normalize(filePathParam).replace(/^(\.\.[\/\\])+/, "");
|
||||
const fullPath = path.join(uploadDir, safePath);
|
||||
|
||||
if (!fullPath.startsWith(uploadDir)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(fullPath, constants.R_OK);
|
||||
const fileStats = await fs.stat(fullPath);
|
||||
|
||||
if (!fileStats.isFile()) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(fullPath);
|
||||
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
let contentType = "application/octet-stream";
|
||||
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
contentType = "image/png";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
contentType = "image/jpeg";
|
||||
break;
|
||||
case ".gif":
|
||||
contentType = "image/gif";
|
||||
break;
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml";
|
||||
break;
|
||||
// WebP is intentionally omitted as it is not supported in PDF generation
|
||||
}
|
||||
|
||||
return new Response(fileContent, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { h } from "vue";
|
||||
import { Document, Page, Text, View } from "@ceereals/vue-pdf";
|
||||
import { Document, Page, Text, View, Image } from "@ceereals/vue-pdf";
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
import type { Style } from "@react-pdf/types";
|
||||
|
||||
interface InvoiceItem {
|
||||
@@ -13,6 +15,11 @@ interface InvoiceItem {
|
||||
interface Client {
|
||||
name: string;
|
||||
email: string | null;
|
||||
street: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
country: string | null;
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
@@ -22,6 +29,7 @@ interface Organization {
|
||||
state: string | null;
|
||||
zip: string | null;
|
||||
country: string | null;
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
@@ -32,6 +40,9 @@ interface Invoice {
|
||||
dueDate: Date;
|
||||
currency: string;
|
||||
subtotal: number;
|
||||
discountValue: number | null;
|
||||
discountType: string | null;
|
||||
discountAmount: number | null;
|
||||
taxRate: number | null;
|
||||
taxAmount: number;
|
||||
total: number;
|
||||
@@ -67,6 +78,12 @@ const styles = {
|
||||
flex: 1,
|
||||
maxWidth: 280,
|
||||
} as Style,
|
||||
logo: {
|
||||
height: 40,
|
||||
marginBottom: 8,
|
||||
objectFit: "contain",
|
||||
objectPosition: "left",
|
||||
} as Style,
|
||||
headerRight: {
|
||||
flex: 1,
|
||||
alignItems: "flex-end",
|
||||
@@ -84,40 +101,7 @@ const styles = {
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 12,
|
||||
} as Style,
|
||||
statusBadge: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
alignSelf: "flex-start",
|
||||
} as Style,
|
||||
statusDraft: {
|
||||
backgroundColor: "#F3F4F6",
|
||||
color: "#6B7280",
|
||||
} as Style,
|
||||
statusSent: {
|
||||
backgroundColor: "#DBEAFE",
|
||||
color: "#1E40AF",
|
||||
} as Style,
|
||||
statusPaid: {
|
||||
backgroundColor: "#D1FAE5",
|
||||
color: "#065F46",
|
||||
} as Style,
|
||||
statusAccepted: {
|
||||
backgroundColor: "#D1FAE5",
|
||||
color: "#065F46",
|
||||
} as Style,
|
||||
statusVoid: {
|
||||
backgroundColor: "#FEE2E2",
|
||||
color: "#991B1B",
|
||||
} as Style,
|
||||
statusDeclined: {
|
||||
backgroundColor: "#FEE2E2",
|
||||
color: "#991B1B",
|
||||
} as Style,
|
||||
|
||||
invoiceTypeContainer: {
|
||||
alignItems: "flex-end",
|
||||
marginBottom: 16,
|
||||
@@ -178,6 +162,11 @@ const styles = {
|
||||
fontSize: 10,
|
||||
color: "#6B7280",
|
||||
} as Style,
|
||||
clientAddress: {
|
||||
fontSize: 10,
|
||||
color: "#6B7280",
|
||||
lineHeight: 1.5,
|
||||
} as Style,
|
||||
table: {
|
||||
marginBottom: 40,
|
||||
} as Style,
|
||||
@@ -304,24 +293,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string): Style => {
|
||||
const baseStyle = styles.statusBadge;
|
||||
switch (status) {
|
||||
case "draft":
|
||||
return { ...baseStyle, ...styles.statusDraft };
|
||||
case "sent":
|
||||
return { ...baseStyle, ...styles.statusSent };
|
||||
case "paid":
|
||||
case "accepted":
|
||||
return { ...baseStyle, ...styles.statusPaid };
|
||||
case "void":
|
||||
case "declined":
|
||||
return { ...baseStyle, ...styles.statusVoid };
|
||||
default:
|
||||
return { ...baseStyle, ...styles.statusDraft };
|
||||
}
|
||||
};
|
||||
|
||||
return h(Document, [
|
||||
h(
|
||||
Page,
|
||||
@@ -330,6 +301,55 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
// Header
|
||||
h(View, { style: styles.header }, [
|
||||
h(View, { style: styles.headerLeft }, [
|
||||
(() => {
|
||||
if (organization.logoUrl) {
|
||||
try {
|
||||
let logoPath;
|
||||
// Handle uploads directory which might be external to public/
|
||||
if (organization.logoUrl.startsWith("/uploads/")) {
|
||||
const dataDir = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: import.meta.env.DATA_DIR;
|
||||
|
||||
if (!dataDir) {
|
||||
throw new Error(
|
||||
"DATA_DIR environment variable is not set",
|
||||
);
|
||||
}
|
||||
|
||||
const uploadDir = join(dataDir, "uploads");
|
||||
|
||||
const filename = organization.logoUrl.replace(
|
||||
"/uploads/",
|
||||
"",
|
||||
);
|
||||
logoPath = join(uploadDir, filename);
|
||||
} else {
|
||||
logoPath = join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
organization.logoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (existsSync(logoPath)) {
|
||||
const ext = logoPath.split(".").pop()?.toLowerCase();
|
||||
if (ext === "png" || ext === "jpg" || ext === "jpeg") {
|
||||
return h(Image, {
|
||||
src: {
|
||||
data: readFileSync(logoPath),
|
||||
format: ext === "png" ? "png" : "jpg",
|
||||
},
|
||||
style: styles.logo,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})(),
|
||||
h(Text, { style: styles.organizationName }, organization.name),
|
||||
organization.street || organization.city
|
||||
? h(
|
||||
@@ -353,9 +373,6 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
].filter(Boolean),
|
||||
)
|
||||
: null,
|
||||
h(View, { style: getStatusStyle(invoice.status) }, [
|
||||
h(Text, invoice.status),
|
||||
]),
|
||||
]),
|
||||
h(View, { style: styles.headerRight }, [
|
||||
h(View, { style: styles.invoiceTypeContainer }, [
|
||||
@@ -374,14 +391,16 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
formatDate(invoice.issueDate),
|
||||
),
|
||||
]),
|
||||
h(View, { style: styles.metaRow }, [
|
||||
invoice.type !== "quote"
|
||||
? h(View, { style: styles.metaRow }, [
|
||||
h(Text, { style: styles.metaLabel }, "Due Date"),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.metaValue },
|
||||
formatDate(invoice.dueDate),
|
||||
),
|
||||
]),
|
||||
])
|
||||
: null,
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
@@ -393,6 +412,28 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
[
|
||||
h(Text, { style: styles.sectionLabel }, "Bill To"),
|
||||
h(Text, { style: styles.clientName }, client.name),
|
||||
client.street ||
|
||||
client.city ||
|
||||
client.state ||
|
||||
client.zip ||
|
||||
client.country
|
||||
? h(
|
||||
View,
|
||||
{ style: styles.clientAddress },
|
||||
[
|
||||
client.street ? h(Text, client.street) : null,
|
||||
client.city || client.state || client.zip
|
||||
? h(
|
||||
Text,
|
||||
[client.city, client.state, client.zip]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
)
|
||||
: null,
|
||||
client.country ? h(Text, client.country) : null,
|
||||
].filter(Boolean),
|
||||
)
|
||||
: null,
|
||||
client.email
|
||||
? h(Text, { style: styles.clientEmail }, client.email)
|
||||
: null,
|
||||
@@ -465,6 +506,24 @@ export function createInvoiceDocument(props: InvoiceDocumentProps) {
|
||||
formatCurrency(invoice.subtotal),
|
||||
),
|
||||
]),
|
||||
(invoice.discountAmount ?? 0) > 0
|
||||
? h(View, { style: styles.totalRow }, [
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalLabel },
|
||||
`Discount${
|
||||
invoice.discountType === "percentage"
|
||||
? ` (${invoice.discountValue}%)`
|
||||
: ""
|
||||
}`,
|
||||
),
|
||||
h(
|
||||
Text,
|
||||
{ style: styles.totalValue },
|
||||
`-${formatCurrency(invoice.discountAmount ?? 0)}`,
|
||||
),
|
||||
])
|
||||
: null,
|
||||
(invoice.taxRate ?? 0) > 0
|
||||
? h(View, { style: styles.totalRow }, [
|
||||
h(
|
||||
|
||||
@@ -27,16 +27,34 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
|
||||
// Note: amounts are in cents
|
||||
const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
|
||||
|
||||
const taxRate = invoice.taxRate || 0;
|
||||
const taxAmount = Math.round(subtotal * (taxRate / 100));
|
||||
// Calculate discount
|
||||
const discountType = invoice.discountType || "percentage";
|
||||
const discountValue = invoice.discountValue || 0;
|
||||
let discountAmount = 0;
|
||||
|
||||
const total = subtotal + taxAmount;
|
||||
if (discountType === "percentage") {
|
||||
discountAmount = Math.round(subtotal * (discountValue / 100));
|
||||
} else {
|
||||
// Fixed amount is assumed to be in cents
|
||||
discountAmount = Math.round(discountValue);
|
||||
}
|
||||
|
||||
// Ensure discount doesn't exceed subtotal
|
||||
discountAmount = Math.max(0, Math.min(discountAmount, subtotal));
|
||||
|
||||
const taxableAmount = subtotal - discountAmount;
|
||||
|
||||
const taxRate = invoice.taxRate || 0;
|
||||
const taxAmount = Math.round(taxableAmount * (taxRate / 100));
|
||||
|
||||
const total = taxableAmount + taxAmount;
|
||||
|
||||
// Update invoice
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
subtotal,
|
||||
discountAmount,
|
||||
taxAmount,
|
||||
total,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user