Compare commits

...

5 Commits

Author SHA1 Message Date
grabowski 5451a591ad Add deployment doc: Caddy + Tor + Yggdrasil + NetBird + external TLS proxy
Validate / validate (push) Successful in 32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:36:49 +07:00
grabowski fef69b653c Add inline rename/edit on project detail page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:09:52 +07:00
grabowski 57f3d42133 Redesign company overview: 4 compact KPIs, side-by-side projects + recent expenses
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:06:19 +07:00
grabowski f51e156539 Restructure company nav: 8 primary tabs + HR/Ops/Admin dropdowns with active highlight
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:59:25 +07:00
grabowski 03526ff3b9 Restore pointer cursor on buttons (Tailwind v4 Preflight reset)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:35:46 +07:00
6 changed files with 708 additions and 162 deletions
+255
View File
@@ -0,0 +1,255 @@
# Deployment
Self-hosted on a single Linux box (Proxmox LXC / VM / bare metal). Four layers, innermost to outermost:
1. **Node app** (`adapter-node`) — listens on `127.0.0.1:3000`
2. **Caddy** (internal) — listens on LAN IP, Yggdrasil IPv6, NetBird IP, and `127.0.0.1` (for Tor). HTTP only — no TLS.
3. **Tor / Yggdrasil / NetBird** — overlay transports that connect peers to Caddy
4. **External reverse proxy** (off-box, e.g. at the network edge) — does TLS termination for the public hostname; forwards to LAN IP:8080
```
┌─ Internet ──→ TLS proxy ──┐
│ │
Tor peer ───┼─ onion ──→ tor daemon ──┤
│ ├──→ Caddy:8080 ──→ Node:3000
Yggdrasil peer ─┼─ ygg ──→ ygg IPv6 ──┤
│ │
NetBird peer ──┼─ WG ──→ netbird IP ──┘
```
All non-TLS: the external proxy terminates TLS, Tor encrypts its own path, Yggdrasil/NetBird are encrypted overlays. Caddy and the node app speak plain HTTP.
---
## 1. Node app
`systemd` unit at `/etc/systemd/system/buildfor-life-budget.service`:
```ini
[Unit]
Description=B4L Budget (SvelteKit adapter-node)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=budget
WorkingDirectory=/opt/buildfor-life-budget
EnvironmentFile=/opt/buildfor-life-budget/.env
ExecStart=/usr/bin/node build/index.js
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/buildfor-life-budget/uploads
PrivateTmp=true
[Install]
WantedBy=multi-user.target
```
`/opt/buildfor-life-budget/.env` must contain:
```
PORT=3000
HOST=127.0.0.1
ORIGIN=https://budget.example.com
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-host
BODY_SIZE_LIMIT=26214400
DATABASE_URL=postgresql://budget_app:...@127.0.0.1:5432/buildfor_life_budget
UPLOADS_DIR=/var/lib/buildfor-life-budget/uploads
FAVICON_FETCH_ENABLED=true
```
`ORIGIN` stays the public HTTPS URL — SvelteKit uses this for CSRF and cookie checks. If you hit the app via Tor/Yggdrasil/NetBird with a different Host header, either:
- set `csrf.checkOrigin: false` in `svelte.config.js`, or
- access each transport via a Host that matches `ORIGIN` (point DNS / hosts-file entries at the transport IPs).
The second approach is cleaner — all transports see the same `Host: budget.example.com` and SvelteKit doesn't care which IP the connection came from.
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now buildfor-life-budget.service
```
---
## 2. Internal Caddy
Package: `sudo apt install caddy` (or use the official repo for latest).
`/etc/caddy/Caddyfile`:
```caddyfile
{
# Off — TLS is handled by the upstream external proxy.
auto_https off
admin off
}
# Listen on every transport-facing IP. Same backend.
# LAN IP · Yggdrasil IPv6 · NetBird IP · localhost (for Tor)
http://192.168.10.5:8080,
http://[200:abcd:1234:...]:8080,
http://100.64.0.5:8080,
http://127.0.0.1:8080 {
# Forward real client info so SvelteKit's PROTOCOL_HEADER/HOST_HEADER work.
# The external TLS proxy should set X-Forwarded-Proto=https; default to http for
# the overlays (Tor/Ygg/NetBird) which do their own encryption.
@fromTls header X-Forwarded-Proto https
header @fromTls X-Forwarded-Proto https
reverse_proxy 127.0.0.1:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
encode zstd gzip
request_body {
max_size 26MB
}
log {
output file /var/log/caddy/budget.log {
roll_size 50mb
roll_keep 10
}
format console
}
}
```
Replace the three IPs with the real values:
- **LAN IP** — `ip -4 -br addr show | grep -v lo` on the host.
- **Yggdrasil IPv6** — `yggdrasilctl getSelf | grep -i address` (starts with `200:` or `201:`).
- **NetBird IP** — `netbird status | grep 'NetBird IP'` (usually `100.64.0.0/10` range).
```bash
sudo systemctl enable --now caddy
sudo systemctl reload caddy # after edits
```
**Firewall** — the box should accept `:8080` inbound only on those four interfaces. With `ufw`:
```bash
sudo ufw allow in on eth0 from 192.168.10.0/24 to any port 8080 proto tcp # LAN
sudo ufw allow in on tun-ygg to any port 8080 proto tcp # Yggdrasil
sudo ufw allow in on wt0 to any port 8080 proto tcp # NetBird (interface name varies)
# Tor connects via 127.0.0.1 — no firewall rule needed
sudo ufw deny 8080/tcp # block the rest
```
---
## 3. Tor onion service
Package: `sudo apt install tor`. Append to `/etc/tor/torrc`:
```
HiddenServiceDir /var/lib/tor/budget/
HiddenServicePort 80 127.0.0.1:8080
HiddenServiceVersion 3
```
```bash
sudo systemctl restart tor
sudo cat /var/lib/tor/budget/hostname
```
The printed `<random>.onion` is the address. Protect the contents of `/var/lib/tor/budget/` — anyone with the private key can impersonate this service. Back them up offline.
---
## 4. Yggdrasil
Package: official `.deb` from [yggdrasil-network.github.io](https://yggdrasil-network.github.io/installation-linux.html).
After install, Yggdrasil picks a stable IPv6 address from the `200::/7` range. To see it:
```bash
sudo yggdrasilctl getSelf
```
No Yggdrasil-side config needed for this app — peers who have your Yggdrasil address can reach `http://[200:...]:8080` once they're on the network.
Add trusted peers in `/etc/yggdrasil.conf`'s `Peers` block, or rely on public peers listed at [publicpeers.neilalexander.dev](https://publicpeers.neilalexander.dev/).
---
## 5. NetBird
Package: official from `pkgs.netbird.io`. Set up:
```bash
sudo netbird up --setup-key <your-netbird-setup-key>
sudo netbird status
```
Note the IP under `NetBird IP` — that's what Caddy binds to. NetBird manages WireGuard tunnels; any peer in the same NetBird network can hit that IP.
Keep the daemon running via the included `netbird.service`.
---
## 6. External reverse proxy (TLS termination)
This sits on your network edge, not on the app box. Example Caddyfile on the edge:
```caddyfile
budget.example.com {
reverse_proxy 192.168.10.5:8080 {
header_up X-Forwarded-Proto https
header_up X-Forwarded-Host {host}
header_up Host {host}
}
}
```
Caddy auto-provisions a Let's Encrypt cert. If you use nginx/Traefik instead, forward the same headers so the internal Caddy and the SvelteKit app see the correct `https` protocol and public host.
---
## Update / redeploy
On the app box:
```bash
cd /opt/buildfor-life-budget
sudo -u budget git pull
sudo -u budget npm ci --omit=dev=false
sudo -u budget npm run build
sudo -u budget npm run db:push # only if schema changed
sudo systemctl restart buildfor-life-budget
```
Caddy config does not need reloading unless IPs change.
---
## Troubleshooting
| Symptom | Likely cause |
|---|---|
| External URL 502 from the edge proxy | Internal Caddy not up, or LAN firewall blocking `:8080` from the edge. |
| `.onion` loads but forms return 403 | CSRF origin mismatch — either configure a hosts-file entry for the onion pointing to `budget.example.com`, or disable `csrf.checkOrigin` in `svelte.config.js`. |
| Yggdrasil peer can't reach the address | Both sides must be on Yggdrasil; check `yggdrasilctl getPeers` on both ends. |
| NetBird IP changes after reconnect | Setup key vs reusable key — use a fixed/dedicated peer config so the IP stays stable. |
| SvelteKit sees HTTP for TLS-terminated traffic | External proxy not sending `X-Forwarded-Proto: https` header. |
| Scheduler doesn't tick | Systemd unit restart killed the in-process interval; check logs for `[scheduler] recurring bills started`. |
---
## Security notes
- The internal Caddy MUST NOT be reachable from the internet directly. The four-IP bind list keeps it on private transports only; verify with `ss -tlnp | grep :8080` that no `0.0.0.0:8080` shows up.
- `.onion` key files and NetBird setup keys are secrets — back them up to an offline medium, not to the repo.
- PostgreSQL should bind to `127.0.0.1` only (`listen_addresses = 'localhost'` in `postgresql.conf`).
- File uploads under `UPLOADS_DIR` are served via auth-checked endpoints only — never expose that directory statically.
+21
View File
@@ -1,3 +1,24 @@
@import 'tailwindcss'; @import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* Tailwind v4's Preflight removes cursor:pointer from buttons — restore the expected UX. */
@layer base {
button:not(:disabled),
[role='button']:not(:disabled),
summary,
label[for],
select:not(:disabled),
input[type='submit']:not(:disabled),
input[type='reset']:not(:disabled),
input[type='button']:not(:disabled),
input[type='checkbox']:not(:disabled),
input[type='radio']:not(:disabled) {
cursor: pointer;
}
button:disabled,
[role='button'][aria-disabled='true'] {
cursor: not-allowed;
}
}
@@ -1,58 +1,95 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import { afterNavigate } from '$app/navigation';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
let { data, children } = $props(); let { data, children }: { data: LayoutData; children: Snippet } = $props();
const tabs = $derived([ type MenuKey = 'hr' | 'ops' | 'admin';
{ href: `/companies/${data.company.id}`, label: 'Overview' }, let openMenu = $state<MenuKey | null>(null);
{ href: `/companies/${data.company.id}/links`, label: 'Links' },
{ href: `/companies/${data.company.id}/projects`, label: 'Projects' }, afterNavigate(() => {
{ href: `/companies/${data.company.id}/expenses`, label: 'Expenses' }, openMenu = null;
{ href: `/companies/${data.company.id}/budget`, label: 'Budget' }, });
{ href: `/companies/${data.company.id}/categories`, label: 'Categories' },
{ href: `/companies/${data.company.id}/reports`, label: 'Reports' }, const baseUrl = $derived(`/companies/${data.company.id}`);
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'hr') const currentPath = $derived($page.url.pathname);
function isActive(href: string): boolean {
if (href === baseUrl) return currentPath === baseUrl;
return currentPath === href || currentPath.startsWith(href + '/');
}
function has(roles: string[]): boolean {
return data.companyRoles.some((r) => roles.includes(r));
}
const primaryTabs = $derived(
[
{ href: baseUrl, label: 'Overview', show: true },
{ href: `${baseUrl}/accounts`, label: 'Accounts', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/projects`, label: 'Projects', show: true },
{ href: `${baseUrl}/expenses`, label: 'Expenses', show: true },
{ href: `${baseUrl}/bills`, label: 'Bills', show: has(['admin', 'manager', 'accountant']) },
{ href: `${baseUrl}/invoices`, label: 'Invoices', show: has(['admin', 'manager']) },
{ href: `${baseUrl}/budget`, label: 'Budget', show: true },
{ href: `${baseUrl}/reports`, label: 'Reports', show: true }
].filter((t) => t.show)
);
const hrItems = $derived(
has(['admin', 'manager', 'hr'])
? [ ? [
{ href: `/companies/${data.company.id}/employees`, label: 'Employees' }, { href: `${baseUrl}/employees`, label: 'Employees' },
{ href: `/companies/${data.company.id}/hr/leave-requests`, label: 'Leave' }, { href: `${baseUrl}/hr/leave-requests`, label: 'Leave' },
{ href: `/companies/${data.company.id}/hr/payroll`, label: 'Payroll' }, { href: `${baseUrl}/hr/payroll`, label: 'Payroll' },
{ href: `/companies/${data.company.id}/hr/holidays`, label: 'Holidays' } { href: `${baseUrl}/hr/holidays`, label: 'Holidays' }
] ]
: []), : []
...(data.companyRoles.some(r => r === 'admin' || r === 'manager') );
? [
{ href: `/companies/${data.company.id}/parties`, label: 'Contacts' }, const opsItems = $derived(
{ href: `/companies/${data.company.id}/invoices`, label: 'Invoices' } [
] { href: `${baseUrl}/parties`, label: 'Contacts', show: has(['admin', 'manager']) },
: []), { href: `${baseUrl}/categories`, label: 'Categories', show: true },
...(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr') { href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) },
? [{ href: `/companies/${data.company.id}/packages`, label: 'Packages' }] { href: `${baseUrl}/links`, label: 'Links', show: true },
: []), { href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) }
...(data.companyRoles.includes('admin') ].filter((t) => t.show)
? [ );
{ href: `/companies/${data.company.id}/integrations`, label: 'Integrations' }
] const adminItems = $derived(
: []), [
...(data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant') { href: `${baseUrl}/integrations`, label: 'Integrations', show: has(['admin']) },
? [ { href: `${baseUrl}/import`, label: 'Import', show: has(['admin', 'manager']) },
{ href: `/companies/${data.company.id}/accounts`, label: 'Accounts' }, { href: `${baseUrl}/export`, label: 'Export', show: has(['admin', 'accountant']) },
{ href: `/companies/${data.company.id}/bills`, label: 'Bills' }, { href: `${baseUrl}/profile`, label: 'Profile', show: has(['admin', 'manager', 'accountant']) },
{ href: `/companies/${data.company.id}/profile`, label: 'Profile' }, { href: `${baseUrl}/settings`, label: 'Settings', show: has(['admin', 'manager']) }
{ href: `/companies/${data.company.id}/documents`, label: 'Documents' } ].filter((t) => t.show)
] );
: []),
...(data.companyRoles.includes('admin') || data.companyRoles.includes('accountant') function menuActive(items: Array<{ href: string }>): boolean {
? [{ href: `/companies/${data.company.id}/export`, label: 'Export' }] return items.some((i) => isActive(i.href));
: []), }
...(data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
? [ function toggleMenu(menu: MenuKey) {
{ href: `/companies/${data.company.id}/import`, label: 'Import' }, openMenu = openMenu === menu ? null : menu;
{ href: `/companies/${data.company.id}/settings`, label: 'Settings' } }
]
: []) function handleWindowClick(e: MouseEvent) {
]); const target = e.target as HTMLElement | null;
if (target?.closest('[data-nav-dropdown]')) return;
openMenu = null;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') openMenu = null;
}
</script> </script>
<svelte:window onclick={handleWindowClick} onkeydown={handleKeydown} />
<div> <div>
<div class="mb-6"> <div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.company.name}</h1>
@@ -61,16 +98,82 @@
{/if} {/if}
</div> </div>
<!-- Tabs --> <nav
<nav class="mb-6 flex gap-1 overflow-x-auto border-b border-gray-200 dark:border-gray-700"> class="mb-6 flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-700"
{#each tabs as tab} aria-label="Company navigation"
>
{#each primaryTabs as tab (tab.href)}
{@const active = isActive(tab.href)}
<a <a
href={tab.href} href={tab.href}
class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-700 dark:hover:text-gray-300" class="whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {active
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
aria-current={active ? 'page' : undefined}
> >
{tab.label} {tab.label}
</a> </a>
{/each} {/each}
{#snippet dropdown(key: MenuKey, label: string, items: Array<{ href: string; label: string }>)}
{#if items.length > 0}
{@const active = menuActive(items)}
{@const open = openMenu === key}
<div class="relative" data-nav-dropdown>
<button
type="button"
onclick={() => toggleMenu(key)}
class="flex items-center gap-1 whitespace-nowrap border-b-2 px-4 py-2 text-sm font-medium transition-colors {active
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: open
? 'border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300'}"
aria-haspopup="menu"
aria-expanded={open}
>
{label}
<svg
class="h-3 w-3 transition-transform {open ? 'rotate-180' : ''}"
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if open}
<div
role="menu"
class="absolute left-0 top-full z-20 mt-1 min-w-[180px] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
{#each items as item (item.href)}
{@const itemActive = isActive(item.href)}
<a
href={item.href}
role="menuitem"
class="block px-4 py-2 text-sm transition-colors {itemActive
? 'bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700/60'}"
aria-current={itemActive ? 'page' : undefined}
>
{item.label}
</a>
{/each}
</div>
{/if}
</div>
{/if}
{/snippet}
{@render dropdown('hr', 'HR', hrItems)}
{@render dropdown('ops', 'Ops', opsItems)}
{@render dropdown('admin', 'Admin', adminItems)}
</nav> </nav>
{@render children()} {@render children()}
@@ -2,7 +2,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props(); let { data }: { data: PageData } = $props();
const currency = $derived(data.company.currency); const currency = $derived(data.company.currency);
const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0)); const allocated = $derived(data.projects.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0));
@@ -10,51 +10,101 @@
const total = $derived(parseFloat(data.company.totalBudget)); const total = $derived(parseFloat(data.company.totalBudget));
const remaining = $derived(total - spent); const remaining = $derived(total - spent);
const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0); const remainingPct = $derived(total > 0 ? (remaining / total) * 100 : 0);
const tone = $derived(remaining < 0 ? 'red' : remainingPct < 20 ? 'amber' : 'green');
const toneRing: Record<string, string> = {
green: 'border-green-300 dark:border-green-700',
amber: 'border-amber-300 dark:border-amber-700',
red: 'border-red-300 dark:border-red-700'
};
const toneText: Record<string, string> = {
green: 'text-green-700 dark:text-green-400',
amber: 'text-amber-700 dark:text-amber-400',
red: 'text-red-700 dark:text-red-400'
};
const toneBar: Record<string, string> = {
green: 'bg-green-500',
amber: 'bg-amber-500',
red: 'bg-red-500'
};
</script> </script>
<svelte:head> <svelte:head>
<title>{data.company.name} - {data.appName}</title> <title>{data.company.name} - {data.appName}</title>
</svelte:head> </svelte:head>
<div class="grid gap-6 lg:grid-cols-2"> <div class="space-y-6">
<!-- Budget Summary --> <!-- KPI row -->
<div class="rounded-lg border-2 {remaining < 0 ? 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/30' : remainingPct < 20 ? 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30' : 'border-green-300 bg-green-50 dark:border-green-700 dark:bg-green-900/30'} p-5"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<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="rounded-lg border-2 {toneRing[tone]} bg-white p-4 dark:bg-gray-800">
<div class="text-3xl font-bold {remaining < 0 ? 'text-red-700 dark:text-red-400' : remainingPct < 20 ? 'text-amber-700 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}"> <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Remaining
</p>
<p class="mt-1 text-2xl font-bold {toneText[tone]}">
{formatCurrency(remaining, currency)} {formatCurrency(remaining, currency)}
</div> </p>
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-white/60 dark:bg-gray-700/60"> <div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div <div
class="h-full rounded-full transition-all {remaining < 0 ? 'bg-red-500' : remainingPct < 20 ? 'bg-amber-500' : 'bg-green-500'}" class="h-full transition-all {toneBar[tone]}"
style="width: {Math.max(0, Math.min(remainingPct, 100))}%" style="width: {Math.max(0, Math.min(remainingPct, 100))}%"
></div> ></div>
</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> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{remainingPct.toFixed(1)}% of total
</p>
</div>
<div class="mt-4 space-y-1.5 text-sm"> <div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="flex justify-between"> <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Total budget</span> Total Budget
<span class="font-medium {remaining < 0 ? 'text-red-600 dark:text-red-400' : remainingPct < 20 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">{formatCurrency(total, currency)}</span> </p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{formatCurrency(total, currency)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Company-wide</p>
</div> </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> <div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<span class="font-medium {remaining < 0 ? 'text-red-600 dark:text-red-400' : remainingPct < 20 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">{formatCurrency(spent, currency)}</span> <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
</div> Spent
<div class="flex justify-between"> </p>
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Allocated</span> <p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
<span class="font-medium {remaining < 0 ? 'text-red-600 dark:text-red-400' : remainingPct < 20 ? 'text-amber-600 dark:text-amber-400' : 'text-green-700 dark:text-green-400'}">{formatCurrency(allocated, currency)}</span> {formatCurrency(spent, currency)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Across {data.projects.length} {data.projects.length === 1 ? 'project' : 'projects'}
</p>
</div> </div>
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
Allocated
</p>
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{formatCurrency(allocated, currency)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{total > 0 ? ((allocated / total) * 100).toFixed(1) : '0'}% of total
</p>
</div> </div>
</div> </div>
<div class="grid gap-6 lg:grid-cols-2">
<!-- Projects --> <!-- Projects -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5"> <div
class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Projects</h2> <h2
{#if data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')} class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
Projects
</h2>
{#if data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')}
<a <a
href="/companies/{data.company.id}/projects/new" href="/companies/{data.company.id}/projects/new"
class="text-sm font-medium text-blue-600 hover:text-blue-700" class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
> >
+ New Project + New Project
</a> </a>
@@ -65,7 +115,7 @@
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No projects yet.</p> <p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No projects yet.</p>
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each data.projects as project} {#each data.projects as project (project.id)}
{@const budgetNum = parseFloat(project.allocatedBudget)} {@const budgetNum = parseFloat(project.allocatedBudget)}
{@const spentNum = parseFloat(project.spent)} {@const spentNum = parseFloat(project.spent)}
{@const pct = budgetNum > 0 ? Math.min((spentNum / budgetNum) * 100, 100) : 0} {@const pct = budgetNum > 0 ? Math.min((spentNum / budgetNum) * 100, 100) : 0}
@@ -73,12 +123,21 @@
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-900 dark:text-white">{project.name}</span> <span class="font-medium text-gray-900 dark:text-white">{project.name}</span>
<span class="text-gray-500 dark:text-gray-400"> <span class="text-gray-500 dark:text-gray-400">
{formatCurrency(project.spent, currency)} / {formatCurrency(project.allocatedBudget, currency)} {formatCurrency(project.spent, currency)} / {formatCurrency(
project.allocatedBudget,
currency
)}
</span> </span>
</div> </div>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
<div <div
class="h-full rounded-full {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-blue-500'}" class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700"
>
<div
class="h-full rounded-full {pct > 90
? 'bg-red-500'
: pct > 70
? 'bg-amber-500'
: 'bg-blue-500'}"
style="width: {pct}%" style="width: {pct}%"
></div> ></div>
</div> </div>
@@ -92,32 +151,43 @@
</div> </div>
<!-- Recent Expenses --> <!-- Recent Expenses -->
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 lg:col-span-2"> <div
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Recent Expenses</h2> class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-3 flex items-center justify-between">
<h2
class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
Recent Expenses
</h2>
<a
href="/companies/{data.company.id}/expenses"
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
View all →
</a>
</div>
{#if data.recentExpenses.length === 0} {#if data.recentExpenses.length === 0}
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No expenses yet.</p> <p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No expenses yet.</p>
{:else} {:else}
<table class="w-full text-sm"> <ul class="divide-y divide-gray-100 dark:divide-gray-700">
<thead> {#each data.recentExpenses as expense (expense.id)}
<tr class="border-b border-gray-100 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400"> <li class="flex items-center justify-between gap-3 py-2">
<th class="pb-2 font-medium">Title</th> <div class="min-w-0 flex-1">
<th class="pb-2 font-medium">Project</th> <p class="truncate text-sm font-medium text-gray-900 dark:text-white">
<th class="pb-2 font-medium">Amount</th> {expense.title}
<th class="pb-2 font-medium">Date</th> </p>
<th class="pb-2 font-medium">Status</th> <p class="truncate text-xs text-gray-500 dark:text-gray-400">
</tr> {expense.projectName} · {expense.expenseDate}
</thead> </p>
<tbody> </div>
{#each data.recentExpenses as expense} <div class="flex items-center gap-2 text-sm">
<tr class="border-b border-gray-50 dark:border-gray-700/50"> <span class="font-medium text-gray-900 dark:text-white">
<td class="py-2 font-medium text-gray-900 dark:text-white">{expense.title}</td> {formatCurrency(expense.amount, currency)}
<td class="py-2 text-gray-500 dark:text-gray-400">{expense.projectName}</td> </span>
<td class="py-2 dark:text-white">{formatCurrency(expense.amount, currency)}</td>
<td class="py-2 text-gray-500 dark:text-gray-400">{expense.expenseDate}</td>
<td class="py-2">
<span <span
class="rounded-full px-2 py-0.5 text-xs font-medium class="rounded-full px-2 py-0.5 text-xs font-medium {expense.status ===
{expense.status === 'approved' 'approved'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
: expense.status === 'rejected' : expense.status === 'rejected'
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300' ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
@@ -125,11 +195,11 @@
> >
{expense.status} {expense.status}
</span> </span>
</td> </div>
</tr> </li>
{/each} {/each}
</tbody> </ul>
</table>
{/if} {/if}
</div> </div>
</div>
</div> </div>
@@ -1,8 +1,10 @@
import { error } from '@sveltejs/kit'; import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { projects, expenses, users, categories } from '$lib/server/db/schema.js'; import { projects, expenses, users, categories } from '$lib/server/db/schema.js';
import { eq, and, sql } from 'drizzle-orm'; import { eq, and, sql } from 'drizzle-orm';
import { requireCompanyRole } from '$lib/server/authorization.js';
import { logCompanyEvent } from '$lib/server/audit.js';
export const load: PageServerLoad = async ({ params, parent }) => { export const load: PageServerLoad = async ({ params, parent }) => {
await parent(); await parent();
@@ -48,3 +50,40 @@ export const load: PageServerLoad = async ({ params, parent }) => {
return { project, expenses: expenseList, stats }; return { project, expenses: expenseList, stats };
}; };
export const actions: Actions = {
updateProject: async ({ request, locals, params }) => {
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
const fd = await request.formData();
const name = fd.get('name')?.toString().trim();
const description = fd.get('description')?.toString().trim() || null;
if (!name) return fail(400, { action: 'updateProject', error: 'Project name is required' });
const [existing] = await db
.select({ id: projects.id, name: projects.name })
.from(projects)
.where(and(eq(projects.id, params.projectId), eq(projects.companyId, params.companyId)))
.limit(1);
if (!existing) error(404, 'Project not found');
await db
.update(projects)
.set({ name, description, updatedAt: new Date() })
.where(eq(projects.id, params.projectId));
const renamed = existing.name !== name;
await logCompanyEvent(
params.companyId,
user.id,
'project_updated',
renamed
? `Project renamed from "${existing.name}" to "${name}"`
: `Project "${name}" updated`,
{ projectId: params.projectId, previousName: existing.name, newName: name }
);
return { success: true, action: 'updateProject' };
}
};
@@ -1,10 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
import { formatCurrency } from '$lib/utils/currency.js'; import { formatCurrency } from '$lib/utils/currency.js';
let { data } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
const currency = $derived(data.company.currency); const currency = $derived(data.company.currency);
const canAddExpense = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')); const canAddExpense = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr'));
const canEdit = $derived(data.companyRoles.some(r => r === 'admin' || r === 'manager'));
let editing = $state(false);
</script> </script>
<svelte:head> <svelte:head>
@@ -12,14 +16,68 @@
</svelte:head> </svelte:head>
<div> <div>
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-start justify-between gap-3">
<div> <div class="min-w-0 flex-1">
{#if editing && canEdit}
<form
method="POST"
action="?/updateProject"
use:enhance={() => async ({ result, update }) => {
await update({ reset: false });
if (result.type === 'success') editing = false;
}}
class="space-y-2"
>
<input
name="name"
type="text"
required
value={data.project.name}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-lg font-semibold dark:border-gray-600 dark:bg-gray-700 dark:text-white"
/>
<textarea
name="description"
rows="2"
placeholder="Description (optional)"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white">{data.project.description ?? ''}</textarea>
<div class="flex gap-2">
<button
type="submit"
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
<button
type="button"
onclick={() => (editing = false)}
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
>
Cancel
</button>
</div>
{#if form?.action === 'updateProject' && form.error}
<p class="text-sm text-red-600 dark:text-red-400">{form.error}</p>
{/if}
</form>
{:else}
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{data.project.name}</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">{data.project.name}</h2>
{#if canEdit}
<button
type="button"
onclick={() => (editing = true)}
class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
Edit
</button>
{/if}
</div>
{#if data.project.description} {#if data.project.description}
<p class="text-sm text-gray-500 dark:text-gray-400">{data.project.description}</p> <p class="text-sm text-gray-500 dark:text-gray-400">{data.project.description}</p>
{/if} {/if}
{/if}
</div> </div>
{#if canAddExpense} {#if canAddExpense && !editing}
<a <a
href="/companies/{data.company.id}/projects/{data.project.id}/expenses/new" 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" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"