Initial commit: Buildfor Life Budget app

Multi-company budget/project tracking tool built with SvelteKit 5,
PostgreSQL (Drizzle ORM), and Tailwind CSS v4.

Features:
- Auth: local (email/password with Argon2) + generic OIDC
- 4 roles per company: admin, manager, user, viewer
- Multi-company with per-company user membership
- Projects with budget allocation from company pool
- Expense submission with approval workflow
- Categories and tags for expense organization
- Reports with spending breakdowns (by category, project, time)
- CSV import for Actual Budget migration
- Company audit log tracking all budget and admin actions
- Remaining budget hero display on overview and budget pages
- Admin-only company creation; new users wait for invitation
- Deployment configs for systemd + nginx (bare metal/Proxmox)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:51:32 +07:00
commit 7a4ba0537f
86 changed files with 8963 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Server
PORT=3000
HOST=127.0.0.1
ORIGIN=http://localhost:3000
# Database
DATABASE_URL=postgresql://budget_app:password@localhost:5432/buildfor_life_budget
# OIDC (optional — leave blank to disable)
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:3000/oidc/callback
+10
View File
@@ -0,0 +1,10 @@
node_modules/
build/
.svelte-kit/
.env
.env.*
!.env.example
*.db
*.db-journal
.DS_Store
dist/
+26
View File
@@ -0,0 +1,26 @@
[Unit]
Description=Buildfor Life Budget App
After=network.target postgresql.service
[Service]
Type=simple
User=budget-app
Group=budget-app
WorkingDirectory=/opt/buildfor-life-budget
EnvironmentFile=/opt/buildfor-life-budget/.env
ExecStart=/usr/bin/node build
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=b4l-budget
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/buildfor-life-budget
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+43
View File
@@ -0,0 +1,43 @@
server {
listen 80;
server_name budget.b4l.co.th;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name budget.b4l.co.th;
ssl_certificate /etc/letsencrypt/live/budget.b4l.co.th/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/budget.b4l.co.th/privkey.pem;
# Security headers
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy strict-origin-when-cross-origin;
# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1000;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cache_bypass $http_upgrade;
}
# Cache immutable static assets
location /_app/immutable/ {
proxy_pass http://127.0.0.1:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# Buildfor Life Budget - Server Setup Script
# Run on a fresh Debian/Ubuntu Proxmox VM
set -euo pipefail
APP_USER="budget-app"
APP_DIR="/opt/buildfor-life-budget"
DOMAIN="budget.b4l.co.th"
echo "=== Installing dependencies ==="
apt-get update
apt-get install -y nginx certbot python3-certbot-nginx postgresql nodejs npm
echo "=== Setting up PostgreSQL ==="
sudo -u postgres psql <<SQL
CREATE USER budget_app WITH PASSWORD 'CHANGE_ME_IMMEDIATELY';
CREATE DATABASE buildfor_life_budget OWNER budget_app;
SQL
echo "=== Creating app user ==="
useradd --system --no-create-home --shell /usr/sbin/nologin $APP_USER
echo "=== Creating app directory ==="
mkdir -p $APP_DIR
chown $APP_USER:$APP_USER $APP_DIR
echo "=== Copying nginx config ==="
cp nginx.conf /etc/nginx/sites-available/$DOMAIN
ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
echo "=== Setting up SSL ==="
certbot --nginx -d $DOMAIN --non-interactive --agree-tos --email admin@b4l.co.th
echo "=== Installing systemd service ==="
cp buildfor-life-budget.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable buildfor-life-budget
echo ""
echo "=== Setup complete ==="
echo "Next steps:"
echo "1. Copy the built application to $APP_DIR"
echo "2. Create $APP_DIR/.env (see .env.example)"
echo "3. Run: cd $APP_DIR && npx drizzle-kit migrate"
echo "4. Run: systemctl start buildfor-life-budget"
echo "5. CHANGE the PostgreSQL password!"
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!
}
});
+4213
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "buildfor-life-budget",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"chart.js": "^4.4.7",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.38.4",
"papaparse": "^5.5.2",
"pg": "^8.13.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.15.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.3",
"@types/pg": "^8.11.11",
"@types/papaparse": "^5.3.15",
"drizzle-kit": "^0.30.5",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"tailwindcss": "^4.1.3",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
}
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+20
View File
@@ -0,0 +1,20 @@
declare global {
namespace App {
interface Locals {
user: {
id: string;
email: string;
username: string | null;
displayName: string | null;
isSystemAdmin: boolean;
} | null;
session: {
id: string;
expiresAt: Date;
fresh: boolean;
} | null;
}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
import type { Handle } from '@sveltejs/kit';
import { validateSession, setSessionCookie } from '$lib/server/auth/index.js';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session');
if (token) {
const { session, user } = await validateSession(token);
if (session) {
event.locals.user = user;
event.locals.session = session;
if (session.fresh) {
setSessionCookie(event, token, session.expiresAt);
}
} else {
event.locals.user = null;
event.locals.session = null;
event.cookies.delete('session', { path: '/' });
}
} else {
event.locals.user = null;
event.locals.session = null;
}
return resolve(event);
};
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
import type { CompanyRole } from '$lib/types/index.js';
interface Props {
user: { id: string; email: string; displayName: string | null; isSystemAdmin: boolean };
companies: Array<{ companyId: string; companyName: string; role: CompanyRole }>;
open: boolean;
onToggle: () => void;
}
let { user, companies, open, onToggle }: Props = $props();
</script>
<aside
class="flex w-64 flex-col border-r border-gray-200 bg-white transition-transform duration-200 {open
? 'translate-x-0'
: '-translate-x-full'} fixed inset-y-0 left-0 z-30 lg:relative lg:translate-x-0"
>
<!-- Logo -->
<div class="flex h-14 items-center border-b border-gray-200 px-4">
<a href="/dashboard" class="text-lg font-bold text-gray-900">B4L Budget</a>
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 py-4">
<a
href="/dashboard"
class="mb-1 flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a>
{#if companies.length > 0}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
Companies
</div>
{#each companies as company}
<a
href="/companies/{company.companyId}"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<span
class="flex h-5 w-5 items-center justify-center rounded bg-blue-100 text-xs font-medium text-blue-700"
>
{company.companyName[0]?.toUpperCase()}
</span>
<span class="truncate">{company.companyName}</span>
<span class="ml-auto text-xs text-gray-400">{company.role}</span>
</a>
{/each}
{/if}
{#if user.isSystemAdmin}
<div class="mt-4 mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
Administration
</div>
<a
href="/admin/users"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Manage Users
</a>
<a
href="/admin/settings"
class="mb-0.5 flex items-center gap-2 rounded-md px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
{/if}
</nav>
</aside>
<!-- Backdrop for mobile -->
{#if open}
<button
class="fixed inset-0 z-20 bg-black/30 lg:hidden"
onclick={onToggle}
aria-label="Close sidebar"
></button>
{/if}
+20
View File
@@ -0,0 +1,20 @@
import { db } from './db/index.js';
import { companyLog } from './db/schema.js';
type LogEvent = typeof companyLog.$inferInsert['event'];
export async function logCompanyEvent(
companyId: string,
userId: string | null,
event: LogEvent,
description: string,
metadata?: Record<string, unknown>
): Promise<void> {
await db.insert(companyLog).values({
companyId,
userId,
event,
description,
metadata: metadata ? JSON.stringify(metadata) : null
});
}
+124
View File
@@ -0,0 +1,124 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase
} from '@oslojs/encoding';
import { sha256 } from '@oslojs/crypto/sha2';
import { db } from '../db/index.js';
import { sessions, users } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import type { RequestEvent } from '@sveltejs/kit';
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const SESSION_REFRESH_MS = 15 * 24 * 60 * 60 * 1000; // 15 days
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
export function generateUserId(): string {
const bytes = new Uint8Array(15);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
function hashToken(token: string): string {
const encoded = new TextEncoder().encode(token);
return encodeHexLowerCase(sha256(encoded));
}
export async function createSession(
token: string,
userId: string
): Promise<{ id: string; userId: string; expiresAt: Date; fresh: boolean }> {
const sessionId = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS);
await db.insert(sessions).values({
id: sessionId,
userId,
expiresAt
});
return { id: sessionId, userId, expiresAt, fresh: true };
}
export async function validateSession(
token: string
): Promise<{
session: { id: string; expiresAt: Date; fresh: boolean } | null;
user: App.Locals['user'];
}> {
const sessionId = hashToken(token);
const result = await db
.select({
session: sessions,
user: {
id: users.id,
email: users.email,
username: users.username,
displayName: users.displayName,
isSystemAdmin: users.isSystemAdmin
}
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, sessionId))
.limit(1);
if (result.length === 0) {
return { session: null, user: null };
}
const { session, user } = result[0];
// Session expired
if (Date.now() >= session.expiresAt.getTime()) {
await db.delete(sessions).where(eq(sessions.id, sessionId));
return { session: null, user: null };
}
// Extend session if within refresh window
let fresh = false;
if (Date.now() >= session.expiresAt.getTime() - SESSION_REFRESH_MS) {
const newExpiry = new Date(Date.now() + SESSION_DURATION_MS);
await db
.update(sessions)
.set({ expiresAt: newExpiry })
.where(eq(sessions.id, sessionId));
session.expiresAt = newExpiry;
fresh = true;
}
return {
session: { id: session.id, expiresAt: session.expiresAt, fresh },
user
};
}
export async function invalidateSession(token: string): Promise<void> {
const sessionId = hashToken(token);
await db.delete(sessions).where(eq(sessions.id, sessionId));
}
export function setSessionCookie(event: RequestEvent, token: string, expiresAt: Date): void {
event.cookies.set('session', token, {
httpOnly: true,
sameSite: 'lax',
expires: expiresAt,
path: '/',
secure: event.url.protocol === 'https:'
});
}
export function deleteSessionCookie(event: RequestEvent): void {
event.cookies.set('session', '', {
httpOnly: true,
sameSite: 'lax',
maxAge: 0,
path: '/',
secure: event.url.protocol === 'https:'
});
}
+132
View File
@@ -0,0 +1,132 @@
import { env } from '$env/dynamic/private';
interface OIDCConfig {
issuerUrl: string;
clientId: string;
clientSecret: string;
redirectUri: string;
authorizationEndpoint: string;
tokenEndpoint: string;
userinfoEndpoint: string;
}
let oidcConfig: OIDCConfig | null = null;
export function isOIDCEnabled(): boolean {
return !!(env.OIDC_ISSUER_URL && env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET);
}
export async function getOIDCConfig(): Promise<OIDCConfig> {
if (oidcConfig) return oidcConfig;
if (!isOIDCEnabled()) {
throw new Error('OIDC is not configured');
}
const issuerUrl = env.OIDC_ISSUER_URL!.replace(/\/$/, '');
const discoveryUrl = `${issuerUrl}/.well-known/openid-configuration`;
const res = await fetch(discoveryUrl);
if (!res.ok) {
throw new Error(`Failed to fetch OIDC discovery: ${res.status}`);
}
const discovery = await res.json();
oidcConfig = {
issuerUrl,
clientId: env.OIDC_CLIENT_ID!,
clientSecret: env.OIDC_CLIENT_SECRET!,
redirectUri: env.OIDC_REDIRECT_URI || `${env.ORIGIN}/oidc/callback`,
authorizationEndpoint: discovery.authorization_endpoint,
tokenEndpoint: discovery.token_endpoint,
userinfoEndpoint: discovery.userinfo_endpoint
};
return oidcConfig;
}
export function generateState(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export function generateCodeVerifier(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoded = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', encoded);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
export async function getAuthorizationUrl(state: string, codeVerifier: string): Promise<string> {
const config = await getOIDCConfig();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: 'openid email profile',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
return `${config.authorizationEndpoint}?${params}`;
}
export async function exchangeCode(
code: string,
codeVerifier: string
): Promise<{ accessToken: string; idToken?: string }> {
const config = await getOIDCConfig();
const res = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: codeVerifier
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token exchange failed: ${res.status} ${text}`);
}
const data = await res.json();
return { accessToken: data.access_token, idToken: data.id_token };
}
export async function getUserInfo(
accessToken: string
): Promise<{ sub: string; email: string; name?: string }> {
const config = await getOIDCConfig();
const res = await fetch(config.userinfoEndpoint, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) {
throw new Error(`UserInfo request failed: ${res.status}`);
}
return res.json();
}
+16
View File
@@ -0,0 +1,16 @@
import { hash, verify } from '@node-rs/argon2';
const ARGON2_OPTIONS = {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
};
export async function hashPassword(password: string): Promise<string> {
return hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
return verify(passwordHash, password, ARGON2_OPTIONS);
}
+59
View File
@@ -0,0 +1,59 @@
import { error } from '@sveltejs/kit';
import { db } from './db/index.js';
import { companyMembers } from './db/schema.js';
import { and, eq } from 'drizzle-orm';
import { ROLE_HIERARCHY, type CompanyRole } from '$lib/types/index.js';
export function requireAuth(locals: App.Locals): NonNullable<App.Locals['user']> {
if (!locals.user) {
error(401, 'Authentication required');
}
return locals.user;
}
export function requireSystemAdmin(locals: App.Locals): NonNullable<App.Locals['user']> {
const user = requireAuth(locals);
if (!user.isSystemAdmin) {
error(403, 'System admin access required');
}
return user;
}
export async function getCompanyRole(
userId: string,
companyId: string
): Promise<CompanyRole | null> {
const result = await db
.select({ role: companyMembers.role })
.from(companyMembers)
.where(and(eq(companyMembers.userId, userId), eq(companyMembers.companyId, companyId)))
.limit(1);
if (result.length === 0) return null;
return result[0].role;
}
export async function requireCompanyRole(
locals: App.Locals,
companyId: string,
minRole: CompanyRole
): Promise<{ user: NonNullable<App.Locals['user']>; role: CompanyRole }> {
const user = requireAuth(locals);
// System admins bypass company role checks
if (user.isSystemAdmin) {
return { user, role: 'admin' };
}
const role = await getCompanyRole(user.id, companyId);
if (!role) {
error(403, 'Not a member of this company');
}
if (ROLE_HIERARCHY[role] < ROLE_HIERARCHY[minRole]) {
error(403, `Requires ${minRole} role or higher`);
}
return { user, role };
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import pg from 'pg';
import * as schema from './schema.js';
import { env } from '$env/dynamic/private';
const pool = new pg.Pool({
connectionString: env.DATABASE_URL
});
export const db = drizzle(pool, { schema });
export type Database = typeof db;
+309
View File
@@ -0,0 +1,309 @@
import { relations, sql } from 'drizzle-orm';
import {
pgTable,
pgEnum,
text,
boolean,
timestamp,
uniqueIndex,
uuid,
numeric,
date,
index,
primaryKey
} from 'drizzle-orm/pg-core';
// ── Enums ──────────────────────────────────────────────
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
// ── Users ──────────────────────────────────────────────
export const users = pgTable(
'users',
{
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').unique(),
displayName: text('display_name'),
passwordHash: text('password_hash'),
oidcProvider: text('oidc_provider'),
oidcSubject: text('oidc_subject'),
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('users_oidc_idx')
.on(table.oidcProvider, table.oidcSubject)
.where(sql`${table.oidcProvider} IS NOT NULL AND ${table.oidcSubject} IS NOT NULL`)
]
);
// ── Sessions ───────────────────────────────────────────
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
});
// ── Companies ──────────────────────────────────────────
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
currency: text('currency').notNull().default('THB'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Company Members ────────────────────────────────────
export const companyMembers = pgTable(
'company_members',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
role: companyRoleEnum('role').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
);
// ── Projects ───────────────────────────────────────────
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
allocatedBudget: numeric('allocated_budget', { precision: 15, scale: 2 }).notNull().default('0'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Categories ─────────────────────────────────────────
export const categories = pgTable(
'categories',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('categories_company_name_idx').on(table.companyId, table.name)]
);
// ── Expenses ───────────────────────────────────────────
export const expenses = pgTable(
'expenses',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id),
approvedBy: text('approved_by').references(() => users.id),
title: text('title').notNull(),
description: text('description'),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
receiptUrl: text('receipt_url'),
expenseDate: date('expense_date').notNull(),
status: expenseStatusEnum('status').notNull().default('pending'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('expenses_project_status_idx').on(table.projectId, table.status),
index('expenses_submitted_by_idx').on(table.submittedBy),
index('expenses_date_idx').on(table.expenseDate)
]
);
// ── Tags ───────────────────────────────────────────────
export const tags = pgTable(
'tags',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_company_name_idx').on(table.companyId, table.name)]
);
export const expenseTags = pgTable(
'expense_tags',
{
expenseId: uuid('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' })
},
(table) => [primaryKey({ columns: [table.expenseId, table.tagId] })]
);
// ── Budget Allocations ─────────────────────────────────
export const budgetAllocations = pgTable('budget_allocations', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
allocatedBy: text('allocated_by')
.notNull()
.references(() => users.id),
note: text('note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
// ── Company Log (Audit Trail) ──────────────────────────
export const companyLogEventEnum = pgEnum('company_log_event', [
'company_created',
'company_updated',
'budget_initial',
'budget_added',
'budget_allocated',
'budget_deallocated',
'project_created',
'project_updated',
'member_added',
'member_removed',
'member_role_changed',
'expense_submitted',
'expense_approved',
'expense_rejected',
'category_created',
'import_completed'
]);
export const companyLog = pgTable(
'company_log',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id),
event: companyLogEventEnum('event').notNull(),
description: text('description').notNull(),
metadata: text('metadata'), // JSON string for extra context (amounts, names, etc.)
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [index('company_log_company_idx').on(table.companyId, table.createdAt)]
);
// ── Relations ──────────────────────────────────────────
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
companyMemberships: many(companyMembers),
submittedExpenses: many(expenses, { relationName: 'submittedExpenses' }),
approvedExpenses: many(expenses, { relationName: 'approvedExpenses' })
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] })
}));
export const companiesRelations = relations(companies, ({ many }) => ({
members: many(companyMembers),
projects: many(projects),
categories: many(categories),
tags: many(tags),
budgetAllocations: many(budgetAllocations),
logs: many(companyLog)
}));
export const companyMembersRelations = relations(companyMembers, ({ one }) => ({
user: one(users, { fields: [companyMembers.userId], references: [users.id] }),
company: one(companies, { fields: [companyMembers.companyId], references: [companies.id] })
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
company: one(companies, { fields: [projects.companyId], references: [companies.id] }),
expenses: many(expenses),
budgetAllocations: many(budgetAllocations)
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
company: one(companies, { fields: [categories.companyId], references: [companies.id] }),
expenses: many(expenses)
}));
export const expensesRelations = relations(expenses, ({ one, many }) => ({
project: one(projects, { fields: [expenses.projectId], references: [projects.id] }),
category: one(categories, { fields: [expenses.categoryId], references: [categories.id] }),
submitter: one(users, {
fields: [expenses.submittedBy],
references: [users.id],
relationName: 'submittedExpenses'
}),
approver: one(users, {
fields: [expenses.approvedBy],
references: [users.id],
relationName: 'approvedExpenses'
}),
expenseTags: many(expenseTags)
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
company: one(companies, { fields: [tags.companyId], references: [companies.id] }),
expenseTags: many(expenseTags)
}));
export const expenseTagsRelations = relations(expenseTags, ({ one }) => ({
expense: one(expenses, { fields: [expenseTags.expenseId], references: [expenses.id] }),
tag: one(tags, { fields: [expenseTags.tagId], references: [tags.id] })
}));
export const companyLogRelations = relations(companyLog, ({ one }) => ({
company: one(companies, { fields: [companyLog.companyId], references: [companies.id] }),
user: one(users, { fields: [companyLog.userId], references: [users.id] })
}));
export const budgetAllocationsRelations = relations(budgetAllocations, ({ one }) => ({
company: one(companies, {
fields: [budgetAllocations.companyId],
references: [companies.id]
}),
project: one(projects, {
fields: [budgetAllocations.projectId],
references: [projects.id]
}),
allocator: one(users, { fields: [budgetAllocations.allocatedBy], references: [users.id] })
}));
@@ -0,0 +1,20 @@
import { pgTable, uuid, text, numeric, timestamp } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
import { projects } from './projects.js';
import { users } from './users.js';
export const budgetAllocations = pgTable('budget_allocations', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
allocatedBy: text('allocated_by')
.notNull()
.references(() => users.id),
note: text('note'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
});
+16
View File
@@ -0,0 +1,16 @@
import { pgTable, uuid, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
export const categories = pgTable(
'categories',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('categories_company_name_idx').on(table.companyId, table.name)]
);
+11
View File
@@ -0,0 +1,11 @@
import { pgTable, uuid, text, numeric, timestamp } from 'drizzle-orm/pg-core';
export const companies = pgTable('companies', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
totalBudget: numeric('total_budget', { precision: 15, scale: 2 }).notNull().default('0'),
currency: text('currency').notNull().default('THB'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
@@ -0,0 +1,21 @@
import { pgTable, uuid, text, timestamp, uniqueIndex, pgEnum } from 'drizzle-orm/pg-core';
import { users } from './users.js';
import { companies } from './companies.js';
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer']);
export const companyMembers = pgTable(
'company_members',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
role: companyRoleEnum('role').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [uniqueIndex('company_members_user_company_idx').on(table.userId, table.companyId)]
);
+46
View File
@@ -0,0 +1,46 @@
import {
pgTable,
uuid,
text,
numeric,
date,
timestamp,
index,
pgEnum
} from 'drizzle-orm/pg-core';
import { projects } from './projects.js';
import { categories } from './categories.js';
import { users } from './users.js';
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
export const expenses = pgTable(
'expenses',
{
id: uuid('id').primaryKey().defaultRandom(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
submittedBy: text('submitted_by')
.notNull()
.references(() => users.id),
approvedBy: text('approved_by').references(() => users.id),
title: text('title').notNull(),
description: text('description'),
amount: numeric('amount', { precision: 15, scale: 2 }).notNull(),
currency: text('currency').notNull(),
receiptUrl: text('receipt_url'),
expenseDate: date('expense_date').notNull(),
status: expenseStatusEnum('status').notNull().default('pending'),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
rejectionReason: text('rejection_reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
index('expenses_project_status_idx').on(table.projectId, table.status),
index('expenses_submitted_by_idx').on(table.submittedBy),
index('expenses_date_idx').on(table.expenseDate)
]
);
+9
View File
@@ -0,0 +1,9 @@
export { users } from './users.js';
export { sessions } from './sessions.js';
export { companies } from './companies.js';
export { companyRoleEnum, companyMembers } from './company-members.js';
export { projects } from './projects.js';
export { categories } from './categories.js';
export { expenseStatusEnum, expenses } from './expenses.js';
export { tags, expenseTags } from './tags.js';
export { budgetAllocations } from './budget-allocations.js';
+15
View File
@@ -0,0 +1,15 @@
import { pgTable, uuid, text, numeric, boolean, timestamp } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
allocatedBudget: numeric('allocated_budget', { precision: 15, scale: 2 }).notNull().default('0'),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
});
+83
View File
@@ -0,0 +1,83 @@
import { relations } from 'drizzle-orm';
import { users } from './users.js';
import { sessions } from './sessions.js';
import { companies } from './companies.js';
import { companyMembers } from './company-members.js';
import { projects } from './projects.js';
import { categories } from './categories.js';
import { expenses } from './expenses.js';
import { tags, expenseTags } from './tags.js';
import { budgetAllocations } from './budget-allocations.js';
export const usersRelations = relations(users, ({ many }) => ({
sessions: many(sessions),
companyMemberships: many(companyMembers),
submittedExpenses: many(expenses, { relationName: 'submittedExpenses' }),
approvedExpenses: many(expenses, { relationName: 'approvedExpenses' })
}));
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] })
}));
export const companiesRelations = relations(companies, ({ many }) => ({
members: many(companyMembers),
projects: many(projects),
categories: many(categories),
tags: many(tags),
budgetAllocations: many(budgetAllocations)
}));
export const companyMembersRelations = relations(companyMembers, ({ one }) => ({
user: one(users, { fields: [companyMembers.userId], references: [users.id] }),
company: one(companies, { fields: [companyMembers.companyId], references: [companies.id] })
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
company: one(companies, { fields: [projects.companyId], references: [companies.id] }),
expenses: many(expenses),
budgetAllocations: many(budgetAllocations)
}));
export const categoriesRelations = relations(categories, ({ one, many }) => ({
company: one(companies, { fields: [categories.companyId], references: [companies.id] }),
expenses: many(expenses)
}));
export const expensesRelations = relations(expenses, ({ one, many }) => ({
project: one(projects, { fields: [expenses.projectId], references: [projects.id] }),
category: one(categories, { fields: [expenses.categoryId], references: [categories.id] }),
submitter: one(users, {
fields: [expenses.submittedBy],
references: [users.id],
relationName: 'submittedExpenses'
}),
approver: one(users, {
fields: [expenses.approvedBy],
references: [users.id],
relationName: 'approvedExpenses'
}),
expenseTags: many(expenseTags)
}));
export const tagsRelations = relations(tags, ({ one, many }) => ({
company: one(companies, { fields: [tags.companyId], references: [companies.id] }),
expenseTags: many(expenseTags)
}));
export const expenseTagsRelations = relations(expenseTags, ({ one }) => ({
expense: one(expenses, { fields: [expenseTags.expenseId], references: [expenses.id] }),
tag: one(tags, { fields: [expenseTags.tagId], references: [tags.id] })
}));
export const budgetAllocationsRelations = relations(budgetAllocations, ({ one }) => ({
company: one(companies, {
fields: [budgetAllocations.companyId],
references: [companies.id]
}),
project: one(projects, {
fields: [budgetAllocations.projectId],
references: [projects.id]
}),
allocator: one(users, { fields: [budgetAllocations.allocatedBy], references: [users.id] })
}));
+10
View File
@@ -0,0 +1,10 @@
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { users } from './users.js';
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull()
});
+28
View File
@@ -0,0 +1,28 @@
import { pgTable, uuid, text, uniqueIndex, primaryKey } from 'drizzle-orm/pg-core';
import { companies } from './companies.js';
import { expenses } from './expenses.js';
export const tags = pgTable(
'tags',
{
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id')
.notNull()
.references(() => companies.id, { onDelete: 'cascade' }),
name: text('name').notNull()
},
(table) => [uniqueIndex('tags_company_name_idx').on(table.companyId, table.name)]
);
export const expenseTags = pgTable(
'expense_tags',
{
expenseId: uuid('expense_id')
.notNull()
.references(() => expenses.id, { onDelete: 'cascade' }),
tagId: uuid('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' })
},
(table) => [primaryKey({ columns: [table.expenseId, table.tagId] })]
);
+23
View File
@@ -0,0 +1,23 @@
import { pgTable, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const users = pgTable(
'users',
{
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
username: text('username').unique(),
displayName: text('display_name'),
passwordHash: text('password_hash'),
oidcProvider: text('oidc_provider'),
oidcSubject: text('oidc_subject'),
isSystemAdmin: boolean('is_system_admin').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('users_oidc_idx')
.on(table.oidcProvider, table.oidcSubject)
.where(sql`${table.oidcProvider} IS NOT NULL AND ${table.oidcSubject} IS NOT NULL`)
]
);
+9
View File
@@ -0,0 +1,9 @@
export type CompanyRole = 'admin' | 'manager' | 'user' | 'viewer';
export type ExpenseStatus = 'pending' | 'approved' | 'rejected';
export const ROLE_HIERARCHY: Record<CompanyRole, number> = {
admin: 4,
manager: 3,
user: 2,
viewer: 1
};
+12
View File
@@ -0,0 +1,12 @@
export function budgetPercent(spent: string | number, budget: string | number): number {
const s = typeof spent === 'string' ? parseFloat(spent) : spent;
const b = typeof budget === 'string' ? parseFloat(budget) : budget;
if (b <= 0) return 0;
return Math.min((s / b) * 100, 100);
}
export function budgetColor(pct: number): string {
if (pct > 90) return 'bg-red-500';
if (pct > 70) return 'bg-amber-500';
return 'bg-blue-500';
}
+18
View File
@@ -0,0 +1,18 @@
const CURRENCY_FORMATS: Record<string, { locale: string; minimumFractionDigits: number }> = {
THB: { locale: 'th-TH', minimumFractionDigits: 2 },
USD: { locale: 'en-US', minimumFractionDigits: 2 },
EUR: { locale: 'de-DE', minimumFractionDigits: 2 }
};
export function formatCurrency(amount: string | number, currency: string = 'THB'): string {
const num = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(num)) return `0.00 ${currency}`;
const fmt = CURRENCY_FORMATS[currency] ?? { locale: 'en-US', minimumFractionDigits: 2 };
return new Intl.NumberFormat(fmt.locale, {
style: 'currency',
currency,
minimumFractionDigits: fmt.minimumFractionDigits
}).format(num);
}
+16
View File
@@ -0,0 +1,16 @@
import { format, formatDistanceToNow } from 'date-fns';
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return format(d, 'MMM d, yyyy');
}
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return format(d, 'MMM d, yyyy HH:mm');
}
export function timeAgo(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
return formatDistanceToNow(d, { addSuffix: true });
}
+27
View File
@@ -0,0 +1,27 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyMembers, companies } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
// Load user's companies for the sidebar
const memberships = await db
.select({
companyId: companies.id,
companyName: companies.name,
role: companyMembers.role
})
.from(companyMembers)
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
.where(eq(companyMembers.userId, locals.user.id));
return {
user: locals.user,
companies: memberships
};
};
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import type { LayoutData } from './$types';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
let { data, children } = $props();
let sidebarOpen = $state(true);
</script>
<div class="flex h-screen bg-gray-50">
<Sidebar
user={data.user}
companies={data.companies}
open={sidebarOpen}
onToggle={() => (sidebarOpen = !sidebarOpen)}
/>
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top bar -->
<header class="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-6">
<button
onclick={() => (sidebarOpen = !sidebarOpen)}
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 lg:hidden"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<div class="flex items-center gap-3 ml-auto">
<span class="text-sm text-gray-700">{data.user.displayName ?? data.user.email}</span>
<form method="POST" action="/logout">
<button
type="submit"
class="rounded-md px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100"
>
Sign Out
</button>
</form>
</div>
</header>
<!-- Main content -->
<main class="flex-1 overflow-y-auto p-6">
{@render children()}
</main>
</div>
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
import { requireSystemAdmin } from '$lib/server/authorization.js';
export const load: LayoutServerLoad = async ({ locals }) => {
requireSystemAdmin(locals);
return {};
};
@@ -0,0 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
oidcConfigured: !!(process.env.OIDC_ISSUER_URL && process.env.OIDC_CLIENT_ID),
databaseUrl: process.env.DATABASE_URL ? 'Connected' : 'Not configured'
};
};
@@ -0,0 +1,39 @@
<script lang="ts">
import type { PageData } from './$types';
let { data } = $props();
</script>
<svelte:head>
<title>Settings - Admin</title>
</svelte:head>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900">System Settings</h1>
<div class="space-y-4">
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h2 class="mb-3 font-medium text-gray-900">System Status</h2>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Database</span>
<span class="font-medium text-green-600">{data.databaseUrl}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">OIDC</span>
<span class="font-medium {data.oidcConfigured ? 'text-green-600' : 'text-gray-400'}">
{data.oidcConfigured ? 'Configured' : 'Not configured'}
</span>
</div>
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h2 class="mb-3 font-medium text-gray-900">Configuration</h2>
<p class="text-sm text-gray-500">
System configuration is managed via environment variables. See the <code>.env.example</code> file
for available options.
</p>
</div>
</div>
</div>
@@ -0,0 +1,38 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
const allUsers = await db
.select({
id: users.id,
email: users.email,
username: users.username,
displayName: users.displayName,
isSystemAdmin: users.isSystemAdmin,
oidcProvider: users.oidcProvider,
createdAt: users.createdAt
})
.from(users)
.orderBy(users.email);
return { users: allUsers };
};
export const actions: Actions = {
toggleAdmin: async ({ request }) => {
const formData = await request.formData();
const userId = formData.get('userId')?.toString();
if (!userId) return fail(400, { error: 'User ID required' });
await db
.update(users)
.set({ isSystemAdmin: sql`NOT ${users.isSystemAdmin}`, updatedAt: new Date() })
.where(eq(users.id, userId));
return { success: true };
}
};
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatDateTime } from '$lib/utils/date.js';
import type { PageData } from './$types';
let { data } = $props();
</script>
<svelte:head>
<title>Users - Admin</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900">Manage Users</h1>
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr class="text-left text-gray-500">
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Auth</th>
<th class="px-4 py-3 font-medium">Admin</th>
<th class="px-4 py-3 font-medium">Joined</th>
<th class="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each data.users as user}
<tr class="border-t border-gray-100">
<td class="px-4 py-3 font-medium text-gray-900">{user.displayName ?? '—'}</td>
<td class="px-4 py-3 text-gray-500">{user.email}</td>
<td class="px-4 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium {user.oidcProvider ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-700'}">
{user.oidcProvider ? 'SSO' : 'Local'}
</span>
</td>
<td class="px-4 py-3">
{#if user.isSystemAdmin}
<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Yes</span>
{/if}
</td>
<td class="px-4 py-3 text-gray-400">{formatDateTime(user.createdAt)}</td>
<td class="px-4 py-3">
<form method="POST" action="?/toggleAdmin" use:enhance>
<input type="hidden" name="userId" value={user.id} />
<button
type="submit"
class="text-xs {user.isSystemAdmin ? 'text-red-600 hover:text-red-800' : 'text-blue-600 hover:text-blue-800'}"
>
{user.isSystemAdmin ? 'Remove Admin' : 'Make Admin'}
</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,65 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companies, companyMembers } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { logCompanyEvent } from '$lib/server/audit.js';
import { requireAuth } from '$lib/server/authorization.js';
export const load: PageServerLoad = async ({ locals }) => {
const user = requireAuth(locals);
const userCompanies = await db
.select({
id: companies.id,
name: companies.name,
description: companies.description,
totalBudget: companies.totalBudget,
currency: companies.currency,
role: companyMembers.role
})
.from(companyMembers)
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
.where(eq(companyMembers.userId, user.id));
return { companies: userCompanies, isSystemAdmin: user.isSystemAdmin };
};
export const actions: Actions = {
create: async ({ request, locals }) => {
const user = requireAuth(locals);
if (!user.isSystemAdmin) {
return fail(403, { error: 'Only system admins can create companies' });
}
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
const currency = formData.get('currency')?.toString().trim() || 'THB';
const totalBudget = formData.get('totalBudget')?.toString().trim() || '0';
if (!name) {
return fail(400, { error: 'Company name is required' });
}
const [company] = await db
.insert(companies)
.values({ name, description, currency, totalBudget })
.returning();
// Add creator as admin of the company
await db.insert(companyMembers).values({
userId: user.id,
companyId: company.id,
role: 'admin'
});
await logCompanyEvent(company.id, user.id, 'company_created', `Company "${name}" created`, { currency });
const budgetNum = parseFloat(totalBudget);
if (budgetNum > 0) {
await logCompanyEvent(company.id, user.id, 'budget_initial', `Initial budget set: ${totalBudget} ${currency}`, { amount: totalBudget, currency });
}
redirect(302, `/companies/${company.id}`);
}
};
+138
View File
@@ -0,0 +1,138 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
let showCreateModal = $state(false);
</script>
<svelte:head>
<title>Companies - B4L Budget</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Companies</h1>
{#if data.isSystemAdmin}
<button
onclick={() => (showCreateModal = true)}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
New Company
</button>
{/if}
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
{#if data.companies.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<h2 class="mt-4 text-lg font-medium text-gray-900">No companies yet</h2>
<p class="mt-2 text-sm text-gray-500">
You haven't been assigned to any company yet. Ask an administrator to invite you.
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.companies as company}
<a
href="/companies/{company.id}"
class="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
>
<h2 class="text-lg font-semibold text-gray-900">{company.name}</h2>
{#if company.description}
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{company.description}</p>
{/if}
<div class="mt-3 flex items-center justify-between text-sm">
<span class="text-gray-500">Budget: {formatCurrency(company.totalBudget, company.currency)}</span>
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{company.role}
</span>
</div>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create company modal (admin only) -->
{#if showCreateModal && data.isSystemAdmin}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Create Company</h2>
<form method="POST" action="?/create" use:enhance>
<div class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="name"
name="name"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mb-4">
<label for="description" class="mb-1 block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
<div class="mb-4 grid grid-cols-2 gap-3">
<div>
<label for="totalBudget" class="mb-1 block text-sm font-medium text-gray-700">
Initial Budget
</label>
<input
type="number"
id="totalBudget"
name="totalBudget"
step="0.01"
value="0"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="currency" class="mb-1 block text-sm font-medium text-gray-700">
Currency
</label>
<select
id="currency"
name="currency"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="THB" selected>THB</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showCreateModal = false)}
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create
</button>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companies } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { requireAuth, getCompanyRole } from '$lib/server/authorization.js';
export const load: LayoutServerLoad = async ({ locals, params }) => {
const user = requireAuth(locals);
const [company] = await db
.select()
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
if (!company) {
error(404, 'Company not found');
}
const role = user.isSystemAdmin ? 'admin' : await getCompanyRole(user.id, company.id);
if (!role) {
error(403, 'Not a member of this company');
}
return {
company: {
id: company.id,
name: company.name,
description: company.description,
totalBudget: company.totalBudget,
currency: company.currency
},
companyRole: role
};
};
@@ -0,0 +1,43 @@
<script lang="ts">
import type { LayoutData } from './$types';
let { data, children } = $props();
const tabs = $derived([
{ href: `/companies/${data.company.id}`, label: 'Overview' },
{ href: `/companies/${data.company.id}/projects`, label: 'Projects' },
{ href: `/companies/${data.company.id}/expenses`, label: 'Expenses' },
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' },
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' },
...(data.companyRole === 'admin' || data.companyRole === 'manager'
? [
{ href: `/companies/${data.company.id}/import`, label: 'Import' },
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' }
]
: [])
]);
</script>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">{data.company.name}</h1>
{#if data.company.description}
<p class="mt-1 text-sm text-gray-500">{data.company.description}</p>
{/if}
</div>
<!-- Tabs -->
<nav class="mb-6 flex gap-1 overflow-x-auto border-b border-gray-200">
{#each tabs as tab}
<a
href={tab.href}
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700"
>
{tab.label}
</a>
{/each}
</nav>
{@render children()}
</div>
@@ -0,0 +1,42 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { projects, expenses } from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => {
const { company } = await parent();
// Get projects with spent amounts
const projectList = await db
.select({
id: projects.id,
name: projects.name,
allocatedBudget: projects.allocatedBudget,
isActive: projects.isActive,
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`,
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
})
.from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, company.id))
.groupBy(projects.id)
.orderBy(projects.name);
// Recent expenses
const recentExpenses = await db
.select({
id: expenses.id,
title: expenses.title,
amount: expenses.amount,
status: expenses.status,
expenseDate: expenses.expenseDate,
projectName: projects.name
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, company.id))
.orderBy(sql`${expenses.createdAt} desc`)
.limit(10);
return { projects: projectList, recentExpenses };
};
@@ -0,0 +1,135 @@
<script lang="ts">
import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props();
const currency = $derived(data.company.currency);
const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0));
const spent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
const total = $derived(parseFloat(data.company.totalBudget));
const remaining = $derived(total - spent);
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
</script>
<svelte:head>
<title>{data.company.name} - B4L Budget</title>
</svelte:head>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Budget Summary -->
<div class="rounded-lg border-2 {remaining < 0 ? 'border-red-300 bg-red-50' : remainingPct < 20 ? 'border-amber-300 bg-amber-50' : 'border-green-300 bg-green-50'} p-5">
<h2 class="mb-1 text-sm font-semibold uppercase tracking-wider {remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-400'}">Remaining Budget</h2>
<div class="text-3xl font-bold {remaining < 0 ? 'text-red-700' : remainingPct < 20 ? 'text-amber-700' : 'text-green-700'}">
{formatCurrency(remaining, currency)}
</div>
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-white/60">
<div
class="h-full rounded-full transition-all {remaining < 0 ? 'bg-red-500' : remainingPct < 20 ? 'bg-amber-500' : 'bg-green-500'}"
style="width: {Math.max(0, Math.min(remainingPct, 100))}%"
></div>
</div>
<p class="mt-2 text-xs {remaining < 0 ? 'text-red-500' : remainingPct < 20 ? 'text-amber-500' : 'text-green-500'}">{remainingPct.toFixed(1)}% remaining</p>
<div class="mt-4 space-y-1.5 text-sm">
<div class="flex justify-between">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Total budget</span>
<span class="font-medium {remaining < 0 ? 'text-red-600' : remainingPct < 20 ? 'text-amber-600' : 'text-green-700'}">{formatCurrency(total, currency)}</span>
</div>
<div class="flex justify-between">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Total spent</span>
<span class="font-medium {remaining < 0 ? 'text-red-600' : remainingPct < 20 ? 'text-amber-600' : 'text-green-700'}">{formatCurrency(spent, currency)}</span>
</div>
<div class="flex justify-between">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Allocated</span>
<span class="font-medium {remaining < 0 ? 'text-red-600' : remainingPct < 20 ? 'text-amber-600' : 'text-green-700'}">{formatCurrency(allocated, currency)}</span>
</div>
</div>
</div>
<!-- Projects -->
<div class="rounded-lg border border-gray-200 bg-white p-5">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400">Projects</h2>
{#if data.companyRole !== 'viewer'}
<a
href="/companies/{data.company.id}/projects/new"
class="text-sm font-medium text-blue-600 hover:text-blue-700"
>
+ New Project
</a>
{/if}
</div>
{#if data.projects.length === 0}
<p class="py-4 text-center text-sm text-gray-500">No projects yet.</p>
{:else}
<div class="space-y-3">
{#each data.projects as project}
{@const budgetNum = parseFloat(project.allocatedBudget)}
{@const spentNum = parseFloat(project.spent)}
{@const pct = budgetNum > 0 ? Math.min((spentNum / budgetNum) * 100, 100) : 0}
<a href="/companies/{data.company.id}/projects/{project.id}" class="block">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-900">{project.name}</span>
<span class="text-gray-500">
{formatCurrency(project.spent, currency)} / {formatCurrency(project.allocatedBudget, currency)}
</span>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {pct}%"
></div>
</div>
{#if project.pendingCount > 0}
<p class="mt-0.5 text-xs text-amber-600">{project.pendingCount} pending</p>
{/if}
</a>
{/each}
</div>
{/if}
</div>
<!-- Recent Expenses -->
<div class="rounded-lg border border-gray-200 bg-white p-5 lg:col-span-2">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">Recent Expenses</h2>
{#if data.recentExpenses.length === 0}
<p class="py-4 text-center text-sm text-gray-500">No expenses yet.</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-100 text-left text-gray-500">
<th class="pb-2 font-medium">Title</th>
<th class="pb-2 font-medium">Project</th>
<th class="pb-2 font-medium">Amount</th>
<th class="pb-2 font-medium">Date</th>
<th class="pb-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{#each data.recentExpenses as expense}
<tr class="border-b border-gray-50">
<td class="py-2 font-medium text-gray-900">{expense.title}</td>
<td class="py-2 text-gray-500">{expense.projectName}</td>
<td class="py-2">{formatCurrency(expense.amount, currency)}</td>
<td class="py-2 text-gray-500">{expense.expenseDate}</td>
<td class="py-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{expense.status === 'approved'
? 'bg-green-100 text-green-700'
: expense.status === 'rejected'
? 'bg-red-100 text-red-700'
: 'bg-amber-100 text-amber-700'}"
>
{expense.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
@@ -0,0 +1,162 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
projects,
budgetAllocations,
companies,
users,
expenses,
companyLog
} from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js';
export const load: PageServerLoad = async ({ parent, params }) => {
const { company } = await parent();
const projectList = await db
.select({
id: projects.id,
name: projects.name,
allocatedBudget: projects.allocatedBudget,
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`
})
.from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, params.companyId))
.groupBy(projects.id)
.orderBy(projects.name);
const allocations = await db
.select({
id: budgetAllocations.id,
projectName: projects.name,
amount: budgetAllocations.amount,
allocatorName: users.displayName,
note: budgetAllocations.note,
createdAt: budgetAllocations.createdAt
})
.from(budgetAllocations)
.innerJoin(projects, eq(budgetAllocations.projectId, projects.id))
.innerJoin(users, eq(budgetAllocations.allocatedBy, users.id))
.where(eq(budgetAllocations.companyId, params.companyId))
.orderBy(sql`${budgetAllocations.createdAt} desc`)
.limit(50);
// Changelog
const changelog = await db
.select({
id: companyLog.id,
event: companyLog.event,
description: companyLog.description,
metadata: companyLog.metadata,
userName: users.displayName,
userEmail: users.email,
createdAt: companyLog.createdAt
})
.from(companyLog)
.leftJoin(users, eq(companyLog.userId, users.id))
.where(eq(companyLog.companyId, params.companyId))
.orderBy(sql`${companyLog.createdAt} desc`)
.limit(100);
const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0);
return { projects: projectList, allocations, totalAllocated, changelog };
};
export const actions: Actions = {
addBudget: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const amount = parseFloat(formData.get('amount')?.toString() || '0');
if (isNaN(amount) || amount <= 0) {
return fail(400, { error: 'Amount must be a positive number' });
}
// Get current budget for the log
const [company] = await db
.select({ totalBudget: companies.totalBudget, currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
await db
.update(companies)
.set({
totalBudget: sql`${companies.totalBudget}::numeric + ${amount.toFixed(2)}::numeric`,
updatedAt: new Date()
})
.where(eq(companies.id, params.companyId));
const newTotal = parseFloat(company.totalBudget) + amount;
await logCompanyEvent(
params.companyId,
user.id,
'budget_added',
`Budget increased by ${formatCurrency(amount, company.currency)} (new total: ${formatCurrency(newTotal, company.currency)})`,
{ amount: amount.toFixed(2), previousTotal: company.totalBudget, newTotal: newTotal.toFixed(2) }
);
return { success: true };
},
allocate: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const projectId = formData.get('projectId')?.toString();
const amount = parseFloat(formData.get('amount')?.toString() || '0');
const note = formData.get('note')?.toString().trim() || null;
if (!projectId || isNaN(amount) || amount === 0) {
return fail(400, { error: 'Project and non-zero amount are required' });
}
// Get project name and company currency for the log
const [project] = await db
.select({ name: projects.name })
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
const [company] = await db
.select({ currency: companies.currency })
.from(companies)
.where(eq(companies.id, params.companyId))
.limit(1);
await db
.update(projects)
.set({
allocatedBudget: sql`${projects.allocatedBudget}::numeric + ${amount.toFixed(2)}::numeric`,
updatedAt: new Date()
})
.where(eq(projects.id, projectId));
await db.insert(budgetAllocations).values({
companyId: params.companyId,
projectId,
amount: amount.toFixed(2),
allocatedBy: user.id,
note
});
const event = amount > 0 ? 'budget_allocated' : 'budget_deallocated';
const verb = amount > 0 ? 'Allocated' : 'Deallocated';
await logCompanyEvent(
params.companyId,
user.id,
event,
`${verb} ${formatCurrency(Math.abs(amount), company.currency)} ${amount > 0 ? 'to' : 'from'} project "${project.name}"${note ? `${note}` : ''}`,
{ amount: amount.toFixed(2), projectId, projectName: project.name }
);
return { success: true };
}
};
@@ -0,0 +1,263 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { formatCurrency } from '$lib/utils/currency.js';
import { timeAgo } from '$lib/utils/date.js';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const currency = $derived(data.company.currency);
const total = $derived(parseFloat(data.company.totalBudget));
const totalSpent = $derived(data.projects.reduce((s, p) => s + parseFloat(p.spent), 0));
const remaining = $derived(total - totalSpent);
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
const unallocated = $derived(total - data.totalAllocated);
const canAllocate = $derived(data.companyRole === 'admin' || data.companyRole === 'manager');
const isAdmin = $derived(data.companyRole === 'admin');
let showAddBudget = $state(false);
function getEventStyle(event: string) {
const styles: Record<string, { icon: string; bg: string; text: string; badge: string; label: string }> = {
company_created: { icon: '🏢', bg: 'bg-blue-100', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-700', label: 'Created' },
company_updated: { icon: '✏️', bg: 'bg-gray-100', text: 'text-gray-600', badge: 'bg-gray-100 text-gray-600', label: 'Updated' },
budget_initial: { icon: '💰', bg: 'bg-green-100', text: 'text-green-700', badge: 'bg-green-100 text-green-700', label: 'Initial Budget' },
budget_added: { icon: '', bg: 'bg-green-100', text: 'text-green-700', badge: 'bg-green-100 text-green-700', label: 'Budget Added' },
budget_allocated: { icon: '📊', bg: 'bg-blue-100', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-700', label: 'Allocated' },
budget_deallocated: { icon: '↩️', bg: 'bg-amber-100', text: 'text-amber-700', badge: 'bg-amber-100 text-amber-700', label: 'Deallocated' },
project_created: { icon: '📁', bg: 'bg-indigo-100', text: 'text-indigo-700', badge: 'bg-indigo-100 text-indigo-700', label: 'Project' },
project_updated: { icon: '📁', bg: 'bg-gray-100', text: 'text-gray-600', badge: 'bg-gray-100 text-gray-600', label: 'Project' },
member_added: { icon: '👤', bg: 'bg-purple-100', text: 'text-purple-700', badge: 'bg-purple-100 text-purple-700', label: 'Member' },
member_removed: { icon: '👤', bg: 'bg-red-100', text: 'text-red-700', badge: 'bg-red-100 text-red-700', label: 'Member' },
member_role_changed: { icon: '🔑', bg: 'bg-purple-100', text: 'text-purple-700', badge: 'bg-purple-100 text-purple-700', label: 'Role Change' },
expense_submitted: { icon: '📝', bg: 'bg-amber-100', text: 'text-amber-700', badge: 'bg-amber-100 text-amber-700', label: 'Expense' },
expense_approved: { icon: '✅', bg: 'bg-green-100', text: 'text-green-700', badge: 'bg-green-100 text-green-700', label: 'Approved' },
expense_rejected: { icon: '❌', bg: 'bg-red-100', text: 'text-red-700', badge: 'bg-red-100 text-red-700', label: 'Rejected' },
category_created: { icon: '🏷️', bg: 'bg-gray-100', text: 'text-gray-600', badge: 'bg-gray-100 text-gray-600', label: 'Category' },
import_completed: { icon: '📥', bg: 'bg-blue-100', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-700', label: 'Import' },
};
return styles[event] ?? { icon: '•', bg: 'bg-gray-100', text: 'text-gray-600', badge: 'bg-gray-100 text-gray-600', label: event };
}
</script>
<svelte:head>
<title>Budget - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Budget Allocation</h2>
{#if isAdmin}
<button
onclick={() => (showAddBudget = !showAddBudget)}
class="flex items-center gap-1 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Add Budget
</button>
{/if}
</div>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
<!-- Add Budget form (admin only) -->
{#if showAddBudget && isAdmin}
<div class="mb-6 rounded-lg border-2 border-green-200 bg-green-50 p-5">
<h3 class="mb-3 font-medium text-gray-900">Replenish Company Budget</h3>
<form method="POST" action="?/addBudget" use:enhance={() => {
return async ({ update }) => {
await update();
showAddBudget = false;
};
}} class="flex items-end gap-3">
<div class="flex-1">
<label for="addAmount" class="mb-1 block text-sm text-gray-700">Amount to Add ({currency})</label>
<input
type="number"
id="addAmount"
name="amount"
step="0.01"
min="0.01"
required
placeholder="e.g. 100000"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-green-500 focus:ring-1 focus:ring-green-500 focus:outline-none"
/>
</div>
<button
type="submit"
class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
>
Add to Budget
</button>
<button
type="button"
onclick={() => (showAddBudget = false)}
class="rounded-md px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
>
Cancel
</button>
</form>
</div>
{/if}
<!-- Summary -->
<div class="mb-6 grid gap-4 sm:grid-cols-4">
<!-- Remaining — hero card -->
<div class="rounded-lg border-2 {remaining < 0 ? 'border-red-300 bg-red-50' : remainingPct < 20 ? 'border-amber-300 bg-amber-50' : 'border-green-300 bg-green-50'} p-5 sm:col-span-2 sm:row-span-2">
<p class="text-sm font-medium {remaining < 0 ? 'text-red-600' : remainingPct < 20 ? 'text-amber-600' : 'text-green-600'}">Remaining Budget</p>
<p class="mt-1 text-4xl font-bold {remaining < 0 ? 'text-red-700' : remainingPct < 20 ? 'text-amber-700' : 'text-green-700'}">
{formatCurrency(remaining, currency)}
</p>
<div class="mt-3 h-3 w-full overflow-hidden rounded-full bg-white/60">
<div
class="h-full rounded-full transition-all {remaining < 0 ? 'bg-red-500' : remainingPct < 20 ? 'bg-amber-500' : 'bg-green-500'}"
style="width: {Math.max(0, Math.min(remainingPct, 100))}%"
></div>
</div>
<p class="mt-2 text-sm {remaining < 0 ? 'text-red-500' : remainingPct < 20 ? 'text-amber-500' : 'text-green-500'}">
{remainingPct.toFixed(1)}% of total budget remaining
</p>
</div>
<!-- Total Budget -->
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-xs font-medium uppercase tracking-wider text-gray-400">Total Budget</p>
<p class="mt-1 text-lg font-bold text-gray-900">{formatCurrency(total, currency)}</p>
</div>
<!-- Total Spent -->
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-xs font-medium uppercase tracking-wider text-gray-400">Total Spent</p>
<p class="mt-1 text-lg font-bold text-gray-900">{formatCurrency(totalSpent, currency)}</p>
</div>
<!-- Allocated to Projects -->
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-xs font-medium uppercase tracking-wider text-gray-400">Allocated</p>
<p class="mt-1 text-lg font-bold text-gray-900">{formatCurrency(data.totalAllocated, currency)}</p>
</div>
<!-- Unallocated -->
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-xs font-medium uppercase tracking-wider text-gray-400">Unallocated</p>
<p class="mt-1 text-lg font-bold {unallocated < 0 ? 'text-red-600' : 'text-gray-900'}">
{formatCurrency(unallocated, currency)}
</p>
</div>
</div>
<!-- Allocate to project form -->
{#if canAllocate && data.projects.length > 0}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5">
<h3 class="mb-3 font-medium text-gray-900">Allocate Funds to Project</h3>
<form method="POST" action="?/allocate" use:enhance class="flex flex-wrap items-end gap-3">
<div class="flex-1">
<label for="projectId" class="mb-1 block text-sm text-gray-700">Project</label>
<select
id="projectId"
name="projectId"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
>
{#each data.projects as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<div class="w-36">
<label for="amount" class="mb-1 block text-sm text-gray-700">Amount</label>
<input
type="number"
id="amount"
name="amount"
step="0.01"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
placeholder="Negative to deallocate"
/>
</div>
<div class="flex-1">
<label for="note" class="mb-1 block text-sm text-gray-700">Note</label>
<input
type="text"
id="note"
name="note"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Allocate
</button>
</form>
</div>
{/if}
<!-- Project budgets -->
<div class="mb-6 rounded-lg border border-gray-200 bg-white">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr class="text-left text-gray-500">
<th class="px-4 py-3 font-medium">Project</th>
<th class="px-4 py-3 font-medium">Allocated</th>
<th class="px-4 py-3 font-medium">Spent</th>
<th class="px-4 py-3 font-medium">Remaining</th>
<th class="px-4 py-3 font-medium">Usage</th>
</tr>
</thead>
<tbody>
{#each data.projects as project}
{@const allocated = parseFloat(project.allocatedBudget)}
{@const spent = parseFloat(project.spent)}
{@const remaining = allocated - spent}
{@const pct = allocated > 0 ? Math.min((spent / allocated) * 100, 100) : 0}
<tr class="border-t border-gray-100">
<td class="px-4 py-3 font-medium text-gray-900">{project.name}</td>
<td class="px-4 py-3">{formatCurrency(allocated, currency)}</td>
<td class="px-4 py-3">{formatCurrency(spent, currency)}</td>
<td class="px-4 py-3 {remaining < 0 ? 'text-red-600' : ''}">{formatCurrency(remaining, currency)}</td>
<td class="px-4 py-3 w-32">
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {pct}%"
></div>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Company Changelog -->
{#if data.changelog.length > 0}
<h3 class="mb-3 font-medium text-gray-900">Activity Log</h3>
<div class="rounded-lg border border-gray-200 bg-white">
<div class="divide-y divide-gray-100">
{#each data.changelog as entry}
{@const eventStyle = getEventStyle(entry.event)}
<div class="flex items-start gap-3 px-4 py-3">
<div class="mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full {eventStyle.bg}">
<span class="text-xs {eventStyle.text}">{eventStyle.icon}</span>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-900">{entry.description}</p>
<p class="mt-0.5 text-xs text-gray-400">
{entry.userName ?? entry.userEmail ?? 'System'} · {timeAgo(entry.createdAt)}
</p>
</div>
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium {eventStyle.badge}">
{eventStyle.label}
</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,56 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { categories } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ parent, params }) => {
await parent();
const categoryList = await db
.select()
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(categories.name);
return { categories: categoryList };
};
export const actions: Actions = {
create: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const color = formData.get('color')?.toString().trim() || '#6B7280';
if (!name) return fail(400, { error: 'Category name is required' });
await db.insert(categories).values({
companyId: params.companyId,
name,
color
});
await logCompanyEvent(params.companyId, user.id, 'category_created', `Category "${name}" created`);
return { success: true };
},
delete: async ({ request, locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const categoryId = formData.get('categoryId')?.toString();
if (!categoryId) return fail(400, { error: 'Missing category ID' });
await db
.delete(categories)
.where(and(eq(categories.id, categoryId), eq(categories.companyId, params.companyId)));
return { success: true };
}
};
@@ -0,0 +1,74 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const canManage = data.companyRole === 'admin' || data.companyRole === 'manager';
</script>
<svelte:head>
<title>Categories - {data.company.name}</title>
</svelte:head>
<div>
<h2 class="mb-4 text-lg font-semibold text-gray-900">Categories</h2>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
{#if canManage}
<form method="POST" action="?/create" use:enhance class="mb-6 flex items-end gap-3">
<div class="flex-1">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700">New Category</label>
<input
type="text"
id="name"
name="name"
required
placeholder="Category name"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div class="w-20">
<label for="color" class="mb-1 block text-sm font-medium text-gray-700">Color</label>
<input
type="color"
id="color"
name="color"
value="#3B82F6"
class="h-10 w-full rounded-md border border-gray-300"
/>
</div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add
</button>
</form>
{/if}
{#if data.categories.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<p class="text-gray-500">No categories yet.</p>
</div>
{:else}
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each data.categories as cat}
<div class="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-3">
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full" style="background-color: {cat.color}"></div>
<span class="text-sm font-medium text-gray-900">{cat.name}</span>
</div>
{#if canManage}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="categoryId" value={cat.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Delete</button>
</form>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,122 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { expenses, projects, users, categories } from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js';
export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent();
const status = url.searchParams.get('status') || 'all';
let query = db
.select({
id: expenses.id,
title: expenses.title,
description: expenses.description,
amount: expenses.amount,
currency: expenses.currency,
status: expenses.status,
expenseDate: expenses.expenseDate,
rejectionReason: expenses.rejectionReason,
submittedBy: expenses.submittedBy,
submitterName: users.displayName,
submitterEmail: users.email,
projectId: projects.id,
projectName: projects.name,
categoryName: categories.name,
createdAt: expenses.createdAt
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id))
.where(
status === 'all'
? eq(projects.companyId, params.companyId)
: and(
eq(projects.companyId, params.companyId),
eq(expenses.status, status as 'pending' | 'approved' | 'rejected')
)
)
.orderBy(sql`${expenses.createdAt} desc`)
.limit(100);
const expenseList = await query;
return { expenses: expenseList, statusFilter: status };
};
export const actions: Actions = {
approve: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const expenseId = formData.get('expenseId')?.toString();
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
// Get expense details for the log
const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency })
.from(expenses)
.where(eq(expenses.id, expenseId))
.limit(1);
await db
.update(expenses)
.set({
status: 'approved',
approvedBy: user.id,
reviewedAt: new Date(),
updatedAt: new Date()
})
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_approved',
`Approved expense "${expense.title}" for ${formatCurrency(expense.amount, expense.currency)}`,
{ expenseId, amount: expense.amount }
);
}
return { success: true };
},
reject: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const expenseId = formData.get('expenseId')?.toString();
const reason = formData.get('reason')?.toString().trim() || null;
if (!expenseId) return fail(400, { error: 'Missing expense ID' });
const [expense] = await db
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency })
.from(expenses)
.where(eq(expenses.id, expenseId))
.limit(1);
await db
.update(expenses)
.set({
status: 'rejected',
approvedBy: user.id,
reviewedAt: new Date(),
rejectionReason: reason,
updatedAt: new Date()
})
.where(and(eq(expenses.id, expenseId), eq(expenses.status, 'pending')));
if (expense) {
await logCompanyEvent(params.companyId, user.id, 'expense_rejected',
`Rejected expense "${expense.title}" (${formatCurrency(expense.amount, expense.currency)})${reason ? `${reason}` : ''}`,
{ expenseId, amount: expense.amount, reason }
);
}
return { success: true };
}
};
@@ -0,0 +1,112 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData } from './$types';
let { data } = $props();
const currency = data.company.currency;
const canApprove = data.companyRole === 'admin' || data.companyRole === 'manager';
</script>
<svelte:head>
<title>Expenses - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Expenses</h2>
</div>
<!-- Status filter -->
<div class="mb-4 flex gap-2">
{#each ['all', 'pending', 'approved', 'rejected'] as status}
<a
href="?status={status}"
class="rounded-full px-3 py-1 text-sm font-medium transition-colors
{data.statusFilter === status
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</a>
{/each}
</div>
{#if data.expenses.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<p class="text-gray-500">No expenses found.</p>
</div>
{:else}
<div class="space-y-3">
{#each data.expenses as expense}
<div class="rounded-lg border border-gray-200 bg-white p-4">
<div class="flex items-start justify-between">
<div>
<h3 class="font-medium text-gray-900">{expense.title}</h3>
<p class="text-sm text-gray-500">
{expense.projectName}
{#if expense.categoryName}· {expense.categoryName}{/if}
</p>
{#if expense.description}
<p class="mt-1 text-sm text-gray-400">{expense.description}</p>
{/if}
<p class="mt-1 text-xs text-gray-400">
By {expense.submitterName ?? expense.submitterEmail} · {expense.expenseDate}
</p>
</div>
<div class="text-right">
<p class="text-lg font-semibold">{formatCurrency(expense.amount, expense.currency)}</p>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{expense.status === 'approved'
? 'bg-green-100 text-green-700'
: expense.status === 'rejected'
? 'bg-red-100 text-red-700'
: 'bg-amber-100 text-amber-700'}"
>
{expense.status}
</span>
</div>
</div>
{#if expense.status === 'rejected' && expense.rejectionReason}
<div class="mt-2 rounded bg-red-50 px-3 py-2 text-sm text-red-700">
Reason: {expense.rejectionReason}
</div>
{/if}
{#if canApprove && expense.status === 'pending'}
<div class="mt-3 flex gap-2 border-t border-gray-100 pt-3">
<form method="POST" action="?/approve" use:enhance>
<input type="hidden" name="expenseId" value={expense.id} />
<button
type="submit"
class="rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
>
Approve
</button>
</form>
<form method="POST" action="?/reject" use:enhance class="flex gap-2">
<input type="hidden" name="expenseId" value={expense.id} />
<input
type="text"
name="reason"
placeholder="Rejection reason (optional)"
class="rounded-md border border-gray-300 px-2 py-1 text-sm"
/>
<button
type="submit"
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Reject
</button>
</form>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,89 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { expenses, projects, categories } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const projectList = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(eq(projects.companyId, params.companyId))
.orderBy(projects.name);
const categoryList = await db
.select({ id: categories.id, name: categories.name })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(categories.name);
return { projects: projectList, categories: categoryList };
};
export const actions: Actions = {
import: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const jsonData = formData.get('data')?.toString();
const projectId = formData.get('projectId')?.toString();
const defaultStatus = formData.get('status')?.toString() || 'approved';
if (!jsonData || !projectId) {
return fail(400, { error: 'Data and project are required' });
}
let rows: Array<{
title: string;
amount: string;
date: string;
category?: string;
description?: string;
}>;
try {
rows = JSON.parse(jsonData);
} catch {
return fail(400, { error: 'Invalid JSON data' });
}
if (!Array.isArray(rows) || rows.length === 0) {
return fail(400, { error: 'No data to import' });
}
let imported = 0;
for (const row of rows) {
if (!row.title || !row.amount || !row.date) continue;
const amount = parseFloat(row.amount);
if (isNaN(amount)) continue;
await db.insert(expenses).values({
projectId,
submittedBy: user.id,
approvedBy: defaultStatus === 'approved' ? user.id : null,
title: row.title,
description: row.description || null,
amount: Math.abs(amount).toFixed(2),
currency: 'THB',
expenseDate: row.date,
status: defaultStatus as 'pending' | 'approved' | 'rejected',
reviewedAt: defaultStatus === 'approved' ? new Date() : null
});
imported++;
}
if (imported > 0) {
await logCompanyEvent(params.companyId, user.id, 'import_completed',
`Imported ${imported} expenses (status: ${defaultStatus})`,
{ imported, projectId, defaultStatus }
);
}
return { success: true, imported };
}
};
@@ -0,0 +1,140 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
let csvText = $state('');
let parsedRows = $state<Array<Record<string, string>>>([]);
let jsonData = $state('');
function parseCSV() {
const lines = csvText.trim().split('\n');
if (lines.length < 2) {
parsedRows = [];
return;
}
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const rows = lines.slice(1).map((line) => {
const values = line.split(',');
const obj: Record<string, string> = {};
headers.forEach((h, i) => {
obj[h] = values[i]?.trim() ?? '';
});
return obj;
});
parsedRows = rows;
// Map to expected format
const mapped = rows.map((r) => ({
title: r.title || r.name || r.payee || r.memo || '',
amount: r.amount || r.total || '0',
date: r.date || r.expense_date || new Date().toISOString().split('T')[0],
description: r.description || r.notes || r.note || '',
category: r.category || r.group || ''
}));
jsonData = JSON.stringify(mapped);
}
</script>
<svelte:head>
<title>Import - {data.company.name}</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Import Expenses</h2>
<p class="mb-4 text-sm text-gray-500">
Paste CSV data from Actual Budget or any spreadsheet. Expected columns: title/name, amount, date.
Optional: description, category.
</p>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
{#if form?.success}
<div class="mb-4 rounded-md bg-green-50 p-3 text-sm text-green-700">
Successfully imported {form.imported} expenses.
</div>
{/if}
<!-- Step 1: Paste CSV -->
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5">
<h3 class="mb-2 font-medium text-gray-900">1. Paste CSV Data</h3>
<textarea
bind:value={csvText}
rows="8"
placeholder="title,amount,date,description&#10;Office Supplies,150.00,2024-01-15,Printer paper&#10;..."
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-sm"
></textarea>
<button
onclick={parseCSV}
class="mt-2 rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200"
>
Parse CSV
</button>
</div>
<!-- Step 2: Preview -->
{#if parsedRows.length > 0}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-5">
<h3 class="mb-2 font-medium text-gray-900">2. Preview ({parsedRows.length} rows)</h3>
<div class="max-h-64 overflow-auto">
<table class="w-full text-xs">
<thead class="bg-gray-50">
<tr>
{#each Object.keys(parsedRows[0]) as header}
<th class="px-2 py-1 text-left font-medium text-gray-500">{header}</th>
{/each}
</tr>
</thead>
<tbody>
{#each parsedRows.slice(0, 20) as row}
<tr class="border-t border-gray-100">
{#each Object.values(row) as val}
<td class="px-2 py-1">{val}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{#if parsedRows.length > 20}
<p class="mt-2 text-xs text-gray-400">Showing first 20 of {parsedRows.length} rows</p>
{/if}
</div>
</div>
<!-- Step 3: Import -->
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h3 class="mb-2 font-medium text-gray-900">3. Import</h3>
<form method="POST" action="?/import" use:enhance>
<input type="hidden" name="data" value={jsonData} />
<div class="mb-4 grid grid-cols-2 gap-3">
<div>
<label for="projectId" class="mb-1 block text-sm text-gray-700">Target Project</label>
<select id="projectId" name="projectId" required class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
{#each data.projects as project}
<option value={project.id}>{project.name}</option>
{/each}
</select>
</div>
<div>
<label for="status" class="mb-1 block text-sm text-gray-700">Default Status</label>
<select id="status" name="status" class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value="approved">Approved (import as finalized)</option>
<option value="pending">Pending (require approval)</option>
</select>
</div>
</div>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Import {parsedRows.length} Expenses
</button>
</form>
</div>
{/if}
</div>
@@ -0,0 +1,27 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { projects, expenses } from '$lib/server/db/schema.js';
import { eq, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent }) => {
const { company } = await parent();
const projectList = await db
.select({
id: projects.id,
name: projects.name,
description: projects.description,
allocatedBudget: projects.allocatedBudget,
isActive: projects.isActive,
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`,
expenseCount: sql<number>`count(${expenses.id})::int`,
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
})
.from(projects)
.leftJoin(expenses, eq(expenses.projectId, projects.id))
.where(eq(projects.companyId, company.id))
.groupBy(projects.id)
.orderBy(projects.name);
return { projects: projectList };
};
@@ -0,0 +1,69 @@
<script lang="ts">
import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js';
import { budgetPercent, budgetColor } from '$lib/utils/budget.js';
let { data } = $props();
const currency = data.company.currency;
</script>
<svelte:head>
<title>Projects - {data.company.name}</title>
</svelte:head>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Projects</h2>
{#if data.companyRole !== 'viewer'}
<a
href="/companies/{data.company.id}/projects/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
New Project
</a>
{/if}
</div>
{#if data.projects.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<p class="text-gray-500">No projects yet. Create your first one.</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2">
{#each data.projects as project}
<a
href="/companies/{data.company.id}/projects/{project.id}"
class="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
>
<div class="flex items-center justify-between">
<h3 class="font-semibold text-gray-900">{project.name}</h3>
{#if !project.isActive}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">Inactive</span>
{/if}
</div>
{#if project.description}
<p class="mt-1 text-sm text-gray-500 line-clamp-2">{project.description}</p>
{/if}
<div class="mt-3">
<div class="flex justify-between text-sm">
<span class="text-gray-500">Budget</span>
<span>{formatCurrency(project.spent, currency)} / {formatCurrency(project.allocatedBudget, currency)}</span>
</div>
<div class="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full {budgetColor(budgetPercent(project.spent, project.allocatedBudget))}"
style="width: {budgetPercent(project.spent, project.allocatedBudget)}%"
></div>
</div>
</div>
<div class="mt-3 flex gap-4 text-xs text-gray-400">
<span>{project.expenseCount} expenses</span>
{#if project.pendingCount > 0}
<span class="text-amber-600">{project.pendingCount} pending</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
@@ -0,0 +1,50 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { projects, expenses, users, categories } from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params, parent }) => {
await parent();
const [project] = await db
.select()
.from(projects)
.where(and(eq(projects.id, params.projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!project) {
error(404, 'Project not found');
}
const expenseList = await db
.select({
id: expenses.id,
title: expenses.title,
description: expenses.description,
amount: expenses.amount,
currency: expenses.currency,
status: expenses.status,
expenseDate: expenses.expenseDate,
submitterName: users.displayName,
submitterEmail: users.email,
categoryName: categories.name,
createdAt: expenses.createdAt
})
.from(expenses)
.innerJoin(users, eq(expenses.submittedBy, users.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id))
.where(eq(expenses.projectId, params.projectId))
.orderBy(sql`${expenses.createdAt} desc`);
const [stats] = await db
.select({
totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} else 0 end), 0)`,
totalPending: sql<string>`coalesce(sum(case when ${expenses.status} = 'pending' then ${expenses.amount} else 0 end), 0)`,
count: sql<number>`count(*)::int`
})
.from(expenses)
.where(eq(expenses.projectId, params.projectId));
return { project, expenses: expenseList, stats };
};
@@ -0,0 +1,92 @@
<script lang="ts">
import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props();
const currency = data.company.currency;
const canAddExpense = data.companyRole !== 'viewer';
</script>
<svelte:head>
<title>{data.project.name} - {data.company.name}</title>
</svelte:head>
<div>
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">{data.project.name}</h2>
{#if data.project.description}
<p class="text-sm text-gray-500">{data.project.description}</p>
{/if}
</div>
{#if canAddExpense}
<a
href="/companies/{data.company.id}/projects/{data.project.id}/expenses/new"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Add Expense
</a>
{/if}
</div>
<!-- Stats -->
<div class="mb-6 grid gap-4 sm:grid-cols-3">
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-sm text-gray-500">Budget</p>
<p class="text-xl font-bold">{formatCurrency(data.project.allocatedBudget, currency)}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-sm text-gray-500">Spent (Approved)</p>
<p class="text-xl font-bold">{formatCurrency(data.stats.totalApproved, currency)}</p>
</div>
<div class="rounded-lg border border-gray-200 bg-white p-4">
<p class="text-sm text-gray-500">Pending</p>
<p class="text-xl font-bold text-amber-600">{formatCurrency(data.stats.totalPending, currency)}</p>
</div>
</div>
<!-- Expense list -->
{#if data.expenses.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<p class="text-gray-500">No expenses yet.</p>
</div>
{:else}
<div class="overflow-x-auto rounded-lg border border-gray-200 bg-white">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr class="text-left text-gray-500">
<th class="px-4 py-3 font-medium">Title</th>
<th class="px-4 py-3 font-medium">Category</th>
<th class="px-4 py-3 font-medium">Amount</th>
<th class="px-4 py-3 font-medium">Date</th>
<th class="px-4 py-3 font-medium">Submitted By</th>
<th class="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{#each data.expenses as expense}
<tr class="border-t border-gray-100">
<td class="px-4 py-3 font-medium text-gray-900">{expense.title}</td>
<td class="px-4 py-3 text-gray-500">{expense.categoryName ?? '—'}</td>
<td class="px-4 py-3">{formatCurrency(expense.amount, expense.currency)}</td>
<td class="px-4 py-3 text-gray-500">{expense.expenseDate}</td>
<td class="px-4 py-3 text-gray-500">{expense.submitterName ?? expense.submitterEmail}</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{expense.status === 'approved'
? 'bg-green-100 text-green-700'
: expense.status === 'rejected'
? 'bg-red-100 text-red-700'
: 'bg-amber-100 text-amber-700'}"
>
{expense.status}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
@@ -0,0 +1,98 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { expenses, categories, tags, expenseTags, projects } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
import { formatCurrency } from '$lib/utils/currency.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'user');
const categoryList = await db
.select({ id: categories.id, name: categories.name, color: categories.color })
.from(categories)
.where(eq(categories.companyId, params.companyId))
.orderBy(categories.name);
const tagList = await db
.select({ id: tags.id, name: tags.name })
.from(tags)
.where(eq(tags.companyId, params.companyId))
.orderBy(tags.name);
// Get project info for the currency
const [project] = await db
.select({ name: projects.name })
.from(projects)
.where(eq(projects.id, params.projectId))
.limit(1);
return { categories: categoryList, tags: tagList, projectName: project?.name };
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'user');
const formData = await request.formData();
const title = formData.get('title')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
const amount = formData.get('amount')?.toString().trim();
const expenseDate = formData.get('expenseDate')?.toString();
const categoryId = formData.get('categoryId')?.toString() || null;
const tagIds = formData.getAll('tagIds').map((t) => t.toString());
if (!title || !amount || !expenseDate) {
return fail(400, { error: 'Title, amount, and date are required' });
}
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return fail(400, { error: 'Amount must be a positive number' });
}
// Get company currency from parent data
const [project] = await db
.select({ companyId: projects.companyId })
.from(projects)
.where(and(eq(projects.id, params.projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!project) {
return fail(400, { error: 'Project not found' });
}
const [expense] = await db
.insert(expenses)
.values({
projectId: params.projectId,
categoryId: categoryId || null,
submittedBy: user.id,
title,
description,
amount: parsedAmount.toFixed(2),
currency: 'THB', // Will use company currency
expenseDate
})
.returning();
// Add tags
if (tagIds.length > 0) {
await db.insert(expenseTags).values(
tagIds.map((tagId) => ({
expenseId: expense.id,
tagId
}))
);
}
await logCompanyEvent(params.companyId, user.id, 'expense_submitted',
`Submitted expense "${title}" for ${formatCurrency(parsedAmount, 'THB')}`,
{ expenseId: expense.id, amount: parsedAmount.toFixed(2), projectId: params.projectId }
);
redirect(302, `/companies/${params.companyId}/projects/${params.projectId}`);
}
};
@@ -0,0 +1,115 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form } = $props();
</script>
<svelte:head>
<title>New Expense - {data.projectName}</title>
</svelte:head>
<div class="mx-auto max-w-lg">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Add Expense</h2>
<p class="mb-4 text-sm text-gray-500">Project: {data.projectName}</p>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
<form method="POST" use:enhance class="rounded-lg border border-gray-200 bg-white p-6">
<div class="mb-4">
<label for="title" class="mb-1 block text-sm font-medium text-gray-700">Title</label>
<input
type="text"
id="title"
name="title"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mb-4">
<label for="description" class="mb-1 block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
<div class="mb-4 grid grid-cols-2 gap-3">
<div>
<label for="amount" class="mb-1 block text-sm font-medium text-gray-700">Amount</label>
<input
type="number"
id="amount"
name="amount"
step="0.01"
min="0.01"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="expenseDate" class="mb-1 block text-sm font-medium text-gray-700">Date</label>
<input
type="date"
id="expenseDate"
name="expenseDate"
required
value={new Date().toISOString().split('T')[0]}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="mb-4">
<label for="categoryId" class="mb-1 block text-sm font-medium text-gray-700">
Category
</label>
<select
id="categoryId"
name="categoryId"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>
<option value="">None</option>
{#each data.categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
{#if data.tags.length > 0}
<div class="mb-4">
<label class="mb-1 block text-sm font-medium text-gray-700">Tags</label>
<div class="flex flex-wrap gap-2">
{#each data.tags as tag}
<label class="flex items-center gap-1 rounded-md border border-gray-200 px-2 py-1 text-sm hover:bg-gray-50">
<input type="checkbox" name="tagIds" value={tag.id} class="rounded" />
{tag.name}
</label>
{/each}
</div>
</div>
{/if}
<div class="flex justify-end gap-2">
<a
href="javascript:history.back()"
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cancel
</a>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Submit Expense
</button>
</div>
</form>
</div>
@@ -0,0 +1,38 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { projects } from '$lib/server/db/schema.js';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
return {};
};
export const actions: Actions = {
default: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
if (!name) {
return fail(400, { error: 'Project name is required' });
}
const [project] = await db
.insert(projects)
.values({
companyId: params.companyId,
name,
description
})
.returning();
await logCompanyEvent(params.companyId, user.id, 'project_created', `Project "${name}" created`, { projectId: project.id });
redirect(302, `/companies/${params.companyId}/projects/${project.id}`);
}
};
@@ -0,0 +1,56 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form } = $props();
</script>
<svelte:head>
<title>New Project</title>
</svelte:head>
<div class="mx-auto max-w-lg">
<h2 class="mb-4 text-lg font-semibold text-gray-900">Create Project</h2>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
<form method="POST" use:enhance class="rounded-lg border border-gray-200 bg-white p-6">
<div class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700">Project Name</label>
<input
type="text"
id="name"
name="name"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mb-6">
<label for="description" class="mb-1 block text-sm font-medium text-gray-700">
Description
</label>
<textarea
id="description"
name="description"
rows="3"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
></textarea>
</div>
<div class="flex justify-end gap-2">
<a
href="javascript:history.back()"
class="rounded-md px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Cancel
</a>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Create Project
</button>
</div>
</form>
</div>
@@ -0,0 +1,71 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { expenses, projects, categories } from '$lib/server/db/schema.js';
import { eq, and, sql, gte, lte } from 'drizzle-orm';
export const load: PageServerLoad = async ({ parent, params, url }) => {
await parent();
const from = url.searchParams.get('from') || new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0];
const to = url.searchParams.get('to') || new Date().toISOString().split('T')[0];
// Spending by category
const byCategory = await db
.select({
categoryName: sql<string>`coalesce(${categories.name}, 'Uncategorized')`,
categoryColor: sql<string>`coalesce(${categories.color}, '#9CA3AF')`,
total: sql<string>`sum(${expenses.amount})`
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.leftJoin(categories, eq(expenses.categoryId, categories.id))
.where(
and(
eq(projects.companyId, params.companyId),
eq(expenses.status, 'approved'),
gte(expenses.expenseDate, from),
lte(expenses.expenseDate, to)
)
)
.groupBy(categories.name, categories.color);
// Spending by project
const byProject = await db
.select({
projectName: projects.name,
allocated: projects.allocatedBudget,
spent: sql<string>`sum(${expenses.amount})`
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(
eq(projects.companyId, params.companyId),
eq(expenses.status, 'approved'),
gte(expenses.expenseDate, from),
lte(expenses.expenseDate, to)
)
)
.groupBy(projects.id, projects.name, projects.allocatedBudget);
// Spending over time (by month)
const byMonth = await db
.select({
month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
total: sql<string>`sum(${expenses.amount})`
})
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(
eq(projects.companyId, params.companyId),
eq(expenses.status, 'approved'),
gte(expenses.expenseDate, from),
lte(expenses.expenseDate, to)
)
)
.groupBy(sql`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`)
.orderBy(sql`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`);
return { byCategory, byProject, byMonth, dateRange: { from, to } };
};
@@ -0,0 +1,139 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { formatCurrency } from '$lib/utils/currency.js';
import type { PageData } from './$types';
let { data } = $props();
const currency = data.company.currency;
let from = $state(data.dateRange.from);
let to = $state(data.dateRange.to);
function applyFilter() {
goto(`?from=${from}&to=${to}`);
}
</script>
<svelte:head>
<title>Reports - {data.company.name}</title>
</svelte:head>
<div>
<h2 class="mb-4 text-lg font-semibold text-gray-900">Reports</h2>
<!-- Date range filter -->
<div class="mb-6 flex items-end gap-3 rounded-lg border border-gray-200 bg-white p-4">
<div>
<label for="from" class="mb-1 block text-sm text-gray-700">From</label>
<input
type="date"
id="from"
bind:value={from}
class="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label for="to" class="mb-1 block text-sm text-gray-700">To</label>
<input
type="date"
id="to"
bind:value={to}
class="rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<button
onclick={applyFilter}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Apply
</button>
</div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- By Category -->
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h3 class="mb-3 font-medium text-gray-900">Spending by Category</h3>
{#if data.byCategory.length === 0}
<p class="text-sm text-gray-500">No data for this period.</p>
{:else}
<div class="space-y-2">
{#each data.byCategory as cat}
{@const total = data.byCategory.reduce((s, c) => s + parseFloat(c.total), 0)}
{@const pct = total > 0 ? (parseFloat(cat.total) / total) * 100 : 0}
<div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" style="background-color: {cat.categoryColor}"></div>
<span>{cat.categoryName}</span>
</div>
<span class="font-medium">{formatCurrency(cat.total, currency)}</span>
</div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full"
style="width: {pct}%; background-color: {cat.categoryColor}"
></div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- By Project (Budget vs Actual) -->
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h3 class="mb-3 font-medium text-gray-900">Budget vs Actual by Project</h3>
{#if data.byProject.length === 0}
<p class="text-sm text-gray-500">No data for this period.</p>
{:else}
<div class="space-y-3">
{#each data.byProject as project}
{@const allocated = parseFloat(project.allocated)}
{@const spent = parseFloat(project.spent)}
{@const pct = allocated > 0 ? Math.min((spent / allocated) * 100, 100) : 0}
<div>
<div class="flex justify-between text-sm">
<span class="font-medium">{project.projectName}</span>
<span>
{formatCurrency(spent, currency)} / {formatCurrency(allocated, currency)}
</span>
</div>
<div class="mt-1 flex gap-1">
<div class="h-3 flex-1 overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}"
style="width: {pct}%"
></div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Monthly Trend -->
<div class="rounded-lg border border-gray-200 bg-white p-5 lg:col-span-2">
<h3 class="mb-3 font-medium text-gray-900">Monthly Spending</h3>
{#if data.byMonth.length === 0}
<p class="text-sm text-gray-500">No data for this period.</p>
{:else}
{@const maxVal = Math.max(...data.byMonth.map((m) => parseFloat(m.total)))}
<div class="flex items-end gap-2" style="height: 200px;">
{#each data.byMonth as month}
{@const val = parseFloat(month.total)}
{@const height = maxVal > 0 ? (val / maxVal) * 100 : 0}
<div class="flex flex-1 flex-col items-center gap-1">
<span class="text-xs text-gray-500">{formatCurrency(val, currency)}</span>
<div
class="w-full rounded-t bg-blue-500"
style="height: {height}%"
></div>
<span class="text-xs text-gray-400">{month.month}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,149 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { companyMembers, companies, users } from '$lib/server/db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import type { CompanyRole } from '$lib/types/index.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ locals, params }) => {
await requireCompanyRole(locals, params.companyId, 'manager');
const members = await db
.select({
id: companyMembers.id,
userId: users.id,
email: users.email,
displayName: users.displayName,
role: companyMembers.role
})
.from(companyMembers)
.innerJoin(users, eq(companyMembers.userId, users.id))
.where(eq(companyMembers.companyId, params.companyId))
.orderBy(users.email);
return { members };
};
export const actions: Actions = {
updateCompany: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
if (!name) return fail(400, { error: 'Company name is required' });
await db
.update(companies)
.set({ name, description, updatedAt: new Date() })
.where(eq(companies.id, params.companyId));
await logCompanyEvent(params.companyId, user.id, 'company_updated', `Company details updated (name: "${name}")`);
return { success: true, message: 'Company updated' };
},
addMember: async ({ request, locals, params }) => {
const { user: admin } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const email = formData.get('email')?.toString().trim().toLowerCase();
const role = formData.get('role')?.toString() as CompanyRole;
if (!email || !role) return fail(400, { error: 'Email and role are required' });
const [targetUser] = await db
.select({ id: users.id, displayName: users.displayName })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!targetUser) return fail(400, { error: 'User not found. They must sign up first.' });
const existing = await db
.select({ id: companyMembers.id })
.from(companyMembers)
.where(and(eq(companyMembers.userId, targetUser.id), eq(companyMembers.companyId, params.companyId)))
.limit(1);
if (existing.length > 0) return fail(400, { error: 'User is already a member' });
await db.insert(companyMembers).values({
userId: targetUser.id,
companyId: params.companyId,
role
});
await logCompanyEvent(params.companyId, admin.id, 'member_added',
`Added ${targetUser.displayName ?? email} as ${role}`,
{ targetUserId: targetUser.id, email, role }
);
return { success: true };
},
updateRole: async ({ request, locals, params }) => {
const { user: admin } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const memberId = formData.get('memberId')?.toString();
const role = formData.get('role')?.toString() as CompanyRole;
if (!memberId || !role) return fail(400, { error: 'Member and role are required' });
// Get member info for the log
const [member] = await db
.select({ userId: companyMembers.userId, oldRole: companyMembers.role, email: users.email, displayName: users.displayName })
.from(companyMembers)
.innerJoin(users, eq(companyMembers.userId, users.id))
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
.limit(1);
await db
.update(companyMembers)
.set({ role })
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)));
if (member) {
await logCompanyEvent(params.companyId, admin.id, 'member_role_changed',
`Changed ${member.displayName ?? member.email} role from ${member.oldRole} to ${role}`,
{ targetUserId: member.userId, oldRole: member.oldRole, newRole: role }
);
}
return { success: true };
},
removeMember: async ({ request, locals, params }) => {
const { user: admin } = await requireCompanyRole(locals, params.companyId, 'admin');
const formData = await request.formData();
const memberId = formData.get('memberId')?.toString();
if (!memberId) return fail(400, { error: 'Member ID required' });
// Get member info for the log
const [member] = await db
.select({ userId: companyMembers.userId, email: users.email, displayName: users.displayName, role: companyMembers.role })
.from(companyMembers)
.innerJoin(users, eq(companyMembers.userId, users.id))
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)))
.limit(1);
await db
.delete(companyMembers)
.where(and(eq(companyMembers.id, memberId), eq(companyMembers.companyId, params.companyId)));
if (member) {
await logCompanyEvent(params.companyId, admin.id, 'member_removed',
`Removed ${member.displayName ?? member.email} (was ${member.role})`,
{ targetUserId: member.userId, role: member.role }
);
}
return { success: true };
}
};
@@ -0,0 +1,135 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form } = $props();
const isAdmin = data.companyRole === 'admin';
</script>
<svelte:head>
<title>Settings - {data.company.name}</title>
</svelte:head>
<div class="space-y-8">
{#if form?.error}
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700">{form.error}</div>
{/if}
{#if form?.message}
<div class="rounded-md bg-green-50 p-3 text-sm text-green-700">{form.message}</div>
{/if}
<!-- Company details -->
{#if isAdmin}
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h2 class="mb-4 font-semibold text-gray-900">Company Details</h2>
<form method="POST" action="?/updateCompany" use:enhance>
<div class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
id="name"
name="name"
required
value={data.company.name}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div class="mb-4">
<label for="description" class="mb-1 block text-sm font-medium text-gray-700">Description</label>
<textarea
id="description"
name="description"
rows="2"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
>{data.company.description ?? ''}</textarea>
</div>
<p class="mb-4 text-sm text-gray-500">
To add budget, go to the <a href="/companies/{data.company.id}/budget" class="font-medium text-blue-600 hover:text-blue-500">Budget</a> page and use the "+ Add Budget" button.
</p>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Save Changes
</button>
</form>
</div>
{/if}
<!-- Members -->
<div class="rounded-lg border border-gray-200 bg-white p-5">
<h2 class="mb-4 font-semibold text-gray-900">Members</h2>
{#if isAdmin}
<form method="POST" action="?/addMember" use:enhance class="mb-4 flex items-end gap-3">
<div class="flex-1">
<label for="email" class="mb-1 block text-sm text-gray-700">Add Member by Email</label>
<input
type="email"
id="email"
name="email"
required
placeholder="user@example.com"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div class="w-32">
<label for="role" class="mb-1 block text-sm text-gray-700">Role</label>
<select id="role" name="role" class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
<option value="viewer">Viewer</option>
<option value="user">User</option>
<option value="manager">Manager</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Add
</button>
</form>
{/if}
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr class="text-left text-gray-500">
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Role</th>
{#if isAdmin}
<th class="px-4 py-3 font-medium">Actions</th>
{/if}
</tr>
</thead>
<tbody>
{#each data.members as member}
<tr class="border-t border-gray-100">
<td class="px-4 py-3">{member.displayName ?? '—'}</td>
<td class="px-4 py-3 text-gray-500">{member.email}</td>
<td class="px-4 py-3">
{#if isAdmin}
<form method="POST" action="?/updateRole" use:enhance class="inline">
<input type="hidden" name="memberId" value={member.id} />
<select
name="role"
onchange={(e) => e.currentTarget.form?.requestSubmit()}
class="rounded border border-gray-300 px-2 py-1 text-sm"
>
{#each ['viewer', 'user', 'manager', 'admin'] as role}
<option value={role} selected={member.role === role}>{role}</option>
{/each}
</select>
</form>
{:else}
{member.role}
{/if}
</td>
{#if isAdmin}
<td class="px-4 py-3">
<form method="POST" action="?/removeMember" use:enhance>
<input type="hidden" name="memberId" value={member.id} />
<button type="submit" class="text-xs text-red-600 hover:text-red-800">Remove</button>
</form>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
@@ -0,0 +1,61 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import {
companyMembers,
companies,
projects,
expenses
} from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm';
export const load: PageServerLoad = async ({ locals }) => {
const userId = locals.user!.id;
// Get all companies the user belongs to with summary stats
const userCompanies = await db
.select({
id: companies.id,
name: companies.name,
totalBudget: companies.totalBudget,
currency: companies.currency,
role: companyMembers.role
})
.from(companyMembers)
.innerJoin(companies, eq(companyMembers.companyId, companies.id))
.where(eq(companyMembers.userId, userId));
// For each company, get project count and pending expense count
const companySummaries = await Promise.all(
userCompanies.map(async (company) => {
const [projectCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(projects)
.where(eq(projects.companyId, company.id));
const [pendingCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(eq(projects.companyId, company.id), eq(expenses.status, 'pending'))
);
const [approvedTotal] = await db
.select({ total: sql<string>`coalesce(sum(${expenses.amount}), 0)` })
.from(expenses)
.innerJoin(projects, eq(expenses.projectId, projects.id))
.where(
and(eq(projects.companyId, company.id), eq(expenses.status, 'approved'))
);
return {
...company,
projectCount: projectCount.count,
pendingExpenses: pendingCount.count,
totalSpent: approvedTotal.total
};
})
);
return { companySummaries };
};
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js';
import { budgetPercent, budgetColor } from '$lib/utils/budget.js';
let { data } = $props();
</script>
<svelte:head>
<title>Dashboard - B4L Budget</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
{#if data.user?.isSystemAdmin}
<a
href="/companies"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Manage Companies
</a>
{/if}
</div>
{#if data.companySummaries.length === 0}
<div class="rounded-lg border border-gray-200 bg-white p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 class="mt-4 text-lg font-medium text-gray-900">Waiting for access</h2>
<p class="mt-2 text-sm text-gray-500">
You haven't been assigned to any company yet. Ask an administrator to invite you.
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.companySummaries as company}
<a
href="/companies/{company.id}"
class="rounded-lg border border-gray-200 bg-white p-6 transition-shadow hover:shadow-md"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">{company.name}</h2>
<span
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"
>
{company.role}
</span>
</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">Budget</span>
<span class="font-medium">{formatCurrency(company.totalBudget, company.currency)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Spent</span>
<span class="font-medium">{formatCurrency(company.totalSpent, company.currency)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Projects</span>
<span class="font-medium">{company.projectCount}</span>
</div>
{#if company.pendingExpenses > 0}
<div class="flex justify-between">
<span class="text-gray-500">Pending Approvals</span>
<span class="font-medium text-amber-600">{company.pendingExpenses}</span>
</div>
{/if}
</div>
<div class="mt-4">
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full transition-all {budgetColor(budgetPercent(company.totalSpent, company.totalBudget))}"
style="width: {budgetPercent(company.totalSpent, company.totalBudget)}%"
></div>
</div>
<p class="mt-1 text-xs text-gray-400">{budgetPercent(company.totalSpent, company.totalBudget).toFixed(1)}% spent</p>
</div>
</a>
{/each}
</div>
{/if}
</div>
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>
<div class="flex min-h-screen items-center justify-center bg-gray-50">
<div class="w-full max-w-md">
{@render children()}
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { verifyPassword } from '$lib/server/auth/password.js';
import {
generateSessionToken,
createSession,
setSessionCookie
} from '$lib/server/auth/index.js';
import { isOIDCEnabled } from '$lib/server/auth/oidc.js';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(302, '/dashboard');
}
return { oidcEnabled: isOIDCEnabled() };
};
export const actions: Actions = {
default: async (event) => {
const formData = await event.request.formData();
const email = formData.get('email')?.toString().trim().toLowerCase();
const password = formData.get('password')?.toString();
if (!email || !password) {
return fail(400, { error: 'Email and password are required', email });
}
const result = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (result.length === 0 || !result[0].passwordHash) {
return fail(400, { error: 'Invalid email or password', email });
}
const user = result[0];
const valid = await verifyPassword(user.passwordHash!, password);
if (!valid) {
return fail(400, { error: 'Invalid email or password', email });
}
const token = generateSessionToken();
const session = await createSession(token, user.id);
setSessionCookie(event, token, session.expiresAt);
redirect(302, '/dashboard');
}
};
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { form, data } = $props();
</script>
<svelte:head>
<title>Login - Buildfor Life Budget</title>
</svelte:head>
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
<h1 class="mb-6 text-center text-2xl font-bold text-gray-900">Sign In</h1>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<form method="POST" use:enhance>
<div class="mb-4">
<label for="email" class="mb-1 block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
id="email"
name="email"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={form?.email ?? ''}
/>
</div>
<div class="mb-6">
<label for="password" class="mb-1 block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
name="password"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Sign In
</button>
</form>
{#if data.oidcEnabled}
<div class="mt-4">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white px-2 text-gray-500">Or continue with</span>
</div>
</div>
<a
href="/oidc"
class="mt-4 flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Single Sign-On (SSO)
</a>
</div>
{/if}
<p class="mt-4 text-center text-sm text-gray-600">
Don't have an account?
<a href="/signup" class="font-medium text-blue-600 hover:text-blue-500">Sign up</a>
</p>
</div>
+18
View File
@@ -0,0 +1,18 @@
import { redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { invalidateSession, deleteSessionCookie } from '$lib/server/auth/index.js';
export const load: PageServerLoad = async () => {
redirect(302, '/login');
};
export const actions: Actions = {
default: async (event) => {
const token = event.cookies.get('session');
if (token) {
await invalidateSession(token);
}
deleteSessionCookie(event);
redirect(302, '/login');
}
};
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
generateState,
generateCodeVerifier,
getAuthorizationUrl,
isOIDCEnabled
} from '$lib/server/auth/oidc.js';
export const GET: RequestHandler = async ({ cookies }) => {
if (!isOIDCEnabled()) {
redirect(302, '/login');
}
const state = generateState();
const codeVerifier = generateCodeVerifier();
cookies.set('oidc_state', state, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 600 // 10 minutes
});
cookies.set('oidc_code_verifier', codeVerifier, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 600
});
const url = await getAuthorizationUrl(state, codeVerifier);
redirect(302, url);
};
@@ -0,0 +1,92 @@
import { error, redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { exchangeCode, getUserInfo, getOIDCConfig } from '$lib/server/auth/oidc.js';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { and, eq } from 'drizzle-orm';
import {
generateSessionToken,
generateUserId,
createSession,
setSessionCookie
} from '$lib/server/auth/index.js';
export const GET: RequestHandler = async (event) => {
const { url, cookies } = event;
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get('oidc_state');
const codeVerifier = cookies.get('oidc_code_verifier');
// Clean up cookies
cookies.delete('oidc_state', { path: '/' });
cookies.delete('oidc_code_verifier', { path: '/' });
if (!code || !state || !storedState || !codeVerifier) {
error(400, 'Missing OIDC parameters');
}
if (state !== storedState) {
error(400, 'Invalid OIDC state');
}
const { accessToken } = await exchangeCode(code, codeVerifier);
const userInfo = await getUserInfo(accessToken);
const config = await getOIDCConfig();
// Find existing user by OIDC identity
let user = await db
.select()
.from(users)
.where(
and(eq(users.oidcProvider, config.issuerUrl), eq(users.oidcSubject, userInfo.sub))
)
.limit(1)
.then((r) => r[0] ?? null);
if (!user) {
// Check if a user with this email exists (link accounts)
if (userInfo.email) {
user = await db
.select()
.from(users)
.where(eq(users.email, userInfo.email))
.limit(1)
.then((r) => r[0] ?? null);
if (user) {
// Link OIDC identity to existing user
await db
.update(users)
.set({
oidcProvider: config.issuerUrl,
oidcSubject: userInfo.sub,
updatedAt: new Date()
})
.where(eq(users.id, user.id));
}
}
if (!user) {
// Create new user
const userId = generateUserId();
const result = await db
.insert(users)
.values({
id: userId,
email: userInfo.email,
displayName: userInfo.name || userInfo.email,
oidcProvider: config.issuerUrl,
oidcSubject: userInfo.sub
})
.returning();
user = result[0];
}
}
const token = generateSessionToken();
const session = await createSession(token, user.id);
setSessionCookie(event, token, session.expiresAt);
redirect(302, '/dashboard');
};
+68
View File
@@ -0,0 +1,68 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js';
import { users } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { hashPassword } from '$lib/server/auth/password.js';
import {
generateSessionToken,
generateUserId,
createSession,
setSessionCookie
} from '$lib/server/auth/index.js';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(302, '/dashboard');
}
return {};
};
export const actions: Actions = {
default: async (event) => {
const formData = await event.request.formData();
const displayName = formData.get('displayName')?.toString().trim();
const email = formData.get('email')?.toString().trim().toLowerCase();
const password = formData.get('password')?.toString();
const confirmPassword = formData.get('confirmPassword')?.toString();
if (!displayName || !email || !password || !confirmPassword) {
return fail(400, { error: 'All fields are required', displayName, email });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', displayName, email });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match', displayName, email });
}
// Check if email already exists
const existing = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, email))
.limit(1);
if (existing.length > 0) {
return fail(400, { error: 'An account with this email already exists', displayName, email });
}
const userId = generateUserId();
const passwordHash = await hashPassword(password);
await db.insert(users).values({
id: userId,
email,
displayName,
passwordHash
});
const token = generateSessionToken();
const session = await createSession(token, userId);
setSessionCookie(event, token, session.expiresAt);
redirect(302, '/dashboard');
}
};
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData } from './$types';
let { form } = $props();
</script>
<svelte:head>
<title>Sign Up - Buildfor Life Budget</title>
</svelte:head>
<div class="rounded-lg border border-gray-200 bg-white p-8 shadow-sm">
<h1 class="mb-6 text-center text-2xl font-bold text-gray-900">Create Account</h1>
{#if form?.error}
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<form method="POST" use:enhance>
<div class="mb-4">
<label for="displayName" class="mb-1 block text-sm font-medium text-gray-700">
Display Name
</label>
<input
type="text"
id="displayName"
name="displayName"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={form?.displayName ?? ''}
/>
</div>
<div class="mb-4">
<label for="email" class="mb-1 block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
id="email"
name="email"
required
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
value={form?.email ?? ''}
/>
</div>
<div class="mb-4">
<label for="password" class="mb-1 block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="mb-6">
<label for="confirmPassword" class="mb-1 block text-sm font-medium text-gray-700">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
minlength="8"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
Create Account
</button>
</form>
<p class="mt-4 text-center text-sm text-gray-600">
Already have an account?
<a href="/login" class="font-medium text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};
+7
View File
@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
redirect(302, '/dashboard');
}
redirect(302, '/login');
};
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import type { PageData } from './$types';
let { data } = $props();
</script>
<svelte:head>
<title>Buildfor Life Budget</title>
</svelte:head>
{#if data.user}
<meta http-equiv="refresh" content="0; url=/dashboard" />
{:else}
<meta http-equiv="refresh" content="0; url=/login" />
{/if}
View File
+15
View File
@@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: true
})
}
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+7
View File
@@ -0,0 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});