Compare commits
5 Commits
b43924f527
...
5451a591ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 5451a591ad | |||
| fef69b653c | |||
| 57f3d42133 | |||
| f51e156539 | |||
| 03526ff3b9 |
@@ -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
@@ -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,126 +10,196 @@
|
|||||||
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">
|
||||||
{formatCurrency(remaining, currency)}
|
Remaining
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold {toneText[tone]}">
|
||||||
|
{formatCurrency(remaining, currency)}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-full transition-all {toneBar[tone]}"
|
||||||
|
style="width: {Math.max(0, Math.min(remainingPct, 100))}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{remainingPct.toFixed(1)}% of total
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-white/60 dark:bg-gray-700/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="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>
|
||||||
</div>
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
<div class="flex justify-between">
|
{formatCurrency(total, currency)}
|
||||||
<span class="{remaining < 0 ? 'text-red-400' : remainingPct < 20 ? 'text-amber-400' : 'text-green-600/60'}">Total spent</span>
|
</p>
|
||||||
<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="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'}">Allocated</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(allocated, currency)}</span>
|
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
</div>
|
Spent
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{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 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>
|
||||||
|
|
||||||
<!-- Projects -->
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
|
<!-- Projects -->
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div
|
||||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Projects</h2>
|
class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
|
||||||
{#if data.companyRoles.some(r => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')}
|
>
|
||||||
<a
|
<div class="mb-3 flex items-center justify-between">
|
||||||
href="/companies/{data.company.id}/projects/new"
|
<h2
|
||||||
class="text-sm font-medium text-blue-600 hover:text-blue-700"
|
class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||||
>
|
>
|
||||||
+ New Project
|
Projects
|
||||||
</a>
|
</h2>
|
||||||
|
{#if data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'user' || r === 'hr')}
|
||||||
|
<a
|
||||||
|
href="/companies/{data.company.id}/projects/new"
|
||||||
|
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||||
|
>
|
||||||
|
+ New Project
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.projects.length === 0}
|
||||||
|
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No projects yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each data.projects as project (project.id)}
|
||||||
|
{@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 dark:text-white">{project.name}</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{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 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}%"
|
||||||
|
></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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.projects.length === 0}
|
<!-- Recent Expenses -->
|
||||||
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No projects yet.</p>
|
<div
|
||||||
{:else}
|
class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
|
||||||
<div class="space-y-3">
|
>
|
||||||
{#each data.projects as project}
|
<div class="mb-3 flex items-center justify-between">
|
||||||
{@const budgetNum = parseFloat(project.allocatedBudget)}
|
<h2
|
||||||
{@const spentNum = parseFloat(project.spent)}
|
class="text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||||
{@const pct = budgetNum > 0 ? Math.min((spentNum / budgetNum) * 100, 100) : 0}
|
>
|
||||||
<a href="/companies/{data.company.id}/projects/{project.id}" class="block">
|
Recent Expenses
|
||||||
<div class="flex items-center justify-between text-sm">
|
</h2>
|
||||||
<span class="font-medium text-gray-900 dark:text-white">{project.name}</span>
|
<a
|
||||||
<span class="text-gray-500 dark:text-gray-400">
|
href="/companies/{data.company.id}/expenses"
|
||||||
{formatCurrency(project.spent, currency)} / {formatCurrency(project.allocatedBudget, currency)}
|
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||||
</span>
|
>
|
||||||
</div>
|
View all →
|
||||||
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
</a>
|
||||||
<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>
|
</div>
|
||||||
{/if}
|
{#if data.recentExpenses.length === 0}
|
||||||
</div>
|
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No expenses yet.</p>
|
||||||
|
{:else}
|
||||||
<!-- Recent Expenses -->
|
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 lg:col-span-2">
|
{#each data.recentExpenses as expense (expense.id)}
|
||||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">Recent Expenses</h2>
|
<li class="flex items-center justify-between gap-3 py-2">
|
||||||
{#if data.recentExpenses.length === 0}
|
<div class="min-w-0 flex-1">
|
||||||
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No expenses yet.</p>
|
<p class="truncate text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{:else}
|
{expense.title}
|
||||||
<table class="w-full text-sm">
|
</p>
|
||||||
<thead>
|
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
|
||||||
<tr class="border-b border-gray-100 dark:border-gray-700 text-left text-gray-500 dark:text-gray-400">
|
{expense.projectName} · {expense.expenseDate}
|
||||||
<th class="pb-2 font-medium">Title</th>
|
</p>
|
||||||
<th class="pb-2 font-medium">Project</th>
|
</div>
|
||||||
<th class="pb-2 font-medium">Amount</th>
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<th class="pb-2 font-medium">Date</th>
|
<span class="font-medium text-gray-900 dark:text-white">
|
||||||
<th class="pb-2 font-medium">Status</th>
|
{formatCurrency(expense.amount, currency)}
|
||||||
</tr>
|
</span>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each data.recentExpenses as expense}
|
|
||||||
<tr class="border-b border-gray-50 dark:border-gray-700/50">
|
|
||||||
<td class="py-2 font-medium text-gray-900 dark:text-white">{expense.title}</td>
|
|
||||||
<td class="py-2 text-gray-500 dark:text-gray-400">{expense.projectName}</td>
|
|
||||||
<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'
|
||||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}"
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}"
|
||||||
>
|
>
|
||||||
{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">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{data.project.name}</h2>
|
{#if editing && canEdit}
|
||||||
{#if data.project.description}
|
<form
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{data.project.description}</p>
|
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>
|
||||||
|
{#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}
|
||||||
|
<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"
|
||||||
|
|||||||
Reference in New Issue
Block a user