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,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}
|
||||
Reference in New Issue
Block a user