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:
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
.svelte-kit/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
@@ -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
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!"
|
||||||
@@ -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!
|
||||||
|
}
|
||||||
|
});
|
||||||
Generated
+4213
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
Vendored
+20
@@ -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 {};
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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:'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -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)]
|
||||||
|
);
|
||||||
@@ -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)]
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -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';
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -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] })
|
||||||
|
}));
|
||||||
@@ -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()
|
||||||
|
});
|
||||||
@@ -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] })]
|
||||||
|
);
|
||||||
@@ -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`)
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 Office Supplies,150.00,2024-01-15,Printer paper ..."
|
||||||
|
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>
|
||||||
+98
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
+115
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user