Compare commits
36 Commits
639c261995
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b4338c6814 | |||
| 06ae314b3c | |||
| c570019fd8 | |||
| 8ef2ef7465 | |||
| ef6ba485d3 | |||
| e216a393e4 | |||
| 6d0fb30545 | |||
| 8376116765 | |||
| 7367aa9572 | |||
| 7465b498e0 | |||
| 7fba11941f | |||
| 94e38aca9c | |||
| 00b8b239e0 | |||
| 26945285eb | |||
| 2540a7603e | |||
| 0795d78bdf | |||
| 1c15cbc36e | |||
| f56d4caec8 | |||
| 34aab722b4 | |||
| bbfab9faaa | |||
| 84c8beca15 | |||
| 34b1524d3a | |||
| bc0699a992 | |||
| 283f0d4dd1 | |||
| 0710d63cc1 | |||
| 5ff4f07ff4 | |||
| 0906a448b3 | |||
| 65cee9855c | |||
| f1dd6877f6 | |||
| 8a23a849da | |||
| b4eda2d553 | |||
| dbfd229ba8 | |||
| 1ce614186d | |||
| 493ffa4097 | |||
| a1fffebbf6 | |||
| 1fed8ee920 |
@@ -21,3 +21,7 @@ BODY_SIZE_LIMIT=26214400
|
||||
|
||||
# Company Links favicon fetching (set false to disable outbound fetches in offline dev)
|
||||
FAVICON_FETCH_ENABLED=true
|
||||
|
||||
# Paperless-ngx integration (optional — leave blank to disable)
|
||||
PAPERLESS_URL=
|
||||
PAPERLESS_TOKEN=
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Deploy to LXC
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT || 22 }}
|
||||
script: |
|
||||
set -e
|
||||
|
||||
APP_DIR="${{ secrets.DEPLOY_PATH || '/opt/buildfor-life-budget' }}"
|
||||
|
||||
# Set up deploy key for private repo access
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.REPO_DEPLOY_KEY }}" > ~/.ssh/repo_deploy_key
|
||||
chmod 600 ~/.ssh/repo_deploy_key
|
||||
|
||||
# Configure SSH to use deploy key for git.b4l.co.th
|
||||
if ! grep -q "git.b4l.co.th" ~/.ssh/config 2>/dev/null; then
|
||||
cat >> ~/.ssh/config <<EOF
|
||||
Host git.b4l.co.th
|
||||
HostName git.b4l.co.th
|
||||
IdentityFile ~/.ssh/repo_deploy_key
|
||||
StrictHostKeyChecking accept-new
|
||||
EOF
|
||||
chmod 600 ~/.ssh/config
|
||||
fi
|
||||
|
||||
# Clone if first deploy, otherwise pull
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
echo "==> First deploy, cloning..."
|
||||
git clone git@git.b4l.co.th:B4L/buildfor_life_budget.git "$APP_DIR"
|
||||
cd "$APP_DIR"
|
||||
else
|
||||
cd "$APP_DIR"
|
||||
echo "==> Resetting local changes..."
|
||||
git checkout -- .
|
||||
echo "==> Pulling latest code..."
|
||||
git pull origin main
|
||||
fi
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
npm ci
|
||||
|
||||
echo "==> Building..."
|
||||
npm run build
|
||||
|
||||
echo "==> Running migrations..."
|
||||
npm run db:push
|
||||
|
||||
echo "==> Restarting service..."
|
||||
sudo systemctl restart buildfor-life-budget
|
||||
|
||||
echo "==> Waiting for startup..."
|
||||
sleep 2
|
||||
systemctl is-active --quiet buildfor-life-budget && echo "Deploy successful!" || (echo "Service failed to start!" && exit 1)
|
||||
@@ -0,0 +1,129 @@
|
||||
# CI/CD Deploy Setup
|
||||
|
||||
Auto-deploys to your LXC server on every push to `main` via `.gitea/workflows/deploy.yml`.
|
||||
|
||||
## 1. Server preparation
|
||||
|
||||
On the LXC server, allow the deploy user to restart the service without a password:
|
||||
|
||||
```bash
|
||||
# As root on the LXC
|
||||
echo "budget ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart buildfor-life-budget, /usr/bin/systemctl status buildfor-life-budget" > /etc/sudoers.d/budget-deploy
|
||||
chmod 440 /etc/sudoers.d/budget-deploy
|
||||
```
|
||||
|
||||
Make sure the repo is cloned and the app works manually first (see [`docs/deployment.md`](./deployment.md)).
|
||||
|
||||
## 2. Generate SSH keys
|
||||
|
||||
You need **two** SSH key pairs:
|
||||
|
||||
### a) Deploy key (CI runner → LXC server)
|
||||
|
||||
This lets the CI runner SSH into your server:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "ci-to-server" -f ci_deploy_key -N ""
|
||||
```
|
||||
|
||||
Copy the **public** key to the server:
|
||||
|
||||
```bash
|
||||
ssh-copy-id -i ci_deploy_key.pub budget@your-lxc-ip
|
||||
```
|
||||
|
||||
### b) Repo deploy key (LXC server → private Gitea repo)
|
||||
|
||||
This lets the server `git pull` from the private repo:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "server-to-repo" -f repo_deploy_key -N ""
|
||||
```
|
||||
|
||||
Add the **public** key in Gitea: repo → **Settings** → **Deploy Keys** → **Add Deploy Key**, paste `repo_deploy_key.pub`.
|
||||
|
||||
## 3. Add secrets in Gitea
|
||||
|
||||
Go to your repo on git.b4l.co.th → **Settings** → **Actions** → **Secrets**, and add:
|
||||
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `DEPLOY_HOST` | LXC server IP (e.g. `192.168.10.5`) |
|
||||
| `DEPLOY_USER` | SSH user (e.g. `budget`) |
|
||||
| `DEPLOY_KEY` | Contents of `ci_deploy_key` (private key — CI runner → server) |
|
||||
| `REPO_DEPLOY_KEY` | Contents of `repo_deploy_key` (private key — server → Gitea repo) |
|
||||
| `DEPLOY_PORT` | SSH port (optional, defaults to 22) |
|
||||
| `DEPLOY_PATH` | App directory (optional, defaults to `/opt/buildfor-life-budget`) |
|
||||
|
||||
### First clone on the server
|
||||
|
||||
The workflow will clone the repo automatically on the first run if `DEPLOY_PATH` doesn't exist. If you prefer to clone manually:
|
||||
|
||||
```bash
|
||||
# On the server as the budget user, set up the deploy key first
|
||||
mkdir -p ~/.ssh
|
||||
cp repo_deploy_key ~/.ssh/repo_deploy_key
|
||||
chmod 600 ~/.ssh/repo_deploy_key
|
||||
cat >> ~/.ssh/config <<EOF
|
||||
Host git.b4l.co.th
|
||||
HostName git.b4l.co.th
|
||||
IdentityFile ~/.ssh/repo_deploy_key
|
||||
StrictHostKeyChecking accept-new
|
||||
EOF
|
||||
|
||||
sudo mkdir -p /opt/buildfor-life-budget
|
||||
sudo chown budget:budget /opt/buildfor-life-budget
|
||||
git clone git@git.b4l.co.th:B4L/buildfor_life_budget.git /opt/buildfor-life-budget
|
||||
```
|
||||
|
||||
Remember to create `/opt/buildfor-life-budget/.env` (see [`docs/deployment.md`](./deployment.md#1-node-app)) before the first deploy — the service won't start without it.
|
||||
|
||||
## 4. Enable Actions in Gitea
|
||||
|
||||
Make sure Gitea Actions is enabled on your instance:
|
||||
|
||||
```ini
|
||||
# In app.ini (Gitea config)
|
||||
[actions]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
You also need a runner registered. If you don't have one, install the Gitea runner on the Gitea host or another machine:
|
||||
|
||||
```bash
|
||||
# Download the runner
|
||||
wget https://gitea.com/gitea/act_runner/releases/latest/download/act_runner-linux-amd64
|
||||
chmod +x act_runner-linux-amd64
|
||||
|
||||
# Register with your Gitea instance
|
||||
./act_runner-linux-amd64 register --instance https://git.b4l.co.th --token <your-runner-token>
|
||||
|
||||
# Start
|
||||
./act_runner-linux-amd64 daemon
|
||||
```
|
||||
|
||||
## 5. Test
|
||||
|
||||
Push any change to `main` and check the Actions tab in Gitea for the deploy log.
|
||||
|
||||
## What the workflow does
|
||||
|
||||
1. SSHs into the LXC server
|
||||
2. Installs the repo deploy key for private repo access
|
||||
3. `git pull` the latest code (or `git clone` on first deploy)
|
||||
4. `npm ci` to install exact lockfile deps
|
||||
5. `npm run build` to compile SvelteKit (adapter-node)
|
||||
6. `npm run db:push` to apply any schema changes to PostgreSQL
|
||||
7. `sudo systemctl restart buildfor-life-budget` to restart the service
|
||||
8. Verifies the service started successfully via `systemctl is-active`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| `Permission denied (publickey)` from CI | `DEPLOY_KEY` mismatches what's in `~/.ssh/authorized_keys` for the deploy user. Re-paste exactly (include `-----BEGIN/END-----` lines). |
|
||||
| `sudo: a password is required` | Sudoers file not installed or has a typo. Check `/etc/sudoers.d/budget-deploy` with `sudo visudo -cf /etc/sudoers.d/budget-deploy`. |
|
||||
| `git@git.b4l.co.th: Permission denied` on the LXC | `REPO_DEPLOY_KEY` not registered as a Deploy Key on the repo, or the LXC's `~/.ssh/repo_deploy_key` has wrong permissions (must be `600`). |
|
||||
| `npm ci` fails with "lock file version mismatch" | Node version on the LXC doesn't match what produced `package-lock.json`. Use the same Node major version as local dev (check `.nvmrc` if present, else `node --version` on both sides). |
|
||||
| `npm run db:push` hangs on interactive prompt | Destructive schema change (column/table drop). Either revert the change or run it manually with `npx drizzle-kit push --force` after confirming data loss is acceptable. |
|
||||
| Service restarts but then exits | Missing or invalid `.env` — check `journalctl -u buildfor-life-budget -n 50`. `ORIGIN`, `DATABASE_URL`, and `UPLOADS_DIR` are mandatory. |
|
||||
+9
-1
@@ -30,5 +30,13 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.session = null;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
const response = await resolve(event);
|
||||
|
||||
response.headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'"
|
||||
);
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -117,7 +117,7 @@ export async function exchangeCode(
|
||||
|
||||
export async function getUserInfo(
|
||||
accessToken: string
|
||||
): Promise<{ sub: string; email: string; name?: string }> {
|
||||
): Promise<{ sub: string; email: string; name?: string; email_verified?: boolean }> {
|
||||
const config = await getOIDCConfig();
|
||||
|
||||
const res = await fetch(config.userinfoEndpoint, {
|
||||
|
||||
+257
-3
@@ -18,7 +18,7 @@ import {
|
||||
// ── Enums ──────────────────────────────────────────────
|
||||
|
||||
export const companyRoleEnum = pgEnum('company_role', ['admin', 'manager', 'user', 'viewer', 'hr', 'accountant']);
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected']);
|
||||
export const expenseStatusEnum = pgEnum('expense_status', ['pending', 'approved', 'rejected', 'voided']);
|
||||
|
||||
// ── Users ──────────────────────────────────────────────
|
||||
|
||||
@@ -130,6 +130,9 @@ export const expenses = pgTable(
|
||||
accountId: uuid('account_id').references((): any => companyAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
invoiceId: uuid('invoice_id').references((): any => invoices.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
submittedBy: text('submitted_by')
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
@@ -143,6 +146,13 @@ export const expenses = pgTable(
|
||||
status: expenseStatusEnum('status').notNull().default('pending'),
|
||||
reviewedAt: timestamp('reviewed_at', { withTimezone: true }),
|
||||
rejectionReason: text('rejection_reason'),
|
||||
// Supplier invoice attachment
|
||||
invoiceFileUrl: text('invoice_file_url'),
|
||||
invoiceFileName: text('invoice_file_name'),
|
||||
paperlessUrl: text('paperless_url'),
|
||||
paperlessDocumentId: integer('paperless_document_id'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
voidReason: text('void_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
@@ -153,6 +163,22 @@ export const expenses = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Expense ↔ Packages junction ────────────────────────
|
||||
|
||||
export const expensePackages = pgTable(
|
||||
'expense_packages',
|
||||
{
|
||||
expenseId: uuid('expense_id')
|
||||
.notNull()
|
||||
.references(() => expenses.id, { onDelete: 'cascade' }),
|
||||
packageId: uuid('package_id')
|
||||
.notNull()
|
||||
.references((): any => packages.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.expenseId, table.packageId] })]
|
||||
);
|
||||
|
||||
// ── Tags ───────────────────────────────────────────────
|
||||
|
||||
export const tags = pgTable(
|
||||
@@ -432,7 +458,8 @@ export const invoiceStatusEnum = pgEnum('invoice_status', [
|
||||
'sent',
|
||||
'paid',
|
||||
'overdue',
|
||||
'cancelled'
|
||||
'cancelled',
|
||||
'voided'
|
||||
]);
|
||||
|
||||
export const invoices = pgTable(
|
||||
@@ -460,6 +487,8 @@ export const invoices = pgTable(
|
||||
}),
|
||||
notes: text('notes'),
|
||||
pdfPath: text('pdf_path'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
voidReason: text('void_reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
@@ -887,6 +916,8 @@ export const companyAccounts = pgTable(
|
||||
creditLimit: numeric('credit_limit', { precision: 15, scale: 2 }),
|
||||
statementCloseDay: integer('statement_close_day'),
|
||||
paymentDueDay: integer('payment_due_day'),
|
||||
// FX conversion to company base currency (e.g. 34.5 for USD→THB)
|
||||
fxRateToBase: numeric('fx_rate_to_base', { precision: 18, scale: 8 }).notNull().default('1'),
|
||||
// Banking integration link
|
||||
externalAccountId: uuid('external_account_id').references(() => externalAccounts.id, {
|
||||
onDelete: 'set null'
|
||||
@@ -974,6 +1005,10 @@ export const recurringBills = pgTable(
|
||||
.references(() => companyAccounts.id, { onDelete: 'restrict' }),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
||||
serviceAccountId: uuid('service_account_id').references(
|
||||
(): any => companyServiceAccounts.id,
|
||||
{ onDelete: 'set null' }
|
||||
),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
cycle: recurringBillCycleEnum('cycle').notNull(),
|
||||
@@ -999,6 +1034,208 @@ export const recurringBills = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
// ── Service Accounts ───────────────────────────────────
|
||||
|
||||
export const serviceAccountTypeEnum = pgEnum('service_account_type', [
|
||||
'electricity',
|
||||
'water',
|
||||
'gas',
|
||||
'internet',
|
||||
'phone',
|
||||
'shipping',
|
||||
'insurance',
|
||||
'tax_registration',
|
||||
'social_security',
|
||||
'customs',
|
||||
'other'
|
||||
]);
|
||||
|
||||
export const companyServiceAccounts = pgTable(
|
||||
'company_service_accounts',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
type: serviceAccountTypeEnum('type').notNull(),
|
||||
providerName: text('provider_name').notNull(),
|
||||
accountNumber: text('account_number').notNull(),
|
||||
customLabel: text('custom_label'),
|
||||
contactPhone: text('contact_phone'),
|
||||
websiteUrl: text('website_url'),
|
||||
notes: text('notes'),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
index('company_service_accounts_company_type_idx').on(table.companyId, table.type)
|
||||
]
|
||||
);
|
||||
|
||||
// ── Procedures & Checklists ────────────────────────────
|
||||
|
||||
export const procedureInstanceStatusEnum = pgEnum('procedure_instance_status', [
|
||||
'in_progress',
|
||||
'completed',
|
||||
'cancelled'
|
||||
]);
|
||||
|
||||
export const procedureTemplates = pgTable(
|
||||
'procedure_templates',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
category: text('category'),
|
||||
isPublished: boolean('is_published').notNull().default(false),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('procedure_templates_company_idx').on(table.companyId)]
|
||||
);
|
||||
|
||||
export const procedureSteps = pgTable(
|
||||
'procedure_steps',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
templateId: uuid('template_id')
|
||||
.notNull()
|
||||
.references(() => procedureTemplates.id, { onDelete: 'cascade' }),
|
||||
stepNumber: integer('step_number').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
assigneeRole: text('assignee_role'),
|
||||
estimatedMinutes: integer('estimated_minutes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('procedure_steps_template_step_idx').on(table.templateId, table.stepNumber)
|
||||
]
|
||||
);
|
||||
|
||||
export const procedureInstances = pgTable(
|
||||
'procedure_instances',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
templateId: uuid('template_id')
|
||||
.notNull()
|
||||
.references(() => procedureTemplates.id, { onDelete: 'restrict' }),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
status: procedureInstanceStatusEnum('status').notNull().default('in_progress'),
|
||||
startedBy: text('started_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
index('procedure_instances_company_status_idx').on(table.companyId, table.status)
|
||||
]
|
||||
);
|
||||
|
||||
export const procedureInstanceSteps = pgTable(
|
||||
'procedure_instance_steps',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
instanceId: uuid('instance_id')
|
||||
.notNull()
|
||||
.references(() => procedureInstances.id, { onDelete: 'cascade' }),
|
||||
stepId: uuid('step_id')
|
||||
.notNull()
|
||||
.references(() => procedureSteps.id, { onDelete: 'restrict' }),
|
||||
stepNumber: integer('step_number').notNull(),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
isCompleted: boolean('is_completed').notNull().default(false),
|
||||
completedBy: text('completed_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
notes: text('notes')
|
||||
},
|
||||
(table) => [
|
||||
index('procedure_instance_steps_instance_idx').on(table.instanceId, table.stepNumber)
|
||||
]
|
||||
);
|
||||
|
||||
// ── Sales / Income ─────────────────────────────────────
|
||||
|
||||
export const saleStatusEnum = pgEnum('sale_status', ['draft', 'confirmed', 'voided']);
|
||||
|
||||
export const sales = pgTable(
|
||||
'sales',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
companyId: uuid('company_id')
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: 'cascade' }),
|
||||
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'set null' }),
|
||||
partyId: uuid('party_id').references(() => parties.id, { onDelete: 'set null' }),
|
||||
invoiceId: uuid('invoice_id').references((): any => invoices.id, { onDelete: 'set null' }),
|
||||
title: text('title').notNull(),
|
||||
saleDate: date('sale_date').notNull(),
|
||||
currency: text('currency').notNull().default('THB'),
|
||||
withholdingTaxRate: numeric('withholding_tax_rate', { precision: 5, scale: 4 })
|
||||
.notNull()
|
||||
.default('0'),
|
||||
notes: text('notes'),
|
||||
status: saleStatusEnum('status').notNull().default('draft'),
|
||||
voidedAt: timestamp('voided_at', { withTimezone: true }),
|
||||
voidReason: text('void_reason'),
|
||||
createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [
|
||||
index('sales_company_status_idx').on(table.companyId, table.status),
|
||||
index('sales_project_idx').on(table.projectId),
|
||||
index('sales_date_idx').on(table.saleDate)
|
||||
]
|
||||
);
|
||||
|
||||
export const saleLineItems = pgTable(
|
||||
'sale_line_items',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
saleId: uuid('sale_id')
|
||||
.notNull()
|
||||
.references(() => sales.id, { onDelete: 'cascade' }),
|
||||
productName: text('product_name').notNull(),
|
||||
description: text('description'),
|
||||
quantity: numeric('quantity', { precision: 15, scale: 4 }).notNull().default('1'),
|
||||
unitPrice: numeric('unit_price', { precision: 15, scale: 2 }).notNull(),
|
||||
taxRate: numeric('tax_rate', { precision: 5, scale: 4 }).notNull().default('0'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [index('sale_line_items_sale_idx').on(table.saleId, table.sortOrder)]
|
||||
);
|
||||
|
||||
export const salePackages = pgTable(
|
||||
'sale_packages',
|
||||
{
|
||||
saleId: uuid('sale_id')
|
||||
.notNull()
|
||||
.references(() => sales.id, { onDelete: 'cascade' }),
|
||||
packageId: uuid('package_id')
|
||||
.notNull()
|
||||
.references(() => packages.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.saleId, table.packageId] })]
|
||||
);
|
||||
|
||||
export const companyAddresses = pgTable(
|
||||
'company_addresses',
|
||||
{
|
||||
@@ -1061,6 +1298,13 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'invoice_created',
|
||||
'invoice_sent',
|
||||
'invoice_paid',
|
||||
'invoice_voided',
|
||||
'expense_invoice_uploaded',
|
||||
'expense_updated',
|
||||
'expense_voided',
|
||||
'sale_created',
|
||||
'sale_confirmed',
|
||||
'sale_voided',
|
||||
'integration_connected',
|
||||
'integration_disconnected',
|
||||
'transaction_matched',
|
||||
@@ -1099,7 +1343,17 @@ export const companyLogEventEnum = pgEnum('company_log_event', [
|
||||
'recurring_bill_paused',
|
||||
'recurring_bill_resumed',
|
||||
'recurring_bill_skipped',
|
||||
'recurring_bill_posted'
|
||||
'recurring_bill_posted',
|
||||
'service_account_created',
|
||||
'service_account_updated',
|
||||
'service_account_deleted',
|
||||
'procedure_template_created',
|
||||
'procedure_template_updated',
|
||||
'procedure_template_deleted',
|
||||
'procedure_instance_started',
|
||||
'procedure_step_completed',
|
||||
'procedure_instance_completed',
|
||||
'procedure_instance_cancelled'
|
||||
]);
|
||||
|
||||
export const companyLog = pgTable(
|
||||
|
||||
@@ -74,7 +74,8 @@ async function resolvePublicIp(hostname: string): Promise<string> {
|
||||
return ips[0];
|
||||
}
|
||||
|
||||
async function safeFetch(targetUrl: URL): Promise<Response | null> {
|
||||
async function safeFetch(targetUrl: URL, depth = 0): Promise<Response | null> {
|
||||
if (depth > 3) return null;
|
||||
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') return null;
|
||||
try {
|
||||
await resolvePublicIp(targetUrl.hostname);
|
||||
@@ -109,7 +110,7 @@ async function safeFetch(targetUrl: URL): Promise<Response | null> {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return safeFetch(next);
|
||||
return safeFetch(next, depth + 1);
|
||||
}
|
||||
return res;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { companies, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||
|
||||
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies';
|
||||
const FALLBACK_URL = 'https://latest.currency-api.pages.dev/v1/currencies';
|
||||
|
||||
async function fetchJson(url: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRate(
|
||||
fromCurrency: string,
|
||||
toCurrency: string
|
||||
): Promise<number | null> {
|
||||
const from = fromCurrency.toLowerCase();
|
||||
const to = toCurrency.toLowerCase();
|
||||
if (from === to) return 1;
|
||||
|
||||
for (const base of [CDN_URL, FALLBACK_URL]) {
|
||||
try {
|
||||
const data = (await fetchJson(`${base}/${from}.min.json`)) as Record<string, unknown>;
|
||||
const rates = data[from] as Record<string, number> | undefined;
|
||||
if (rates && typeof rates[to] === 'number') {
|
||||
return rates[to];
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface FxUpdateResult {
|
||||
updated: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export async function updateAllFxRates(): Promise<FxUpdateResult> {
|
||||
let updated = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// Get all companies with their base currency
|
||||
const companyList = await db
|
||||
.select({ id: companies.id, currency: companies.currency })
|
||||
.from(companies)
|
||||
.where(isNull(companies.deletedAt));
|
||||
|
||||
for (const company of companyList) {
|
||||
const baseCurrency = company.currency;
|
||||
|
||||
// Find accounts in foreign currencies
|
||||
const foreignAccounts = await db
|
||||
.select({
|
||||
id: companyAccounts.id,
|
||||
currency: companyAccounts.currency
|
||||
})
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, company.id),
|
||||
isNull(companyAccounts.deletedAt),
|
||||
ne(companyAccounts.currency, baseCurrency)
|
||||
)
|
||||
);
|
||||
|
||||
if (foreignAccounts.length === 0) continue;
|
||||
|
||||
// Group by currency to minimize API calls
|
||||
const byCurrency = new Map<string, string[]>();
|
||||
for (const acct of foreignAccounts) {
|
||||
const ids = byCurrency.get(acct.currency) ?? [];
|
||||
ids.push(acct.id);
|
||||
byCurrency.set(acct.currency, ids);
|
||||
}
|
||||
|
||||
for (const [foreignCurrency, accountIds] of byCurrency) {
|
||||
try {
|
||||
const rate = await fetchRate(foreignCurrency, baseCurrency);
|
||||
if (rate === null) {
|
||||
errors.push(`No rate for ${foreignCurrency}→${baseCurrency}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const id of accountIds) {
|
||||
await db
|
||||
.update(companyAccounts)
|
||||
.set({
|
||||
fxRateToBase: rate.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(companyAccounts.id, id));
|
||||
updated++;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`${foreignCurrency}→${baseCurrency}: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, errors };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
export function isPaperlessEnabled(): boolean {
|
||||
return Boolean(env.PAPERLESS_URL && env.PAPERLESS_TOKEN);
|
||||
}
|
||||
|
||||
function baseUrl(): string {
|
||||
const raw = (env.PAPERLESS_URL ?? '').trim();
|
||||
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a File blob to Paperless-ngx.
|
||||
* Returns the task ID string if accepted; null on failure or if disabled.
|
||||
*
|
||||
* Paperless accepts multipart/form-data at /api/documents/post_document/
|
||||
* and returns a task UUID (string) — the doc ID itself is assigned asynchronously
|
||||
* after OCR. Callers can store the task ID as a reference.
|
||||
*/
|
||||
export async function uploadToPaperless(
|
||||
file: File,
|
||||
title?: string
|
||||
): Promise<{ taskId: string } | null> {
|
||||
if (!isPaperlessEnabled()) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('document', file, file.name);
|
||||
if (title) form.append('title', title);
|
||||
|
||||
const res = await fetch(`${baseUrl()}/api/documents/post_document/`, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Token ${env.PAPERLESS_TOKEN}`
|
||||
},
|
||||
body: form
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('[paperless] upload failed', res.status, await res.text().catch(() => ''));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Paperless returns a quoted task-id string in the body.
|
||||
const raw = (await res.text()).trim().replace(/^"|"$/g, '');
|
||||
return { taskId: raw };
|
||||
} catch (err) {
|
||||
console.error('[paperless] upload error', err);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { postBillsDue } from './poster.js';
|
||||
import { updateAllFxRates } from '$lib/server/fx/index.js';
|
||||
|
||||
const INTERVAL_MS = 15 * 60 * 1000;
|
||||
const GUARD_KEY = '__b4lRecurringBillsScheduler';
|
||||
const FX_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const GUARD_KEY = '__b4lScheduler';
|
||||
const FX_LAST_KEY = '__b4lFxLastRefresh';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type GlobalWithGuard = typeof globalThis & { [GUARD_KEY]?: NodeJS.Timeout };
|
||||
type GlobalAny = typeof globalThis & Record<string, any>;
|
||||
|
||||
export function startScheduler(): void {
|
||||
const g = globalThis as GlobalWithGuard;
|
||||
const g = globalThis as GlobalAny;
|
||||
if (g[GUARD_KEY]) return;
|
||||
|
||||
g[GUARD_KEY] = setInterval(async () => {
|
||||
// ── Recurring bills (every 15min) ──
|
||||
try {
|
||||
const result = await postBillsDue();
|
||||
if (result.postedCount > 0 || result.errors.length > 0 || result.skippedCount > 0) {
|
||||
@@ -19,7 +23,21 @@ export function startScheduler(): void {
|
||||
} catch (err) {
|
||||
console.error('[scheduler] recurring bills tick error:', err);
|
||||
}
|
||||
|
||||
// ── FX rate refresh (daily) ──
|
||||
const lastFx: number = g[FX_LAST_KEY] ?? 0;
|
||||
if (Date.now() - lastFx > FX_INTERVAL_MS) {
|
||||
try {
|
||||
const result = await updateAllFxRates();
|
||||
g[FX_LAST_KEY] = Date.now();
|
||||
if (result.updated > 0 || result.errors.length > 0) {
|
||||
console.log('[scheduler] FX rates refreshed:', result);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[scheduler] FX refresh error:', err);
|
||||
}
|
||||
}
|
||||
}, INTERVAL_MS);
|
||||
|
||||
console.log('[scheduler] recurring bills started (interval: 15min)');
|
||||
console.log('[scheduler] started (bills: 15min, FX: daily)');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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, and, isNull } from 'drizzle-orm';
|
||||
import { companies, companyAccounts, companyAccountTransactions } from '$lib/server/db/schema.js';
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import { requireAuth, getCompanyRoles } from '$lib/server/authorization.js';
|
||||
import type { CompanyRole } from '$lib/types/index.js';
|
||||
|
||||
@@ -27,12 +27,26 @@ export const load: LayoutServerLoad = async ({ locals, params }) => {
|
||||
error(403, 'Not a member of this company');
|
||||
}
|
||||
|
||||
// Total budget = sum of all non-deleted account balances, converted to base currency
|
||||
const [balanceRow] = await db
|
||||
.select({
|
||||
total: sql<string>`coalesce(sum(${companyAccountTransactions.amount} * ${companyAccounts.fxRateToBase}), '0')::text`
|
||||
})
|
||||
.from(companyAccountTransactions)
|
||||
.innerJoin(companyAccounts, eq(companyAccountTransactions.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccountTransactions.companyId, company.id),
|
||||
isNull(companyAccounts.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
company: {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
description: company.description,
|
||||
totalBudget: company.totalBudget,
|
||||
totalBudget: balanceRow?.total ?? '0',
|
||||
currency: company.currency
|
||||
},
|
||||
companyRoles: roles
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
{ 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}/sales`, label: 'Sales', show: has(['admin', 'manager', 'accountant']) },
|
||||
{ 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 },
|
||||
@@ -55,7 +56,9 @@
|
||||
{ href: `${baseUrl}/categories`, label: 'Categories', show: true },
|
||||
{ href: `${baseUrl}/packages`, label: 'Packages', show: has(['admin', 'manager', 'user', 'hr']) },
|
||||
{ href: `${baseUrl}/links`, label: 'Links', show: true },
|
||||
{ href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) }
|
||||
{ href: `${baseUrl}/documents`, label: 'Documents', show: has(['admin', 'manager', 'accountant']) },
|
||||
{ href: `${baseUrl}/service-accounts`, label: 'Service Accounts', show: has(['admin', 'manager', 'accountant']) },
|
||||
{ href: `${baseUrl}/procedures`, label: 'Procedures', show: true }
|
||||
].filter((t) => t.show)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses } from '$lib/server/db/schema.js';
|
||||
import {
|
||||
projects,
|
||||
expenses,
|
||||
sales,
|
||||
saleLineItems,
|
||||
companyAccounts
|
||||
} 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
|
||||
// Get projects with spent amounts (converted to base currency via each expense's account fx rate)
|
||||
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)`,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
pendingCount: sql<number>`count(case when ${expenses.status} = 'pending' then 1 end)::int`
|
||||
})
|
||||
.from(projects)
|
||||
.leftJoin(expenses, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(projects.companyId, company.id))
|
||||
.groupBy(projects.id)
|
||||
.orderBy(projects.name);
|
||||
@@ -38,5 +45,20 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
.orderBy(sql`${expenses.createdAt} desc`)
|
||||
.limit(10);
|
||||
|
||||
return { projects: projectList, recentExpenses };
|
||||
// Total confirmed sales income (net of withholding)
|
||||
const [incomeRow] = await db
|
||||
.select({
|
||||
total: sql<string>`coalesce(sum(
|
||||
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
|
||||
* (1 - ${sales.withholdingTaxRate})
|
||||
), '0')::text`
|
||||
})
|
||||
.from(sales)
|
||||
.where(and(eq(sales.companyId, company.id), eq(sales.status, 'confirmed')));
|
||||
|
||||
return {
|
||||
projects: projectList,
|
||||
recentExpenses,
|
||||
totalIncome: incomeRow?.total ?? '0'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,10 +8,16 @@
|
||||
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);
|
||||
const income = $derived(parseFloat(data.totalIncome ?? '0'));
|
||||
// Total already reflects approved expenses (they post negative txns to the ledger),
|
||||
// so available cash IS the total. Spent stays informational.
|
||||
const available = $derived(total);
|
||||
const unallocated = $derived(total - allocated);
|
||||
const allocatedPct = $derived(total > 0 ? (allocated / total) * 100 : 0);
|
||||
const net = $derived(income - spent);
|
||||
const netPositive = $derived(net >= 0);
|
||||
|
||||
const tone = $derived(remaining < 0 ? 'red' : remainingPct < 20 ? 'amber' : 'green');
|
||||
const tone = $derived(available < 0 ? 'red' : available < Math.abs(allocated) * 0.2 ? 'amber' : 'green');
|
||||
|
||||
const toneRing: Record<string, string> = {
|
||||
green: 'border-green-300 dark:border-green-700',
|
||||
@@ -35,45 +41,64 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- KPI row -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="rounded-lg border-2 {toneRing[tone]} bg-white p-4 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Remaining
|
||||
<!-- Income vs Expenses (hero split) -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border-2 border-emerald-300 bg-emerald-50 p-5 dark:border-emerald-700 dark:bg-emerald-900/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400">
|
||||
Income
|
||||
</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>
|
||||
<svg class="h-5 w-5 text-emerald-500 dark:text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 3a1 1 0 01.707.293l5 5a1 1 0 01-1.414 1.414L11 6.414V16a1 1 0 11-2 0V6.414L5.707 9.707a1 1 0 01-1.414-1.414l5-5A1 1 0 0110 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{remainingPct.toFixed(1)}% of total
|
||||
<p class="mt-2 text-3xl font-bold text-emerald-700 dark:text-emerald-400">
|
||||
{formatCurrency(income, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-emerald-700/70 dark:text-emerald-400/70">
|
||||
Net of withholding · from confirmed sales
|
||||
</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">
|
||||
Total Budget
|
||||
<div class="rounded-lg border-2 border-red-300 bg-red-50 p-5 dark:border-red-700 dark:bg-red-900/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-red-600 dark:text-red-400">
|
||||
Expenses
|
||||
</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>
|
||||
<svg class="h-5 w-5 text-red-500 dark:text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M10 17a1 1 0 01-.707-.293l-5-5a1 1 0 011.414-1.414L9 13.586V4a1 1 0 112 0v9.586l3.293-3.293a1 1 0 011.414 1.414l-5 5A1 1 0 0110 17z" />
|
||||
</svg>
|
||||
</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">
|
||||
Spent
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<p class="mt-2 text-3xl font-bold text-red-700 dark:text-red-400">
|
||||
{formatCurrency(spent, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-red-700/70 dark:text-red-400/70">
|
||||
Approved · across {data.projects.length} {data.projects.length === 1 ? 'project' : 'projects'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Net position -->
|
||||
<div class="rounded-lg border-2 {netPositive ? 'border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-900/20' : 'border-red-300 bg-red-50 dark:border-red-700 dark:bg-red-900/20'} p-5 md:col-span-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider {netPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}">
|
||||
Net Position (Income − Expenses)
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-bold {netPositive ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-700 dark:text-red-400'}">
|
||||
{netPositive ? '+' : ''}{formatCurrency(net, currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cash KPIs (secondary) -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||
<div class="rounded-lg border {toneRing[tone]} bg-white p-4 dark:bg-gray-800">
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Available Cash
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold {toneText[tone]}">
|
||||
{formatCurrency(available, 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'}
|
||||
Sum of account balances (base currency)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -84,8 +109,23 @@
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{formatCurrency(allocated, 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 bg-blue-500 transition-all" style="width: {Math.min(allocatedPct, 100)}%"></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{total > 0 ? ((allocated / total) * 100).toFixed(1) : '0'}% of total
|
||||
{allocatedPct.toFixed(1)}% of available
|
||||
</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">
|
||||
Unallocated
|
||||
</p>
|
||||
<p class="mt-1 text-2xl font-bold {unallocated < 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'}">
|
||||
{formatCurrency(unallocated, currency)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Cash not assigned to a project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
companies,
|
||||
companyAccounts,
|
||||
companyAccountTransactions,
|
||||
externalAccounts
|
||||
@@ -13,8 +14,26 @@ import {
|
||||
postTransfer,
|
||||
type CompanyAccountTxnType
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
import { fetchRate } from '$lib/server/fx/index.js';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
async function resolveFxRate(companyId: string, accountCurrency: string): Promise<string> {
|
||||
const [company] = await db
|
||||
.select({ currency: companies.currency })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.limit(1);
|
||||
const base = company?.currency ?? 'THB';
|
||||
if (accountCurrency.toUpperCase() === base.toUpperCase()) return '1';
|
||||
try {
|
||||
const rate = await fetchRate(accountCurrency, base);
|
||||
if (rate !== null && rate > 0) return rate.toFixed(8);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return '1';
|
||||
}
|
||||
|
||||
const MANUAL_TXN_TYPES = ['deposit', 'adjustment'] as const;
|
||||
type ManualTxnType = (typeof MANUAL_TXN_TYPES)[number];
|
||||
|
||||
@@ -112,6 +131,7 @@ type AccountFields = {
|
||||
creditLimit: string | null;
|
||||
statementCloseDay: number | null;
|
||||
paymentDueDay: number | null;
|
||||
fxRateToBase: string;
|
||||
externalAccountId: string | null;
|
||||
};
|
||||
|
||||
@@ -171,6 +191,7 @@ function extractAccountFields(fd: FormData):
|
||||
creditLimit: parseDecimalOrNull(fd.get('creditLimit')),
|
||||
statementCloseDay,
|
||||
paymentDueDay,
|
||||
fxRateToBase: parseDecimalOrNull(fd.get('fxRateToBase')) ?? '1',
|
||||
externalAccountId: trimOrNull(fd.get('externalAccountId'))
|
||||
}
|
||||
};
|
||||
@@ -279,6 +300,9 @@ export const actions: Actions = {
|
||||
const openingBalanceDate =
|
||||
parseDate(fd.get('openingBalanceDate')) ?? new Date();
|
||||
|
||||
// Auto-determine FX rate: 1 for base currency, API rate otherwise
|
||||
const fxRateToBase = await resolveFxRate(params.companyId, f.currency);
|
||||
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(companyAccounts)
|
||||
@@ -304,6 +328,7 @@ export const actions: Actions = {
|
||||
creditLimit: f.creditLimit,
|
||||
statementCloseDay: f.statementCloseDay,
|
||||
paymentDueDay: f.paymentDueDay,
|
||||
fxRateToBase,
|
||||
externalAccountId: f.externalAccountId
|
||||
})
|
||||
.returning({ id: companyAccounts.id });
|
||||
@@ -383,6 +408,7 @@ export const actions: Actions = {
|
||||
creditLimit: f.creditLimit,
|
||||
statementCloseDay: f.statementCloseDay,
|
||||
paymentDueDay: f.paymentDueDay,
|
||||
fxRateToBase: f.fxRateToBase,
|
||||
externalAccountId: f.externalAccountId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
creditLimit?: string | null;
|
||||
statementCloseDay?: number | null;
|
||||
paymentDueDay?: number | null;
|
||||
fxRateToBase?: string | null;
|
||||
externalAccountId?: string | null;
|
||||
} = {}
|
||||
)}
|
||||
@@ -176,7 +177,26 @@
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div></div>
|
||||
{#if prefix.startsWith('edit-')}
|
||||
<div>
|
||||
<label for="{prefix}-fxRate" class={labelCls}>FX Rate to Base</label>
|
||||
<input
|
||||
id="{prefix}-fxRate"
|
||||
name="fxRateToBase"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min="0.0001"
|
||||
value={prefill.fxRateToBase ?? '1'}
|
||||
placeholder="1.0 for THB, 34.5 for USD→THB"
|
||||
class={inputCls}
|
||||
/>
|
||||
<p class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">Auto-refreshed daily from FX API. Override here to override.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 self-end pb-2">
|
||||
FX rate: auto-set on create (1 if base currency, else fetched from FX API)
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if type === 'bank'}
|
||||
<div>
|
||||
@@ -753,6 +773,13 @@
|
||||
<p class="text-2xl font-bold {balanceClass(acct.balance)}">
|
||||
{formatAmount(acct.balance, acct.currency)}
|
||||
</p>
|
||||
{#if acct.currency !== data.company.currency && acct.fxRateToBase}
|
||||
{@const baseEquivalent = (Number(acct.balance) * Number(acct.fxRateToBase)).toFixed(2)}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
≈ {formatAmount(baseEquivalent, data.company.currency)}
|
||||
<span class="text-gray-400 dark:text-gray-500">(@ {Number(acct.fxRateToBase)})</span>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if acct.accountType === 'credit_card' && acct.creditLimit}
|
||||
{@const pct = utilisation(acct.balance, acct.creditLimit)}
|
||||
@@ -891,6 +918,7 @@
|
||||
creditLimit: acct.creditLimit,
|
||||
statementCloseDay: acct.statementCloseDay,
|
||||
paymentDueDay: acct.paymentDueDay,
|
||||
fxRateToBase: acct.fxRateToBase,
|
||||
externalAccountId: acct.externalAccountId
|
||||
})}
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
|
||||
@@ -347,6 +347,22 @@
|
||||
Counterparty: {txn.counterpartyName}
|
||||
</div>
|
||||
{/if}
|
||||
{#if txn.sourceExpenseId}
|
||||
<a
|
||||
href={`/companies/${data.company.id}/expenses/${txn.sourceExpenseId}`}
|
||||
class="mt-0.5 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Open expense →
|
||||
</a>
|
||||
{/if}
|
||||
{#if txn.sourceInvoiceId}
|
||||
<a
|
||||
href={`/companies/${data.company.id}/invoices/${txn.sourceInvoiceId}`}
|
||||
class="mt-0.5 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Open invoice →
|
||||
</a>
|
||||
{/if}
|
||||
{#if txn.fxRate && txn.fxAmount}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400">
|
||||
FX: {txn.fxAmount} @ {Number(txn.fxRate).toFixed(4)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
recurringBills,
|
||||
companyAccounts,
|
||||
companyServiceAccounts,
|
||||
projects,
|
||||
categories,
|
||||
parties
|
||||
@@ -60,6 +61,7 @@ type BillFormFields = {
|
||||
projectId: string;
|
||||
categoryId: string | null;
|
||||
partyId: string | null;
|
||||
serviceAccountId: string | null;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
startDate: string;
|
||||
@@ -106,6 +108,7 @@ function extractFields(fd: FormData): BillFormFields | string {
|
||||
projectId,
|
||||
categoryId: trimOrNull(fd.get('categoryId')),
|
||||
partyId: trimOrNull(fd.get('partyId')),
|
||||
serviceAccountId: trimOrNull(fd.get('serviceAccountId')),
|
||||
description: trimOrNull(fd.get('description')),
|
||||
currency,
|
||||
startDate,
|
||||
@@ -118,7 +121,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const [billRows, accountRows, projectRows, categoryRows, partyRows] = await Promise.all([
|
||||
const [billRows, accountRows, projectRows, categoryRows, partyRows, serviceAccountRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: recurringBills.id,
|
||||
@@ -144,6 +148,9 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
categoryName: categories.name,
|
||||
partyId: recurringBills.partyId,
|
||||
partyName: parties.name,
|
||||
serviceAccountId: recurringBills.serviceAccountId,
|
||||
serviceAccountProvider: companyServiceAccounts.providerName,
|
||||
serviceAccountNumber: companyServiceAccounts.accountNumber,
|
||||
createdAt: recurringBills.createdAt,
|
||||
updatedAt: recurringBills.updatedAt
|
||||
})
|
||||
@@ -152,6 +159,7 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
.leftJoin(projects, eq(recurringBills.projectId, projects.id))
|
||||
.leftJoin(categories, eq(recurringBills.categoryId, categories.id))
|
||||
.leftJoin(parties, eq(recurringBills.partyId, parties.id))
|
||||
.leftJoin(companyServiceAccounts, eq(recurringBills.serviceAccountId, companyServiceAccounts.id))
|
||||
.where(
|
||||
and(eq(recurringBills.companyId, params.companyId), isNull(recurringBills.deletedAt))
|
||||
)
|
||||
@@ -190,7 +198,25 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||
.orderBy(asc(parties.name))
|
||||
.orderBy(asc(parties.name)),
|
||||
|
||||
db
|
||||
.select({
|
||||
id: companyServiceAccounts.id,
|
||||
type: companyServiceAccounts.type,
|
||||
providerName: companyServiceAccounts.providerName,
|
||||
accountNumber: companyServiceAccounts.accountNumber,
|
||||
customLabel: companyServiceAccounts.customLabel
|
||||
})
|
||||
.from(companyServiceAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyServiceAccounts.companyId, params.companyId),
|
||||
isNull(companyServiceAccounts.deletedAt),
|
||||
eq(companyServiceAccounts.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(companyServiceAccounts.type), asc(companyServiceAccounts.providerName))
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -198,7 +224,8 @@ export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
accounts: accountRows,
|
||||
projects: projectRows,
|
||||
categories: categoryRows,
|
||||
parties: partyRows
|
||||
parties: partyRows,
|
||||
serviceAccounts: serviceAccountRows
|
||||
};
|
||||
};
|
||||
|
||||
@@ -259,6 +286,7 @@ export const actions: Actions = {
|
||||
accountId: parsed.accountId,
|
||||
categoryId: parsed.categoryId,
|
||||
partyId: parsed.partyId,
|
||||
serviceAccountId: parsed.serviceAccountId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
cycle: parsed.cycle,
|
||||
@@ -342,6 +370,7 @@ export const actions: Actions = {
|
||||
accountId: parsed.accountId,
|
||||
categoryId: parsed.categoryId,
|
||||
partyId: parsed.partyId,
|
||||
serviceAccountId: parsed.serviceAccountId,
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
cycle: parsed.cycle,
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
projectId?: string;
|
||||
categoryId?: string;
|
||||
partyId?: string;
|
||||
serviceAccountId?: string;
|
||||
description?: string;
|
||||
currency?: string;
|
||||
startDate?: string;
|
||||
@@ -211,6 +212,20 @@
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-sa-{billId ?? 'new'}">Service Account</label>
|
||||
<select
|
||||
id="bill-sa-{billId ?? 'new'}"
|
||||
name="serviceAccountId"
|
||||
value={values.serviceAccountId ?? ''}
|
||||
class={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each data.serviceAccounts as sa (sa.id)}
|
||||
<option value={sa.id}>[{sa.type}] {sa.providerName} #{sa.accountNumber}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="bill-start-{billId ?? 'new'}">Start date <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
@@ -379,6 +394,11 @@
|
||||
Vendor: {bill.partyName}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bill.serviceAccountProvider}
|
||||
<div class="text-xs text-indigo-600 dark:text-indigo-400">
|
||||
{bill.serviceAccountProvider} #{bill.serviceAccountNumber}
|
||||
</div>
|
||||
{/if}
|
||||
{#if bill.status === 'paused' && bill.pausedAt}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Paused since {formatDate(bill.pausedAt)}
|
||||
@@ -526,6 +546,7 @@
|
||||
projectId: bill.projectId,
|
||||
categoryId: bill.categoryId ?? '',
|
||||
partyId: bill.partyId ?? '',
|
||||
serviceAccountId: bill.serviceAccountId ?? '',
|
||||
description: bill.description ?? '',
|
||||
currency: bill.currency,
|
||||
startDate: bill.startDate,
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
companies,
|
||||
users,
|
||||
expenses,
|
||||
companyLog
|
||||
companyLog,
|
||||
sales,
|
||||
saleLineItems,
|
||||
companyAccounts
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { and, 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';
|
||||
@@ -17,19 +20,50 @@ import { formatCurrency } from '$lib/utils/currency.js';
|
||||
export const load: PageServerLoad = async ({ parent, params }) => {
|
||||
const { company } = await parent();
|
||||
|
||||
const projectList = await db
|
||||
const projectListRaw = 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)`
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`
|
||||
})
|
||||
.from(projects)
|
||||
.leftJoin(expenses, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(projects.companyId, params.companyId))
|
||||
.groupBy(projects.id)
|
||||
.orderBy(projects.name);
|
||||
|
||||
// Income per project from confirmed sales (gross - withholding = net receivable)
|
||||
const incomeRows = await db
|
||||
.select({
|
||||
projectId: sales.projectId,
|
||||
income: sql<string>`coalesce(sum(
|
||||
(select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate})) from sale_line_items where sale_id = ${sales.id})
|
||||
* (1 - ${sales.withholdingTaxRate})
|
||||
), '0')::text`
|
||||
})
|
||||
.from(sales)
|
||||
.where(
|
||||
and(
|
||||
eq(sales.companyId, params.companyId),
|
||||
eq(sales.status, 'confirmed')
|
||||
)
|
||||
)
|
||||
.groupBy(sales.projectId);
|
||||
|
||||
const incomeByProject = new Map<string | null, string>();
|
||||
for (const row of incomeRows) {
|
||||
incomeByProject.set(row.projectId, row.income);
|
||||
}
|
||||
|
||||
const projectList = projectListRaw.map((p) => ({
|
||||
...p,
|
||||
income: incomeByProject.get(p.id) ?? '0'
|
||||
}));
|
||||
|
||||
const unassignedIncome = incomeByProject.get(null) ?? '0';
|
||||
|
||||
const allocations = await db
|
||||
.select({
|
||||
id: budgetAllocations.id,
|
||||
@@ -64,48 +98,20 @@ export const load: PageServerLoad = async ({ parent, params }) => {
|
||||
.limit(100);
|
||||
|
||||
const totalAllocated = projectList.reduce((s, p) => s + parseFloat(p.allocatedBudget), 0);
|
||||
const totalIncome =
|
||||
projectList.reduce((s, p) => s + parseFloat(p.income), 0) + parseFloat(unassignedIncome);
|
||||
|
||||
return { projects: projectList, allocations, totalAllocated, changelog };
|
||||
return {
|
||||
projects: projectList,
|
||||
allocations,
|
||||
totalAllocated,
|
||||
totalIncome,
|
||||
unassignedIncome,
|
||||
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');
|
||||
|
||||
@@ -118,12 +124,13 @@ export const actions: Actions = {
|
||||
return fail(400, { error: 'Project and non-zero amount are required' });
|
||||
}
|
||||
|
||||
// Get project name and company currency for the log
|
||||
// Verify project belongs to this company
|
||||
const [project] = await db
|
||||
.select({ name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!project) return fail(400, { error: 'Project not found in this company' });
|
||||
|
||||
const [company] = await db
|
||||
.select({ currency: companies.currency })
|
||||
@@ -137,7 +144,7 @@ export const actions: Actions = {
|
||||
allocatedBudget: sql`${projects.allocatedBudget}::numeric + ${amount.toFixed(2)}::numeric`,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(projects.id, projectId));
|
||||
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)));
|
||||
|
||||
await db.insert(budgetAllocations).values({
|
||||
companyId: params.companyId,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
const canAllocate = $derived(data.companyRoles.includes('admin') || data.companyRoles.includes('manager'));
|
||||
const isAdmin = $derived(data.companyRoles.includes('admin'));
|
||||
|
||||
let showAddBudget = $state(false);
|
||||
// Budget total now comes from account balances — no manual add
|
||||
|
||||
function getEventStyle(event: string) {
|
||||
const styles: Record<string, { icon: string; bg: string; text: string; badge: string; label: string }> = {
|
||||
@@ -44,65 +44,17 @@
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">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}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Total budget reflects the sum of your account balances. Manage funds via the Accounts tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="mb-4 rounded-md bg-red-50 dark:bg-red-900/30 p-3 text-sm text-red-700 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Budget form (admin only) -->
|
||||
{#if showAddBudget && isAdmin}
|
||||
<div class="mb-6 rounded-lg border-2 border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30 p-5">
|
||||
<h3 class="mb-3 font-medium text-gray-900 dark:text-white">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 dark:text-gray-300">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 dark:border-gray-600 dark:bg-gray-700 dark:text-white 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 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-4">
|
||||
<!-- Remaining — hero card -->
|
||||
|
||||
+3
-2
@@ -41,8 +41,9 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
|
||||
return new Response(new Blob([buf as BlobPart], { type: row.mimeType }), {
|
||||
headers: {
|
||||
'Content-Disposition': `inline; filename="${safeName}"`,
|
||||
'Cache-Control': 'private, no-store'
|
||||
'Content-Disposition': `attachment; filename="${safeName}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,9 +6,14 @@ import {
|
||||
projects,
|
||||
users,
|
||||
categories,
|
||||
companyAccounts
|
||||
companyAccounts,
|
||||
companies,
|
||||
invoices,
|
||||
parties,
|
||||
packages,
|
||||
expensePackages
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, isNull } from 'drizzle-orm';
|
||||
import { asc, eq, and, ne, sql, isNull } from 'drizzle-orm';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
@@ -16,6 +21,8 @@ import {
|
||||
postExpenseTransaction,
|
||||
removeExpenseTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js';
|
||||
import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
await parent();
|
||||
@@ -40,6 +47,10 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
categoryName: categories.name,
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
invoiceId: expenses.invoiceId,
|
||||
invoiceFileUrl: expenses.invoiceFileUrl,
|
||||
invoiceFileName: expenses.invoiceFileName,
|
||||
paperlessUrl: expenses.paperlessUrl,
|
||||
createdAt: expenses.createdAt
|
||||
})
|
||||
.from(expenses)
|
||||
@@ -75,10 +86,165 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
)
|
||||
.orderBy(companyAccounts.name);
|
||||
|
||||
return { expenses: expenseList, statusFilter: status, accounts: accountsList };
|
||||
const projectList = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
|
||||
.orderBy(asc(projects.name));
|
||||
|
||||
const categoryList = await db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.where(eq(categories.companyId, params.companyId))
|
||||
.orderBy(asc(categories.name));
|
||||
|
||||
const invoiceList = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
direction: invoices.direction,
|
||||
partyName: parties.name,
|
||||
total: invoices.total,
|
||||
currency: invoices.currency
|
||||
})
|
||||
.from(invoices)
|
||||
.innerJoin(parties, eq(invoices.partyId, parties.id))
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.companyId, params.companyId),
|
||||
ne(invoices.status, 'voided'),
|
||||
ne(invoices.status, 'cancelled')
|
||||
)
|
||||
)
|
||||
.orderBy(asc(invoices.invoiceNumber));
|
||||
|
||||
const packageList = await db
|
||||
.select({
|
||||
id: packages.id,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
direction: packages.direction,
|
||||
status: packages.status
|
||||
})
|
||||
.from(packages)
|
||||
.where(eq(packages.companyId, params.companyId))
|
||||
.orderBy(sql`${packages.createdAt} desc`);
|
||||
|
||||
const expensePackageLinks = await db
|
||||
.select({
|
||||
expenseId: expensePackages.expenseId,
|
||||
packageId: expensePackages.packageId
|
||||
})
|
||||
.from(expensePackages)
|
||||
.innerJoin(expenses, eq(expensePackages.expenseId, expenses.id))
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(eq(projects.companyId, params.companyId));
|
||||
|
||||
return {
|
||||
expenses: expenseList,
|
||||
statusFilter: status,
|
||||
accounts: accountsList,
|
||||
projects: projectList,
|
||||
categories: categoryList,
|
||||
invoices: invoiceList,
|
||||
packages: packageList,
|
||||
expensePackageLinks
|
||||
};
|
||||
};
|
||||
|
||||
async function ensureGeneralProject(companyId: string): Promise<string> {
|
||||
const [existing] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, companyId), eq(projects.name, 'General')))
|
||||
.limit(1);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const [created] = await db
|
||||
.insert(projects)
|
||||
.values({ companyId, name: 'General', description: 'Company-wide expenses' })
|
||||
.returning({ id: projects.id });
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
submitExpense: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'hr', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const title = fd.get('title')?.toString().trim();
|
||||
const amountStr = fd.get('amount')?.toString().trim();
|
||||
const projectId = fd.get('projectId')?.toString().trim() || null;
|
||||
const categoryId = fd.get('categoryId')?.toString().trim() || null;
|
||||
const accountId = fd.get('accountId')?.toString().trim() || null;
|
||||
const invoiceId = fd.get('invoiceId')?.toString().trim() || null;
|
||||
const expenseDate = fd.get('expenseDate')?.toString().trim();
|
||||
const description = fd.get('description')?.toString().trim() || null;
|
||||
|
||||
if (!title) return fail(400, { action: 'submitExpense', error: 'Title is required' });
|
||||
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
|
||||
return fail(400, { action: 'submitExpense', error: 'Valid positive amount is required' });
|
||||
}
|
||||
if (!expenseDate) return fail(400, { action: 'submitExpense', error: 'Date is required' });
|
||||
|
||||
const resolvedProjectId = projectId || (await ensureGeneralProject(params.companyId));
|
||||
|
||||
const [proj] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, resolvedProjectId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!proj) return fail(400, { action: 'submitExpense', error: 'Project not found' });
|
||||
|
||||
// Resolve currency: use the selected account's currency, else company base
|
||||
let resolvedCurrency = 'THB';
|
||||
if (accountId) {
|
||||
const [acct] = await db
|
||||
.select({ currency: companyAccounts.currency })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.id, accountId),
|
||||
eq(companyAccounts.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (acct) resolvedCurrency = acct.currency;
|
||||
} else {
|
||||
const [company] = await db
|
||||
.select({ currency: companies.currency })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, params.companyId))
|
||||
.limit(1);
|
||||
if (company) resolvedCurrency = company.currency;
|
||||
}
|
||||
|
||||
await db.insert(expenses).values({
|
||||
projectId: resolvedProjectId,
|
||||
categoryId: categoryId || null,
|
||||
accountId: accountId || null,
|
||||
invoiceId: invoiceId || null,
|
||||
submittedBy: user.id,
|
||||
title,
|
||||
description,
|
||||
amount: Number(amountStr).toFixed(2),
|
||||
currency: resolvedCurrency,
|
||||
expenseDate,
|
||||
status: 'pending'
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'expense_submitted',
|
||||
`Expense "${title}" submitted for ${formatCurrency(amountStr, 'THB')}`,
|
||||
{ projectId: resolvedProjectId }
|
||||
);
|
||||
|
||||
return { success: true, action: 'submitExpense' };
|
||||
},
|
||||
|
||||
approve: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const formData = await request.formData();
|
||||
@@ -94,8 +260,10 @@ export const actions: Actions = {
|
||||
accountId: expenses.accountId
|
||||
})
|
||||
.from(expenses)
|
||||
.where(eq(expenses.id, expenseId))
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!expense) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
@@ -137,8 +305,10 @@ export const actions: Actions = {
|
||||
const [expense] = await db
|
||||
.select({ title: expenses.title, amount: expenses.amount, currency: expenses.currency })
|
||||
.from(expenses)
|
||||
.where(eq(expenses.id, expenseId))
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!expense) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
@@ -234,5 +404,173 @@ export const actions: Actions = {
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
linkInvoice: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const expenseId = fd.get('expenseId')?.toString();
|
||||
const invoiceId = fd.get('invoiceId')?.toString().trim() || null;
|
||||
|
||||
if (!expenseId) return fail(400, { error: 'Expense ID required' });
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({ invoiceId, updatedAt: new Date() })
|
||||
.where(eq(expenses.id, expenseId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
uploadExpenseInvoice: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const expenseId = fd.get('expenseId')?.toString();
|
||||
const file = fd.get('file') as File | null;
|
||||
|
||||
if (!expenseId) return fail(400, { error: 'Expense ID required' });
|
||||
if (!file || !(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { action: 'uploadExpenseInvoice', error: 'File is required' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return fail(400, {
|
||||
action: 'uploadExpenseInvoice',
|
||||
error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)`
|
||||
});
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!isAllowedMime(mime)) {
|
||||
return fail(400, {
|
||||
action: 'uploadExpenseInvoice',
|
||||
error: `File type not allowed: ${mime}`
|
||||
});
|
||||
}
|
||||
|
||||
// Verify expense belongs to this company
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id, title: expenses.title })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = await saveCompanyFile(params.companyId, file);
|
||||
} catch (err) {
|
||||
console.error('saveCompanyFile failed', err);
|
||||
return fail(500, { action: 'uploadExpenseInvoice', error: 'Failed to save file' });
|
||||
}
|
||||
|
||||
// Fire-and-forget Paperless push if configured
|
||||
let paperlessTaskId: string | null = null;
|
||||
if (isPaperlessEnabled()) {
|
||||
const paperlessResult = await uploadToPaperless(file, exp.title);
|
||||
paperlessTaskId = paperlessResult?.taskId ?? null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({
|
||||
invoiceFileUrl: saved.storedPath,
|
||||
invoiceFileName: file.name,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, expenseId));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'expense_invoice_uploaded',
|
||||
`Invoice attached to expense "${exp.title}"`,
|
||||
{ expenseId, fileName: file.name, paperlessTaskId }
|
||||
);
|
||||
|
||||
return { success: true, action: 'uploadExpenseInvoice' };
|
||||
},
|
||||
|
||||
linkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const expenseId = fd.get('expenseId')?.toString();
|
||||
const packageId = fd.get('packageId')?.toString();
|
||||
|
||||
if (!expenseId || !packageId) return fail(400, { error: 'Expense and package IDs required' });
|
||||
|
||||
// Verify expense belongs to this company
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
// Verify package belongs to this company
|
||||
const [pkg] = await db
|
||||
.select({ id: packages.id })
|
||||
.from(packages)
|
||||
.where(and(eq(packages.id, packageId), eq(packages.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!pkg) return fail(404, { error: 'Package not found' });
|
||||
|
||||
await db
|
||||
.insert(expensePackages)
|
||||
.values({ expenseId, packageId })
|
||||
.onConflictDoNothing();
|
||||
|
||||
return { success: true, action: 'linkPackage' };
|
||||
},
|
||||
|
||||
unlinkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const expenseId = fd.get('expenseId')?.toString();
|
||||
const packageId = fd.get('packageId')?.toString();
|
||||
|
||||
if (!expenseId || !packageId) return fail(400, { error: 'Expense and package IDs required' });
|
||||
|
||||
await db
|
||||
.delete(expensePackages)
|
||||
.where(
|
||||
and(eq(expensePackages.expenseId, expenseId), eq(expensePackages.packageId, packageId))
|
||||
);
|
||||
|
||||
return { success: true, action: 'unlinkPackage' };
|
||||
},
|
||||
|
||||
setExpensePaperlessLink: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const expenseId = fd.get('expenseId')?.toString();
|
||||
const url = fd.get('paperlessUrl')?.toString().trim() || null;
|
||||
|
||||
if (!expenseId) return fail(400, { error: 'Expense ID required' });
|
||||
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return fail(400, {
|
||||
action: 'setExpensePaperlessLink',
|
||||
error: 'URL must start with http:// or https://'
|
||||
});
|
||||
}
|
||||
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id, title: expenses.title })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({ paperlessUrl: url, updatedAt: new Date() })
|
||||
.where(eq(expenses.id, expenseId));
|
||||
|
||||
return { success: true, action: 'setExpensePaperlessLink' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import type { PageData } from './$types';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data } = $props();
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
const currency = $derived(data.company.currency);
|
||||
const canApprove = $derived(
|
||||
data.companyRoles.includes('admin') || data.companyRoles.includes('manager')
|
||||
@@ -15,6 +15,12 @@
|
||||
data.companyRoles.includes('manager') ||
|
||||
data.companyRoles.includes('accountant')
|
||||
);
|
||||
|
||||
let showAddForm = $state(false);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const inputCls = '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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -24,8 +30,90 @@
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Expenses</h2>
|
||||
<button type="button" onclick={() => (showAddForm = !showAddForm)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
{showAddForm ? 'Cancel' : '+ New Expense'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.action === 'submitExpense' && form.error}
|
||||
<div class="mb-4 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAddForm}
|
||||
<section class="mb-6 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h3 class="mb-3 font-semibold text-gray-900 dark:text-white">Add Expense</h3>
|
||||
<form method="POST" action="?/submitExpense"
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') { showAddForm = false; formElement.reset(); }
|
||||
}}
|
||||
class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label for="exp-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
||||
<input id="exp-title" name="title" type="text" required class={inputCls} placeholder="e.g. Office supplies" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
|
||||
<input id="exp-amount" name="amount" type="number" step="0.01" min="0.01" required class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
|
||||
<input id="exp-date" name="expenseDate" type="date" required value={todayIso} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-project" class={labelCls}>Project</label>
|
||||
<select id="exp-project" name="projectId" class={inputCls}>
|
||||
<option value="">Company-wide (General)</option>
|
||||
{#each data.projects as proj (proj.id)}
|
||||
<option value={proj.id}>{proj.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-category" class={labelCls}>Category</label>
|
||||
<select id="exp-category" name="categoryId" class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.categories as cat (cat.id)}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-account" class={labelCls}>Account</label>
|
||||
<select id="exp-account" name="accountId" class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.accounts as acct (acct.id)}
|
||||
<option value={acct.id}>{acct.name} ({acct.currency})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="exp-invoice" class={labelCls}>Invoice</label>
|
||||
<select id="exp-invoice" name="invoiceId" class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.invoices as inv (inv.id)}
|
||||
<option value={inv.id}>{inv.invoiceNumber} — {inv.partyName} ({inv.direction})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="exp-desc" class={labelCls}>Description</label>
|
||||
<textarea id="exp-desc" name="description" rows="2" class={inputCls}></textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showAddForm = false)}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 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>
|
||||
<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>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Expense will be submitted as pending. A manager can approve or reject it.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Status filter -->
|
||||
<div class="mb-4 flex gap-2">
|
||||
{#each ['all', 'pending', 'approved', 'rejected'] as status}
|
||||
@@ -47,8 +135,13 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each data.expenses as expense}
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
|
||||
{#each data.expenses as expense (expense.id)}
|
||||
{@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
|
||||
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 transition-colors hover:border-blue-400 dark:hover:border-blue-500">
|
||||
<a href={`/companies/${data.company.id}/expenses/${expense.id}`}
|
||||
class="mb-1 inline-block text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
View details →
|
||||
</a>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{expense.title}</h3>
|
||||
@@ -71,6 +164,57 @@
|
||||
</span>
|
||||
</p>
|
||||
{/if}
|
||||
{#if expense.invoiceId}
|
||||
{@const inv = data.invoices.find((i) => i.id === expense.invoiceId)}
|
||||
{#if inv}
|
||||
<p class="mt-1 text-xs">
|
||||
<a
|
||||
href={`/companies/${data.company.id}/invoices/${inv.id}`}
|
||||
class="rounded-full bg-indigo-100 px-2 py-0.5 font-medium text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/40 dark:text-indigo-300"
|
||||
>
|
||||
Invoice: {inv.invoiceNumber}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
<p class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
{#if expense.invoiceFileUrl}
|
||||
<a
|
||||
href={`/companies/${data.company.id}/expenses/${expense.id}/invoice`}
|
||||
class="rounded-full bg-emerald-100 px-2 py-0.5 font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300"
|
||||
>
|
||||
📄 {expense.invoiceFileName ?? 'Invoice file'}
|
||||
</a>
|
||||
{/if}
|
||||
{#if expense.paperlessUrl}
|
||||
<a
|
||||
href={expense.paperlessUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300"
|
||||
>
|
||||
🗂 Paperless
|
||||
</a>
|
||||
{/if}
|
||||
{#if !expense.invoiceFileUrl && !expense.paperlessUrl}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
Pending invoice
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{#if linkedPkgIds.length > 0}
|
||||
<p class="mt-1 flex flex-wrap gap-1 text-xs">
|
||||
{#each linkedPkgIds as pkgId (pkgId)}
|
||||
{@const pkg = data.packages.find((p) => p.id === pkgId)}
|
||||
{#if pkg}
|
||||
<a href={`/companies/${data.company.id}/packages/${pkg.id}`}
|
||||
class="rounded-full bg-cyan-100 px-2 py-0.5 font-medium text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
📦 {pkg.trackingNumber}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold dark:text-white">{formatCurrency(expense.amount, expense.currency)}</p>
|
||||
@@ -152,6 +296,81 @@
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if canAssignAccount && data.packages.length > 0}
|
||||
{@const linkedPkgIds = data.expensePackageLinks.filter((l) => l.expenseId === expense.id).map((l) => l.packageId)}
|
||||
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
Link packages ({linkedPkgIds.length})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-1">
|
||||
{#each data.packages as pkg (pkg.id)}
|
||||
{@const isLinked = linkedPkgIds.includes(pkg.id)}
|
||||
<form
|
||||
method="POST"
|
||||
action={isLinked ? '?/unlinkPackage' : '?/linkPackage'}
|
||||
use:enhance
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<input type="hidden" name="expenseId" value={expense.id} />
|
||||
<input type="hidden" name="packageId" value={pkg.id} />
|
||||
<button type="submit"
|
||||
class="flex h-4 w-4 items-center justify-center rounded border-2 {isLinked
|
||||
? 'border-cyan-500 bg-cyan-500 text-white'
|
||||
: 'border-gray-300 hover:border-cyan-400 dark:border-gray-600'}">
|
||||
{#if isLinked}✓{/if}
|
||||
</button>
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{pkg.trackingNumber} — {pkg.carrier} ({pkg.direction})
|
||||
</span>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
{#if canAssignAccount}
|
||||
<details class="mt-3 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<summary class="text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{expense.invoiceFileUrl || expense.paperlessUrl ? 'Manage invoice' : '+ Attach invoice'}
|
||||
</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/uploadExpenseInvoice"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-xs"
|
||||
>
|
||||
<input type="hidden" name="expenseId" value={expense.id} />
|
||||
<label class="text-gray-500 dark:text-gray-400" for="inv-file-{expense.id}">File:</label>
|
||||
<input id="inv-file-{expense.id}" name="file" type="file" accept="application/pdf,image/*" required
|
||||
class="text-xs text-gray-700 dark:text-gray-300" />
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700">
|
||||
Upload
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setExpensePaperlessLink"
|
||||
use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-xs"
|
||||
>
|
||||
<input type="hidden" name="expenseId" value={expense.id} />
|
||||
<label class="text-gray-500 dark:text-gray-400" for="pless-{expense.id}">Paperless URL:</label>
|
||||
<input id="pless-{expense.id}" name="paperlessUrl" type="url"
|
||||
value={expense.paperlessUrl ?? ''}
|
||||
placeholder="https://paperless.example.com/documents/123"
|
||||
class="flex-1 rounded border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
<button type="submit"
|
||||
class="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
Save link
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
expenses,
|
||||
projects,
|
||||
users,
|
||||
categories,
|
||||
parties,
|
||||
companyAccounts,
|
||||
invoices,
|
||||
packages,
|
||||
expensePackages
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import {
|
||||
postExpenseTransaction,
|
||||
removeExpenseTransaction
|
||||
} from '$lib/server/accounts/ledger.js';
|
||||
import { saveCompanyFile, isAllowedMime, MAX_BYTES } from '$lib/server/uploads/index.js';
|
||||
import { uploadToPaperless, isPaperlessEnabled } from '$lib/server/paperless/index.js';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'accountant', 'hr'
|
||||
]);
|
||||
await parent();
|
||||
|
||||
const [row] = await 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,
|
||||
reviewedAt: expenses.reviewedAt,
|
||||
createdAt: expenses.createdAt,
|
||||
updatedAt: expenses.updatedAt,
|
||||
projectId: expenses.projectId,
|
||||
projectName: projects.name,
|
||||
partyId: expenses.partyId,
|
||||
partyName: parties.name,
|
||||
categoryId: expenses.categoryId,
|
||||
categoryName: categories.name,
|
||||
accountId: expenses.accountId,
|
||||
accountName: companyAccounts.name,
|
||||
invoiceId: expenses.invoiceId,
|
||||
invoiceFileUrl: expenses.invoiceFileUrl,
|
||||
invoiceFileName: expenses.invoiceFileName,
|
||||
paperlessUrl: expenses.paperlessUrl,
|
||||
voidedAt: expenses.voidedAt,
|
||||
voidReason: expenses.voidReason,
|
||||
submitterName: users.displayName,
|
||||
submitterEmail: users.email
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.innerJoin(users, eq(expenses.submittedBy, users.id))
|
||||
.leftJoin(categories, eq(expenses.categoryId, categories.id))
|
||||
.leftJoin(parties, eq(expenses.partyId, parties.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) error(404, 'Expense not found');
|
||||
|
||||
const projectList = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
|
||||
.orderBy(asc(projects.name));
|
||||
|
||||
const categoryList = await db
|
||||
.select({ id: categories.id, name: categories.name })
|
||||
.from(categories)
|
||||
.where(eq(categories.companyId, params.companyId))
|
||||
.orderBy(asc(categories.name));
|
||||
|
||||
const accountList = await db
|
||||
.select({
|
||||
id: companyAccounts.id,
|
||||
name: companyAccounts.name,
|
||||
currency: companyAccounts.currency
|
||||
})
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.companyId, params.companyId),
|
||||
eq(companyAccounts.isArchived, false),
|
||||
isNull(companyAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(companyAccounts.name);
|
||||
|
||||
const partyList = await db
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||
.orderBy(asc(parties.name));
|
||||
|
||||
const invoiceList = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
direction: invoices.direction
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.companyId, params.companyId),
|
||||
ne(invoices.status, 'voided'),
|
||||
ne(invoices.status, 'cancelled')
|
||||
)
|
||||
)
|
||||
.orderBy(asc(invoices.invoiceNumber));
|
||||
|
||||
const linkedPackages = await db
|
||||
.select({
|
||||
id: packages.id,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
direction: packages.direction,
|
||||
status: packages.status
|
||||
})
|
||||
.from(expensePackages)
|
||||
.innerJoin(packages, eq(expensePackages.packageId, packages.id))
|
||||
.where(eq(expensePackages.expenseId, params.expenseId));
|
||||
|
||||
const availablePackages = await db
|
||||
.select({
|
||||
id: packages.id,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
direction: packages.direction
|
||||
})
|
||||
.from(packages)
|
||||
.where(eq(packages.companyId, params.companyId))
|
||||
.orderBy(packages.createdAt);
|
||||
|
||||
return {
|
||||
expense: row,
|
||||
projects: projectList,
|
||||
categories: categoryList,
|
||||
accounts: accountList,
|
||||
parties: partyList,
|
||||
invoices: invoiceList,
|
||||
linkedPackages,
|
||||
availablePackages
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateExpense: async ({ request, locals, params }) => {
|
||||
const { user, roles } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const canManage = roles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant');
|
||||
if (!canManage) return fail(403, { error: 'Not permitted' });
|
||||
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const amountStr = fd.get('amount')?.toString().trim();
|
||||
const expenseDate = trimOrNull(fd.get('expenseDate'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const projectId = trimOrNull(fd.get('projectId'));
|
||||
const categoryId = trimOrNull(fd.get('categoryId'));
|
||||
const partyId = trimOrNull(fd.get('partyId'));
|
||||
const accountId = trimOrNull(fd.get('accountId'));
|
||||
const invoiceId = trimOrNull(fd.get('invoiceId'));
|
||||
|
||||
if (!title) return fail(400, { action: 'updateExpense', error: 'Title is required' });
|
||||
if (!amountStr || isNaN(Number(amountStr)) || Number(amountStr) <= 0) {
|
||||
return fail(400, { action: 'updateExpense', error: 'Valid positive amount required' });
|
||||
}
|
||||
if (!expenseDate) return fail(400, { action: 'updateExpense', error: 'Date is required' });
|
||||
if (!projectId) return fail(400, { action: 'updateExpense', error: 'Project is required' });
|
||||
|
||||
// Verify current expense belongs to this company
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
title: expenses.title,
|
||||
amount: expenses.amount,
|
||||
status: expenses.status,
|
||||
accountId: expenses.accountId
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(
|
||||
and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId))
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Expense not found');
|
||||
|
||||
// Verify target project belongs to this company
|
||||
const [proj] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, projectId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!proj) return fail(400, { action: 'updateExpense', error: 'Project not in this company' });
|
||||
|
||||
const newAmount = Number(amountStr).toFixed(2);
|
||||
const amountChanged = newAmount !== existing.amount;
|
||||
const accountChanged = (accountId ?? null) !== (existing.accountId ?? null);
|
||||
|
||||
// Resolve currency from the (possibly new) account
|
||||
let resolvedCurrency: string | undefined = undefined;
|
||||
if (accountId) {
|
||||
const [acct] = await db
|
||||
.select({ currency: companyAccounts.currency })
|
||||
.from(companyAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyAccounts.id, accountId),
|
||||
eq(companyAccounts.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (acct) resolvedCurrency = acct.currency;
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
title,
|
||||
description,
|
||||
amount: newAmount,
|
||||
expenseDate,
|
||||
projectId,
|
||||
categoryId,
|
||||
partyId,
|
||||
accountId,
|
||||
invoiceId,
|
||||
...(resolvedCurrency ? { currency: resolvedCurrency } : {}),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
// Re-post ledger entry if approved and amount or account changed
|
||||
if (existing.status === 'approved' && (amountChanged || accountChanged)) {
|
||||
if (accountId) {
|
||||
await postExpenseTransaction(params.expenseId, accountId, user.id, tx);
|
||||
} else {
|
||||
await removeExpenseTransaction(params.expenseId, tx);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'expense_updated',
|
||||
`Expense "${title}" edited (was ${formatCurrency(existing.amount, 'THB')})`,
|
||||
{
|
||||
expenseId: params.expenseId,
|
||||
previousTitle: existing.title,
|
||||
previousAmount: existing.amount,
|
||||
newAmount,
|
||||
amountChanged,
|
||||
accountChanged
|
||||
}
|
||||
);
|
||||
|
||||
return { success: true, action: 'updateExpense' };
|
||||
},
|
||||
|
||||
uploadInvoice: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const file = fd.get('file') as File | null;
|
||||
|
||||
if (!file || !(file instanceof File) || file.size === 0) {
|
||||
return fail(400, { action: 'uploadInvoice', error: 'File is required' });
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
return fail(400, {
|
||||
action: 'uploadInvoice',
|
||||
error: `File too large (max ${Math.round(MAX_BYTES / 1024 / 1024)} MB)`
|
||||
});
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!isAllowedMime(mime)) {
|
||||
return fail(400, { action: 'uploadInvoice', error: `File type not allowed: ${mime}` });
|
||||
}
|
||||
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id, title: expenses.title })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = await saveCompanyFile(params.companyId, file);
|
||||
} catch (err) {
|
||||
console.error('saveCompanyFile failed', err);
|
||||
return fail(500, { action: 'uploadInvoice', error: 'Failed to save file' });
|
||||
}
|
||||
|
||||
if (isPaperlessEnabled()) {
|
||||
await uploadToPaperless(file, exp.title);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({
|
||||
invoiceFileUrl: saved.storedPath,
|
||||
invoiceFileName: file.name,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'expense_invoice_uploaded',
|
||||
`Invoice attached to expense "${exp.title}"`,
|
||||
{ expenseId: params.expenseId, fileName: file.name });
|
||||
|
||||
return { success: true, action: 'uploadInvoice' };
|
||||
},
|
||||
|
||||
setPaperlessLink: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const url = fd.get('paperlessUrl')?.toString().trim() || null;
|
||||
|
||||
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return fail(400, {
|
||||
action: 'setPaperlessLink',
|
||||
error: 'URL must start with http:// or https://'
|
||||
});
|
||||
}
|
||||
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
await db
|
||||
.update(expenses)
|
||||
.set({ paperlessUrl: url, updatedAt: new Date() })
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
return { success: true, action: 'setPaperlessLink' };
|
||||
},
|
||||
|
||||
linkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const packageId = fd.get('packageId')?.toString();
|
||||
if (!packageId) return fail(400, { error: 'Package id required' });
|
||||
|
||||
// Verify expense and package belong to this company
|
||||
const [exp] = await db
|
||||
.select({ id: expenses.id })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!exp) return fail(404, { error: 'Expense not found' });
|
||||
|
||||
const [pkg] = await db
|
||||
.select({ id: packages.id })
|
||||
.from(packages)
|
||||
.where(and(eq(packages.id, packageId), eq(packages.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!pkg) return fail(404, { error: 'Package not found' });
|
||||
|
||||
await db
|
||||
.insert(expensePackages)
|
||||
.values({ expenseId: params.expenseId, packageId })
|
||||
.onConflictDoNothing();
|
||||
|
||||
return { success: true, action: 'linkPackage' };
|
||||
},
|
||||
|
||||
voidExpense: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const reason = fd.get('reason')?.toString().trim();
|
||||
if (!reason) return fail(400, { action: 'voidExpense', error: 'Void reason is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: expenses.id, title: expenses.title, status: expenses.status })
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
if (!existing) return fail(404, { error: 'Expense not found' });
|
||||
if (existing.status === 'voided') return fail(400, { error: 'Expense is already voided' });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(expenses)
|
||||
.set({
|
||||
status: 'voided',
|
||||
voidedAt: new Date(),
|
||||
voidReason: reason,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(expenses.id, params.expenseId));
|
||||
|
||||
// Reverse any ledger post for this expense
|
||||
await removeExpenseTransaction(params.expenseId, tx);
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'expense_voided',
|
||||
`Expense "${existing.title}" voided: ${reason}`,
|
||||
{ expenseId: params.expenseId, reason, previousStatus: existing.status }
|
||||
);
|
||||
|
||||
return { success: true, action: 'voidExpense' };
|
||||
},
|
||||
|
||||
unlinkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const packageId = fd.get('packageId')?.toString();
|
||||
if (!packageId) return fail(400, { error: 'Package id required' });
|
||||
|
||||
await db
|
||||
.delete(expensePackages)
|
||||
.where(
|
||||
and(
|
||||
eq(expensePackages.expenseId, params.expenseId),
|
||||
eq(expensePackages.packageId, packageId)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true, action: 'unlinkPackage' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
|
||||
const canManage = $derived(
|
||||
data.companyRoles.some((r) => r === 'admin' || r === 'manager' || r === 'accountant')
|
||||
);
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
approved: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
rejected: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
|
||||
};
|
||||
|
||||
let showVoidForm = $state(false);
|
||||
const canVoid = $derived(canManage && data.expense.status !== 'voided');
|
||||
|
||||
const inputCls = '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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.expense.title} - Expense</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<a href={`/companies/${data.company.id}/expenses`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Expenses</a>
|
||||
<div class="mt-1 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.expense.title}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.expense.status]}">
|
||||
{data.expense.status}
|
||||
</span>
|
||||
<span>{data.expense.expenseDate}</span>
|
||||
<span>By {data.expense.submitterName ?? data.expense.submitterEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if canManage && !editing}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => (editing = true)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Edit
|
||||
</button>
|
||||
{#if canVoid}
|
||||
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
|
||||
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Void
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.expense.status === 'voided' && data.expense.voidReason}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/20">
|
||||
<span class="text-sm font-medium text-red-700 dark:text-red-300">Voided:</span>
|
||||
<span class="text-sm text-red-600 dark:text-red-400">{data.expense.voidReason}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showVoidForm && canVoid}
|
||||
<form method="POST" action="?/voidExpense"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') showVoidForm = false;
|
||||
}}
|
||||
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
|
||||
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
|
||||
Void this expense? This reverses any ledger entry. Cannot be undone.
|
||||
</p>
|
||||
<label for="void-reason" class={labelCls}>Reason <span class="text-red-500">*</span></label>
|
||||
<textarea id="void-reason" name="reason" rows="2" required
|
||||
placeholder="e.g. Duplicate, wrong supplier, incorrect amount"
|
||||
class={inputCls}></textarea>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showVoidForm = false)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Confirm Void</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if editing && canManage}
|
||||
<form method="POST" action="?/updateExpense"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editing = false;
|
||||
}}
|
||||
class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label for="e-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
||||
<input id="e-title" name="title" type="text" required value={data.expense.title} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-amount" class={labelCls}>Amount <span class="text-red-500">*</span></label>
|
||||
<input id="e-amount" name="amount" type="number" step="0.01" min="0.01" required value={data.expense.amount} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-date" class={labelCls}>Date <span class="text-red-500">*</span></label>
|
||||
<input id="e-date" name="expenseDate" type="date" required value={data.expense.expenseDate} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-project" class={labelCls}>Project <span class="text-red-500">*</span></label>
|
||||
<select id="e-project" name="projectId" required value={data.expense.projectId} class={inputCls}>
|
||||
{#each data.projects as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-category" class={labelCls}>Category</label>
|
||||
<select id="e-category" name="categoryId" value={data.expense.categoryId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.categories as c (c.id)}
|
||||
<option value={c.id}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-party" class={labelCls}>Supplier</label>
|
||||
<select id="e-party" name="partyId" value={data.expense.partyId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.parties as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-account" class={labelCls}>Account</label>
|
||||
<select id="e-account" name="accountId" value={data.expense.accountId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.accounts as a (a.id)}
|
||||
<option value={a.id}>{a.name} ({a.currency})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="e-invoice" class={labelCls}>Linked Invoice</label>
|
||||
<select id="e-invoice" name="invoiceId" value={data.expense.invoiceId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.invoices as inv (inv.id)}
|
||||
<option value={inv.id}>{inv.invoiceNumber} ({inv.direction})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="e-desc" class={labelCls}>Description</label>
|
||||
<textarea id="e-desc" name="description" rows="2" class={inputCls}>{data.expense.description ?? ''}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editing = false)}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200">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">Save changes</button>
|
||||
</div>
|
||||
<p class="md:col-span-2 text-xs text-gray-500 dark:text-gray-400">Edits are audit-logged. If status is approved and amount/account changes, the ledger entry is re-posted.</p>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Amount</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{formatCurrency(data.expense.amount, data.expense.currency)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Project</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">
|
||||
<a href={`/companies/${data.company.id}/projects/${data.expense.projectId}`}
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400">{data.expense.projectName}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Category</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{data.expense.categoryName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Supplier</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{data.expense.partyName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Account</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{data.expense.accountName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Created</p>
|
||||
<p class="text-sm text-gray-900 dark:text-white">{formatDate(data.expense.createdAt)}</p>
|
||||
</div>
|
||||
{#if data.expense.description}
|
||||
<div class="md:col-span-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500">Description</p>
|
||||
<p class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">{data.expense.description}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if data.expense.rejectionReason}
|
||||
<div class="md:col-span-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20">
|
||||
<p class="text-xs font-medium uppercase tracking-wider text-red-500">Rejected</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{data.expense.rejectionReason}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Invoice attachments -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Invoice</h2>
|
||||
<div class="mb-3 flex flex-wrap gap-2 text-sm">
|
||||
{#if data.expense.invoiceFileUrl}
|
||||
<a href={`/companies/${data.company.id}/expenses/${data.expense.id}/invoice`}
|
||||
class="rounded-full bg-emerald-100 px-3 py-1 text-sm font-medium text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
📄 {data.expense.invoiceFileName ?? 'Invoice file'}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.expense.paperlessUrl}
|
||||
<a href={data.expense.paperlessUrl} target="_blank" rel="noopener noreferrer"
|
||||
class="rounded-full bg-purple-100 px-3 py-1 text-sm font-medium text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300">
|
||||
🗂 Paperless
|
||||
</a>
|
||||
{/if}
|
||||
{#if !data.expense.invoiceFileUrl && !data.expense.paperlessUrl}
|
||||
<span class="rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
No invoice attached
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canManage}
|
||||
<div class="space-y-3 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<form method="POST" action="?/uploadInvoice" enctype="multipart/form-data" use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label class={labelCls + ' mb-0'} for="inv-file">Upload file:</label>
|
||||
<input id="inv-file" name="file" type="file" accept="application/pdf,image/*" required
|
||||
class="text-xs text-gray-700 dark:text-gray-300" />
|
||||
<button type="submit"
|
||||
class="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700">
|
||||
Upload
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="?/setPaperlessLink" use:enhance
|
||||
class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<label class={labelCls + ' mb-0'} for="pless-url">Paperless URL:</label>
|
||||
<input id="pless-url" name="paperlessUrl" type="url"
|
||||
value={data.expense.paperlessUrl ?? ''}
|
||||
placeholder="https://paperless.example.com/documents/123"
|
||||
class="flex-1 min-w-0 rounded-md border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
||||
<button type="submit"
|
||||
class="rounded-md border border-gray-300 bg-white px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Packages -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
|
||||
{#if canManage}
|
||||
<a href={`/companies/${data.company.id}/packages/new`}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700">
|
||||
+ New Package
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if data.linkedPackages.length > 0}
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
{#each data.linkedPackages as pkg (pkg.id)}
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-sm font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
<a href={`/companies/${data.company.id}/packages/${pkg.id}`} class="hover:underline">
|
||||
📦 {pkg.trackingNumber} — {pkg.carrier}
|
||||
</a>
|
||||
{#if canManage}
|
||||
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="packageId" value={pkg.id} />
|
||||
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
|
||||
</form>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">No packages linked yet.</p>
|
||||
{/if}
|
||||
|
||||
{#if canManage}
|
||||
{@const selectable = data.availablePackages.filter((p) => !data.linkedPackages.find((l) => l.id === p.id))}
|
||||
{#if selectable.length > 0}
|
||||
<form method="POST" action="?/linkPackage" use:enhance={() => async ({ update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
formElement.reset();
|
||||
}} class="flex items-center gap-2 border-t border-gray-100 pt-3 text-sm dark:border-gray-700">
|
||||
<select name="packageId" required class={inputCls + ' flex-1'}>
|
||||
<option value="" disabled selected>Select an existing package</option>
|
||||
{#each selectable as pkg (pkg.id)}
|
||||
<option value={pkg.id}>{pkg.trackingNumber} — {pkg.carrier} ({pkg.direction})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Link
|
||||
</button>
|
||||
</form>
|
||||
{:else if data.availablePackages.length === 0}
|
||||
<p class="border-t border-gray-100 pt-3 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
No packages exist yet. Use "+ New Package" above to create one.
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { expenses, projects } from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { readCompanyFile } from '$lib/server/uploads/index.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'accountant'
|
||||
]);
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
invoiceFileUrl: expenses.invoiceFileUrl,
|
||||
invoiceFileName: expenses.invoiceFileName
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.where(and(eq(expenses.id, params.expenseId), eq(projects.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row || !row.invoiceFileUrl) error(404, 'Invoice file not found');
|
||||
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = await readCompanyFile(row.invoiceFileUrl);
|
||||
} catch (err) {
|
||||
console.error('readCompanyFile failed', err);
|
||||
error(404, 'File missing on disk');
|
||||
}
|
||||
|
||||
const safeName = (row.invoiceFileName ?? 'invoice').replace(/[\r\n"\\]/g, '_');
|
||||
|
||||
return new Response(new Blob([buf as BlobPart]), {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${safeName}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-Content-Type-Options': 'nosniff'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -34,6 +34,8 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
total: invoices.total,
|
||||
currency: invoices.currency,
|
||||
notes: invoices.notes,
|
||||
voidedAt: invoices.voidedAt,
|
||||
voidReason: invoices.voidReason,
|
||||
expenseId: invoices.expenseId,
|
||||
paymentAccountId: invoices.paymentAccountId,
|
||||
createdAt: invoices.createdAt,
|
||||
@@ -173,6 +175,52 @@ export const actions: Actions = {
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
voidInvoice: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const reason = fd.get('reason')?.toString().trim();
|
||||
|
||||
if (!reason) return fail(400, { error: 'Void reason is required' });
|
||||
|
||||
const [inv] = await db
|
||||
.select({ invoiceNumber: invoices.invoiceNumber, status: invoices.status })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)))
|
||||
.limit(1);
|
||||
|
||||
if (!inv) return fail(404, { error: 'Invoice not found' });
|
||||
if (inv.status === 'voided') return fail(400, { error: 'Invoice is already voided' });
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: 'voided',
|
||||
voidedAt: new Date(),
|
||||
voidReason: reason,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(and(eq(invoices.id, params.invoiceId), eq(invoices.companyId, params.companyId)));
|
||||
|
||||
if (inv.status === 'paid') {
|
||||
await removeInvoicePaymentTransaction(params.invoiceId, tx);
|
||||
}
|
||||
});
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'invoice_voided',
|
||||
`Invoice ${inv.invoiceNumber} voided: ${reason}`,
|
||||
{ invoiceId: params.invoiceId, reason }
|
||||
);
|
||||
|
||||
return { success: true, voided: true };
|
||||
},
|
||||
|
||||
linkExpense: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const formData = await request.formData();
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
sent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
paid: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
overdue: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500'
|
||||
cancelled: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500',
|
||||
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
|
||||
};
|
||||
|
||||
const nextStatuses: Record<string, string[]> = {
|
||||
@@ -24,9 +25,17 @@
|
||||
sent: ['paid', 'overdue', 'cancelled'],
|
||||
overdue: ['paid', 'cancelled'],
|
||||
paid: [],
|
||||
cancelled: []
|
||||
cancelled: [],
|
||||
voided: []
|
||||
};
|
||||
|
||||
const canVoid = $derived(
|
||||
data.companyRoles.some((r: string) => r === 'admin' || r === 'accountant') &&
|
||||
inv.status !== 'voided' &&
|
||||
inv.status !== 'cancelled'
|
||||
);
|
||||
let showVoidForm = $state(false);
|
||||
|
||||
let showLinkExpense = $state(false);
|
||||
let selectedProject = $state('');
|
||||
</script>
|
||||
@@ -241,8 +250,56 @@
|
||||
class="rounded-md border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Download PDF
|
||||
</a>
|
||||
|
||||
{#if canVoid}
|
||||
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
|
||||
class="rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Void Invoice
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showVoidForm}
|
||||
<form method="POST" action="?/voidInvoice"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
showVoidForm = false;
|
||||
}}
|
||||
class="mt-4 rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
|
||||
<p class="mb-2 text-sm font-medium text-red-700 dark:text-red-300">
|
||||
Void invoice {inv.invoiceNumber}? This will reverse any ledger entry and cannot be undone.
|
||||
</p>
|
||||
<label for="void-reason" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Reason <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="void-reason"
|
||||
name="reason"
|
||||
rows="2"
|
||||
required
|
||||
placeholder="e.g. Duplicate invoice, incorrect amount, wrong vendor"
|
||||
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"
|
||||
></textarea>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showVoidForm = false)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">
|
||||
Confirm Void
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if inv.status === 'voided' && inv.voidReason}
|
||||
<div class="mt-4 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-700 dark:bg-red-900/20">
|
||||
<span class="font-medium text-red-700 dark:text-red-300">Voided:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{inv.voidReason}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Link expense (incoming only, not already linked) -->
|
||||
{#if inv.direction === 'incoming' && !inv.expenseId}
|
||||
<div class="mt-4 border-t border-gray-100 dark:border-gray-700 pt-4">
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
procedureTemplates,
|
||||
procedureSteps,
|
||||
procedureInstances
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { and, asc, eq, isNull, sql, ne } from 'drizzle-orm';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'viewer', 'hr', 'accountant'
|
||||
]);
|
||||
await parent();
|
||||
|
||||
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
|
||||
|
||||
const whereClause = canManage
|
||||
? and(eq(procedureTemplates.companyId, params.companyId), isNull(procedureTemplates.deletedAt))
|
||||
: and(
|
||||
eq(procedureTemplates.companyId, params.companyId),
|
||||
isNull(procedureTemplates.deletedAt),
|
||||
eq(procedureTemplates.isPublished, true)
|
||||
);
|
||||
|
||||
const templates = await db
|
||||
.select({
|
||||
id: procedureTemplates.id,
|
||||
title: procedureTemplates.title,
|
||||
description: procedureTemplates.description,
|
||||
category: procedureTemplates.category,
|
||||
isPublished: procedureTemplates.isPublished,
|
||||
createdAt: procedureTemplates.createdAt,
|
||||
stepCount: sql<number>`(select count(*)::int from procedure_steps where template_id = ${procedureTemplates.id})`,
|
||||
instanceCount: sql<number>`(select count(*)::int from procedure_instances where template_id = ${procedureTemplates.id} and status != 'cancelled')`
|
||||
})
|
||||
.from(procedureTemplates)
|
||||
.where(whereClause)
|
||||
.orderBy(asc(procedureTemplates.title));
|
||||
|
||||
return { templates, canManage };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createTemplate: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const category = trimOrNull(fd.get('category'));
|
||||
|
||||
if (!title) return fail(400, { action: 'createTemplate', error: 'Title is required' });
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(procedureTemplates)
|
||||
.values({
|
||||
companyId: params.companyId,
|
||||
title,
|
||||
description,
|
||||
category,
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning({ id: procedureTemplates.id });
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'procedure_template_created',
|
||||
`Procedure "${title}" created`,
|
||||
{ templateId: inserted.id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'createTemplate' };
|
||||
},
|
||||
|
||||
updateTemplate: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const category = trimOrNull(fd.get('category'));
|
||||
|
||||
if (!id) return fail(400, { action: 'updateTemplate', error: 'Template id required' });
|
||||
if (!title) return fail(400, { action: 'updateTemplate', error: 'Title is required' });
|
||||
|
||||
const result = await db
|
||||
.update(procedureTemplates)
|
||||
.set({ title, description, category, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureTemplates.id, id),
|
||||
eq(procedureTemplates.companyId, params.companyId),
|
||||
isNull(procedureTemplates.deletedAt)
|
||||
)
|
||||
)
|
||||
.returning({ id: procedureTemplates.id });
|
||||
|
||||
if (result.length === 0) error(404, 'Template not found');
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'procedure_template_updated',
|
||||
`Procedure "${title}" updated`, { templateId: id });
|
||||
|
||||
return { success: true, action: 'updateTemplate' };
|
||||
},
|
||||
|
||||
deleteTemplate: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'deleteTemplate', error: 'Template id required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: procedureTemplates.id, title: procedureTemplates.title })
|
||||
.from(procedureTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(procedureTemplates.id, id),
|
||||
eq(procedureTemplates.companyId, params.companyId),
|
||||
isNull(procedureTemplates.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Template not found');
|
||||
|
||||
// Check for active instances (RESTRICT FK would block, but give a nice error)
|
||||
const [activeCount] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(procedureInstances)
|
||||
.where(
|
||||
and(eq(procedureInstances.templateId, id), ne(procedureInstances.status, 'cancelled'))
|
||||
);
|
||||
if (activeCount && activeCount.count > 0) {
|
||||
return fail(400, {
|
||||
action: 'deleteTemplate',
|
||||
error: `Cannot delete — ${activeCount.count} active instance(s) exist`
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(procedureTemplates)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(procedureTemplates.id, id));
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'procedure_template_deleted',
|
||||
`Procedure "${existing.title}" deleted`, { templateId: id });
|
||||
|
||||
return { success: true, action: 'deleteTemplate' };
|
||||
},
|
||||
|
||||
publishTemplate: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'publishTemplate', error: 'Template id required' });
|
||||
|
||||
await db
|
||||
.update(procedureTemplates)
|
||||
.set({ isPublished: true, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureTemplates.id, id),
|
||||
eq(procedureTemplates.companyId, params.companyId),
|
||||
isNull(procedureTemplates.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true, action: 'publishTemplate' };
|
||||
},
|
||||
|
||||
unpublishTemplate: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'unpublishTemplate', error: 'Template id required' });
|
||||
|
||||
await db
|
||||
.update(procedureTemplates)
|
||||
.set({ isPublished: false, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureTemplates.id, id),
|
||||
eq(procedureTemplates.companyId, params.companyId),
|
||||
isNull(procedureTemplates.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true, action: 'unpublishTemplate' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let deletingId = $state<string | null>(null);
|
||||
|
||||
const inputCls = '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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Procedures - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Procedures</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Reusable checklists for standard processes. Start an instance to track progress on a specific case.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if data.canManage}
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">New Procedure</h2>
|
||||
<button type="button" onclick={() => { showAddForm = !showAddForm; editingId = null; }}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
{showAddForm ? 'Cancel' : '+ New'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showAddForm}
|
||||
<form method="POST" action="?/createTemplate"
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') { showAddForm = false; formElement.reset(); }
|
||||
}}
|
||||
class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label for="new-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
||||
<input id="new-title" name="title" type="text" required class={inputCls} placeholder="e.g. Purchase Order Process" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-category" class={labelCls}>Category</label>
|
||||
<input id="new-category" name="category" type="text" class={inputCls} placeholder="e.g. Finance, HR, Operations" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="new-desc" class={labelCls}>Description</label>
|
||||
<textarea id="new-desc" name="description" rows="2" class={inputCls}></textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<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>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data.templates.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No procedures yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.templates as tmpl (tmpl.id)}
|
||||
<div class="group relative flex flex-col gap-2 rounded-lg border border-gray-200 bg-white p-4 transition-colors hover:border-blue-400 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500">
|
||||
<a href={`/companies/${data.company.id}/procedures/${tmpl.id}`}
|
||||
class="absolute inset-0 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
<span class="sr-only">Open {tmpl.title}</span>
|
||||
</a>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400">{tmpl.title}</h3>
|
||||
<span class="flex-shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {tmpl.isPublished
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}">
|
||||
{tmpl.isPublished ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
{#if tmpl.category}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{tmpl.category}</span>
|
||||
{/if}
|
||||
{#if tmpl.description}
|
||||
<p class="line-clamp-2 text-xs text-gray-600 dark:text-gray-300">{tmpl.description}</p>
|
||||
{/if}
|
||||
<div class="mt-auto flex gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{tmpl.stepCount} {tmpl.stepCount === 1 ? 'step' : 'steps'}</span>
|
||||
<span>{tmpl.instanceCount} {tmpl.instanceCount === 1 ? 'instance' : 'instances'}</span>
|
||||
</div>
|
||||
{#if data.canManage}
|
||||
<div class="relative z-10 flex justify-end gap-2 border-t border-gray-100 pt-2 dark:border-gray-700">
|
||||
{#if tmpl.isPublished}
|
||||
<form method="POST" action="?/unpublishTemplate" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="id" value={tmpl.id} />
|
||||
<button type="submit" class="text-xs font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400">Unpublish</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/publishTemplate" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="id" value={tmpl.id} />
|
||||
<button type="submit" class="text-xs font-medium text-green-600 hover:text-green-700 dark:text-green-400">Publish</button>
|
||||
</form>
|
||||
{/if}
|
||||
<button type="button" onclick={() => (deletingId = deletingId === tmpl.id ? null : tmpl.id)}
|
||||
class="text-xs font-medium text-red-600 hover:text-red-700 dark:text-red-400">Delete</button>
|
||||
</div>
|
||||
{#if deletingId === tmpl.id}
|
||||
<form method="POST" action="?/deleteTemplate"
|
||||
use:enhance={() => async ({ update }) => { await update({ reset: false }); deletingId = null; }}
|
||||
class="relative z-10 mt-1 rounded-md bg-red-50 p-2 text-xs dark:bg-red-900/30">
|
||||
<input type="hidden" name="id" value={tmpl.id} />
|
||||
<p class="mb-2 text-red-700 dark:text-red-300">Delete "{tmpl.title}"?</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (deletingId = null)}
|
||||
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,230 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
procedureTemplates,
|
||||
procedureSteps,
|
||||
procedureInstances,
|
||||
procedureInstanceSteps,
|
||||
users
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'viewer', 'hr', 'accountant'
|
||||
]);
|
||||
await parent();
|
||||
|
||||
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
|
||||
|
||||
const [template] = await db
|
||||
.select()
|
||||
.from(procedureTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(procedureTemplates.id, params.templateId),
|
||||
eq(procedureTemplates.companyId, params.companyId),
|
||||
isNull(procedureTemplates.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!template) error(404, 'Procedure not found');
|
||||
if (!canManage && !template.isPublished) error(404, 'Procedure not found');
|
||||
|
||||
const steps = await db
|
||||
.select()
|
||||
.from(procedureSteps)
|
||||
.where(eq(procedureSteps.templateId, params.templateId))
|
||||
.orderBy(asc(procedureSteps.stepNumber));
|
||||
|
||||
const instances = await db
|
||||
.select({
|
||||
id: procedureInstances.id,
|
||||
title: procedureInstances.title,
|
||||
status: procedureInstances.status,
|
||||
startedByName: users.displayName,
|
||||
createdAt: procedureInstances.createdAt,
|
||||
completedAt: procedureInstances.completedAt
|
||||
})
|
||||
.from(procedureInstances)
|
||||
.leftJoin(users, eq(procedureInstances.startedBy, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstances.templateId, params.templateId),
|
||||
eq(procedureInstances.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(procedureInstances.createdAt));
|
||||
|
||||
return { template, steps, instances, canManage };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addStep: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const assigneeRole = trimOrNull(fd.get('assigneeRole'));
|
||||
const estimatedMinutes = fd.get('estimatedMinutes')
|
||||
? parseInt(fd.get('estimatedMinutes')!.toString())
|
||||
: null;
|
||||
|
||||
if (!title) return fail(400, { action: 'addStep', error: 'Step title is required' });
|
||||
|
||||
const [maxRow] = await db
|
||||
.select({ max: sql<number>`coalesce(max(${procedureSteps.stepNumber}), 0)::int` })
|
||||
.from(procedureSteps)
|
||||
.where(eq(procedureSteps.templateId, params.templateId));
|
||||
|
||||
await db.insert(procedureSteps).values({
|
||||
templateId: params.templateId,
|
||||
stepNumber: (maxRow?.max ?? 0) + 1,
|
||||
title,
|
||||
description,
|
||||
assigneeRole,
|
||||
estimatedMinutes: estimatedMinutes && !isNaN(estimatedMinutes) ? estimatedMinutes : null
|
||||
});
|
||||
|
||||
return { success: true, action: 'addStep' };
|
||||
},
|
||||
|
||||
updateStep: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const stepId = trimOrNull(fd.get('stepId'));
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const assigneeRole = trimOrNull(fd.get('assigneeRole'));
|
||||
const estimatedMinutes = fd.get('estimatedMinutes')
|
||||
? parseInt(fd.get('estimatedMinutes')!.toString())
|
||||
: null;
|
||||
|
||||
if (!stepId) return fail(400, { action: 'updateStep', error: 'Step id required' });
|
||||
if (!title) return fail(400, { action: 'updateStep', error: 'Step title is required' });
|
||||
|
||||
await db
|
||||
.update(procedureSteps)
|
||||
.set({
|
||||
title,
|
||||
description,
|
||||
assigneeRole,
|
||||
estimatedMinutes: estimatedMinutes && !isNaN(estimatedMinutes) ? estimatedMinutes : null
|
||||
})
|
||||
.where(
|
||||
and(eq(procedureSteps.id, stepId), eq(procedureSteps.templateId, params.templateId))
|
||||
);
|
||||
|
||||
return { success: true, action: 'updateStep' };
|
||||
},
|
||||
|
||||
removeStep: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const stepId = trimOrNull(fd.get('stepId'));
|
||||
if (!stepId) return fail(400, { action: 'removeStep', error: 'Step id required' });
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(procedureSteps)
|
||||
.where(
|
||||
and(eq(procedureSteps.id, stepId), eq(procedureSteps.templateId, params.templateId))
|
||||
);
|
||||
} catch {
|
||||
return fail(400, {
|
||||
action: 'removeStep',
|
||||
error: 'Cannot remove — step is referenced by active instances'
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, action: 'removeStep' };
|
||||
},
|
||||
|
||||
reorderSteps: async ({ request, locals, params }) => {
|
||||
await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
const fd = await request.formData();
|
||||
const raw = fd.get('steps')?.toString();
|
||||
if (!raw) return fail(400, { action: 'reorderSteps', error: 'Steps data required' });
|
||||
|
||||
let parsed: Array<{ id: string; stepNumber: number }>;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return fail(400, { action: 'reorderSteps', error: 'Invalid JSON' });
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const { id, stepNumber } of parsed) {
|
||||
await tx
|
||||
.update(procedureSteps)
|
||||
.set({ stepNumber })
|
||||
.where(
|
||||
and(eq(procedureSteps.id, id), eq(procedureSteps.templateId, params.templateId))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, action: 'reorderSteps' };
|
||||
},
|
||||
|
||||
startInstance: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'hr', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
if (!title) return fail(400, { action: 'startInstance', error: 'Instance title is required' });
|
||||
|
||||
const steps = await db
|
||||
.select()
|
||||
.from(procedureSteps)
|
||||
.where(eq(procedureSteps.templateId, params.templateId))
|
||||
.orderBy(asc(procedureSteps.stepNumber));
|
||||
|
||||
if (steps.length === 0) {
|
||||
return fail(400, { action: 'startInstance', error: 'Template has no steps' });
|
||||
}
|
||||
|
||||
const [instance] = await db
|
||||
.insert(procedureInstances)
|
||||
.values({
|
||||
templateId: params.templateId,
|
||||
companyId: params.companyId,
|
||||
title,
|
||||
startedBy: user.id
|
||||
})
|
||||
.returning({ id: procedureInstances.id });
|
||||
|
||||
await db.insert(procedureInstanceSteps).values(
|
||||
steps.map((s) => ({
|
||||
instanceId: instance.id,
|
||||
stepId: s.id,
|
||||
stepNumber: s.stepNumber,
|
||||
title: s.title,
|
||||
description: s.description
|
||||
}))
|
||||
);
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'procedure_instance_started',
|
||||
`Started "${title}" (from procedure template)`,
|
||||
{ instanceId: instance.id, templateId: params.templateId }
|
||||
);
|
||||
|
||||
redirect(
|
||||
303,
|
||||
`/companies/${params.companyId}/procedures/${params.templateId}/instances/${instance.id}`
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let showAddStep = $state(false);
|
||||
let editingStepId = $state<string | null>(null);
|
||||
let showStartInstance = $state(false);
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
|
||||
};
|
||||
|
||||
const inputCls = '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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.template.title} - Procedures</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href={`/companies/${data.company.id}/procedures`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Procedures</a>
|
||||
</div>
|
||||
<h1 class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{data.template.title}</h1>
|
||||
{#if data.template.description}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{data.template.description}</p>
|
||||
{/if}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {data.template.isPublished
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}">
|
||||
{data.template.isPublished ? 'Published' : 'Draft'}
|
||||
</span>
|
||||
{#if data.template.category}
|
||||
<span class="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">{data.template.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={() => { showStartInstance = !showStartInstance; }}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Start Instance
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showStartInstance}
|
||||
<form method="POST" action="?/startInstance"
|
||||
use:enhance
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/20">
|
||||
<label for="instance-title" class={labelCls}>Instance Title <span class="text-red-500">*</span></label>
|
||||
<input id="instance-title" name="title" type="text" required value={data.template.title} class={inputCls} />
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Customize for this specific case, e.g. "Onboard ABC Corp"</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showStartInstance = false)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Start</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Steps -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Steps ({data.steps.length})
|
||||
</h2>
|
||||
|
||||
{#if data.steps.length === 0}
|
||||
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No steps yet. Add the first step below.</p>
|
||||
{:else}
|
||||
<ol class="space-y-3">
|
||||
{#each data.steps as step, i (step.id)}
|
||||
<li class="flex items-start gap-3 rounded-md border border-gray-100 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-700/30">
|
||||
<span class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-xs font-bold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
|
||||
{step.stepNumber}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if editingStepId === step.id && data.canManage}
|
||||
<form method="POST" action="?/updateStep"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editingStepId = null;
|
||||
}}
|
||||
class="space-y-2">
|
||||
<input type="hidden" name="stepId" value={step.id} />
|
||||
<input name="title" type="text" required value={step.title} class={inputCls} />
|
||||
<textarea name="description" rows="2" class={inputCls}>{step.description ?? ''}</textarea>
|
||||
<div class="flex gap-2">
|
||||
<input name="assigneeRole" type="text" value={step.assigneeRole ?? ''} placeholder="Role (e.g. manager)" class={inputCls} />
|
||||
<input name="estimatedMinutes" type="number" min="0" value={step.estimatedMinutes ?? ''} placeholder="Est. min" class={inputCls} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editingStepId = null)} class="text-xs text-gray-500">Cancel</button>
|
||||
<button type="submit" class="text-xs font-medium text-blue-600">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{step.title}</div>
|
||||
{#if step.description}
|
||||
<p class="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{step.description}</p>
|
||||
{/if}
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if step.assigneeRole}
|
||||
<span class="rounded bg-gray-200 px-1.5 py-0.5 dark:bg-gray-600 dark:text-gray-300">{step.assigneeRole}</span>
|
||||
{/if}
|
||||
{#if step.estimatedMinutes}
|
||||
<span>~{step.estimatedMinutes} min</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.canManage}
|
||||
<div class="mt-2 flex gap-2 text-xs">
|
||||
<button type="button" onclick={() => { editingStepId = step.id; }} class="font-medium text-blue-600 dark:text-blue-400">Edit</button>
|
||||
<form method="POST" action="?/removeStep" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="stepId" value={step.id} />
|
||||
<button type="submit" class="font-medium text-red-600 dark:text-red-400">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
{#if data.canManage}
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
{#if showAddStep}
|
||||
<form method="POST" action="?/addStep"
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') formElement.reset();
|
||||
}}
|
||||
class="space-y-2">
|
||||
<input name="title" type="text" required placeholder="Step title" class={inputCls} />
|
||||
<textarea name="description" rows="2" placeholder="Description (optional)" class={inputCls}></textarea>
|
||||
<div class="flex gap-2">
|
||||
<input name="assigneeRole" type="text" placeholder="Role (optional)" class={inputCls} />
|
||||
<input name="estimatedMinutes" type="number" min="0" placeholder="Est. minutes" class={inputCls} />
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showAddStep = false)} class="text-xs text-gray-500">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">Add Step</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button type="button" onclick={() => (showAddStep = true)}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">+ Add Step</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Instances -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||
Instances ({data.instances.length})
|
||||
</h2>
|
||||
{#if data.instances.length === 0}
|
||||
<p class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">No instances started yet.</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each data.instances as inst (inst.id)}
|
||||
<a href={`/companies/${data.company.id}/procedures/${data.template.id}/instances/${inst.id}`}
|
||||
class="flex items-center justify-between rounded-md border border-gray-100 bg-gray-50 p-3 transition-colors hover:border-blue-300 dark:border-gray-700 dark:bg-gray-700/30 dark:hover:border-blue-500">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">{inst.title}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Started by {inst.startedByName ?? 'Unknown'} · {formatDate(inst.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[inst.status] ?? STATUS_BADGE.in_progress}">
|
||||
{inst.status.replace('_', ' ')}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
procedureInstances,
|
||||
procedureInstanceSteps,
|
||||
procedureTemplates,
|
||||
users
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRole, requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
const { roles } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'viewer', 'hr', 'accountant'
|
||||
]);
|
||||
await parent();
|
||||
|
||||
const canManage = roles.some((r) => r === 'admin' || r === 'manager');
|
||||
|
||||
const [instance] = await db
|
||||
.select({
|
||||
id: procedureInstances.id,
|
||||
templateId: procedureInstances.templateId,
|
||||
title: procedureInstances.title,
|
||||
status: procedureInstances.status,
|
||||
startedByName: users.displayName,
|
||||
notes: procedureInstances.notes,
|
||||
completedAt: procedureInstances.completedAt,
|
||||
cancelledAt: procedureInstances.cancelledAt,
|
||||
createdAt: procedureInstances.createdAt
|
||||
})
|
||||
.from(procedureInstances)
|
||||
.leftJoin(users, eq(procedureInstances.startedBy, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstances.id, params.instanceId),
|
||||
eq(procedureInstances.companyId, params.companyId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!instance) error(404, 'Instance not found');
|
||||
|
||||
const steps = await db
|
||||
.select({
|
||||
id: procedureInstanceSteps.id,
|
||||
stepNumber: procedureInstanceSteps.stepNumber,
|
||||
title: procedureInstanceSteps.title,
|
||||
description: procedureInstanceSteps.description,
|
||||
isCompleted: procedureInstanceSteps.isCompleted,
|
||||
completedByName: users.displayName,
|
||||
completedAt: procedureInstanceSteps.completedAt,
|
||||
notes: procedureInstanceSteps.notes
|
||||
})
|
||||
.from(procedureInstanceSteps)
|
||||
.leftJoin(users, eq(procedureInstanceSteps.completedBy, users.id))
|
||||
.where(eq(procedureInstanceSteps.instanceId, params.instanceId))
|
||||
.orderBy(asc(procedureInstanceSteps.stepNumber));
|
||||
|
||||
return { instance, instanceSteps: steps, canManage };
|
||||
};
|
||||
|
||||
async function checkAutoComplete(instanceId: string, companyId: string, userId: string) {
|
||||
const [counts] = await db
|
||||
.select({
|
||||
total: sql<number>`count(*)::int`,
|
||||
done: sql<number>`count(*) filter (where ${procedureInstanceSteps.isCompleted})::int`
|
||||
})
|
||||
.from(procedureInstanceSteps)
|
||||
.where(eq(procedureInstanceSteps.instanceId, instanceId));
|
||||
|
||||
if (counts && counts.total > 0 && counts.done === counts.total) {
|
||||
await db
|
||||
.update(procedureInstances)
|
||||
.set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(procedureInstances.id, instanceId));
|
||||
|
||||
await logCompanyEvent(companyId, userId, 'procedure_instance_completed',
|
||||
'All steps completed — instance auto-completed',
|
||||
{ instanceId });
|
||||
}
|
||||
}
|
||||
|
||||
export const actions: Actions = {
|
||||
completeStep: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'hr', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const stepId = trimOrNull(fd.get('stepId'));
|
||||
if (!stepId) return fail(400, { action: 'completeStep', error: 'Step id required' });
|
||||
|
||||
await db
|
||||
.update(procedureInstanceSteps)
|
||||
.set({
|
||||
isCompleted: true,
|
||||
completedBy: user.id,
|
||||
completedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstanceSteps.id, stepId),
|
||||
eq(procedureInstanceSteps.instanceId, params.instanceId)
|
||||
)
|
||||
);
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'procedure_step_completed',
|
||||
'Procedure step completed', { instanceId: params.instanceId, stepId });
|
||||
|
||||
await checkAutoComplete(params.instanceId, params.companyId, user.id);
|
||||
|
||||
return { success: true, action: 'completeStep' };
|
||||
},
|
||||
|
||||
uncompleteStep: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'hr', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const stepId = trimOrNull(fd.get('stepId'));
|
||||
if (!stepId) return fail(400, { action: 'uncompleteStep', error: 'Step id required' });
|
||||
|
||||
await db
|
||||
.update(procedureInstanceSteps)
|
||||
.set({ isCompleted: false, completedBy: null, completedAt: null })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstanceSteps.id, stepId),
|
||||
eq(procedureInstanceSteps.instanceId, params.instanceId)
|
||||
)
|
||||
);
|
||||
|
||||
// If instance was completed, revert to in_progress
|
||||
const [inst] = await db
|
||||
.select({ status: procedureInstances.status })
|
||||
.from(procedureInstances)
|
||||
.where(eq(procedureInstances.id, params.instanceId))
|
||||
.limit(1);
|
||||
|
||||
if (inst?.status === 'completed') {
|
||||
await db
|
||||
.update(procedureInstances)
|
||||
.set({ status: 'in_progress', completedAt: null, updatedAt: new Date() })
|
||||
.where(eq(procedureInstances.id, params.instanceId));
|
||||
}
|
||||
|
||||
return { success: true, action: 'uncompleteStep' };
|
||||
},
|
||||
|
||||
addStepNote: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'hr', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const stepId = trimOrNull(fd.get('stepId'));
|
||||
const notes = trimOrNull(fd.get('notes'));
|
||||
if (!stepId) return fail(400, { action: 'addStepNote', error: 'Step id required' });
|
||||
|
||||
await db
|
||||
.update(procedureInstanceSteps)
|
||||
.set({ notes })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstanceSteps.id, stepId),
|
||||
eq(procedureInstanceSteps.instanceId, params.instanceId)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true, action: 'addStepNote' };
|
||||
},
|
||||
|
||||
completeInstance: async ({ locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'user', 'hr', 'accountant'
|
||||
]);
|
||||
|
||||
await db
|
||||
.update(procedureInstances)
|
||||
.set({ status: 'completed', completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstances.id, params.instanceId),
|
||||
eq(procedureInstances.companyId, params.companyId)
|
||||
)
|
||||
);
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'procedure_instance_completed',
|
||||
'Procedure instance manually completed',
|
||||
{ instanceId: params.instanceId });
|
||||
|
||||
return { success: true, action: 'completeInstance' };
|
||||
},
|
||||
|
||||
cancelInstance: async ({ locals, params }) => {
|
||||
const { user } = await requireCompanyRole(locals, params.companyId, 'manager');
|
||||
|
||||
await db
|
||||
.update(procedureInstances)
|
||||
.set({ status: 'cancelled', cancelledAt: new Date(), updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(procedureInstances.id, params.instanceId),
|
||||
eq(procedureInstances.companyId, params.companyId)
|
||||
)
|
||||
);
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'procedure_instance_cancelled',
|
||||
'Procedure instance cancelled',
|
||||
{ instanceId: params.instanceId });
|
||||
|
||||
return { success: true, action: 'cancelInstance' };
|
||||
}
|
||||
};
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatDate } from '$lib/utils/date.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
completed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
cancelled: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'
|
||||
};
|
||||
|
||||
const completedCount = $derived(data.instanceSteps.filter((s) => s.isCompleted).length);
|
||||
const totalSteps = $derived(data.instanceSteps.length);
|
||||
const progressPct = $derived(totalSteps > 0 ? (completedCount / totalSteps) * 100 : 0);
|
||||
const isLive = $derived(data.instance.status === 'in_progress');
|
||||
|
||||
let editingNoteId = $state<string | null>(null);
|
||||
|
||||
const inputCls = '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';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.instance.title} - Procedure Instance</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<a href={`/companies/${data.company.id}/procedures/${data.instance.templateId}`}
|
||||
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Back to template</a>
|
||||
<h1 class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{data.instance.title}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.instance.status]}">
|
||||
{data.instance.status.replace('_', ' ')}
|
||||
</span>
|
||||
<span>Started by {data.instance.startedByName ?? 'Unknown'}</span>
|
||||
<span>{formatDate(data.instance.createdAt)}</span>
|
||||
{#if data.instance.completedAt}
|
||||
<span>Completed {formatDate(data.instance.completedAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Progress</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{completedCount} of {totalSteps} steps</span>
|
||||
</div>
|
||||
<div class="mt-2 h-2.5 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full rounded-full transition-all {progressPct === 100 ? 'bg-green-500' : 'bg-blue-500'}"
|
||||
style="width: {progressPct}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Steps checklist -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<ol class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{#each data.instanceSteps as step (step.id)}
|
||||
<li class="flex items-start gap-3 p-4 {step.isCompleted ? 'bg-green-50/50 dark:bg-green-900/10' : ''}">
|
||||
<div class="pt-0.5">
|
||||
{#if isLive}
|
||||
<form method="POST" action={step.isCompleted ? '?/uncompleteStep' : '?/completeStep'}
|
||||
use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="stepId" value={step.id} />
|
||||
<button type="submit"
|
||||
class="flex h-5 w-5 items-center justify-center rounded border-2 transition-colors {step.isCompleted
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: 'border-gray-300 hover:border-blue-400 dark:border-gray-600'}">
|
||||
{#if step.isCompleted}
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<div class="flex h-5 w-5 items-center justify-center rounded border-2 {step.isCompleted
|
||||
? 'border-green-500 bg-green-500 text-white'
|
||||
: 'border-gray-300 dark:border-gray-600'}">
|
||||
{#if step.isCompleted}
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6L5 8.5L9.5 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span class="text-xs font-bold text-gray-400 dark:text-gray-500">Step {step.stepNumber}</span>
|
||||
<p class="text-sm font-medium {step.isCompleted ? 'text-gray-500 line-through dark:text-gray-400' : 'text-gray-900 dark:text-white'}">
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if step.description}
|
||||
<p class="mt-0.5 text-xs text-gray-600 dark:text-gray-300">{step.description}</p>
|
||||
{/if}
|
||||
{#if step.isCompleted && step.completedByName}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">
|
||||
Completed by {step.completedByName}
|
||||
{#if step.completedAt} on {formatDate(step.completedAt)}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{#if step.notes}
|
||||
<p class="mt-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{step.notes}
|
||||
</p>
|
||||
{/if}
|
||||
{#if isLive}
|
||||
{#if editingNoteId === step.id}
|
||||
<form method="POST" action="?/addStepNote"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editingNoteId = null;
|
||||
}}
|
||||
class="mt-2">
|
||||
<input type="hidden" name="stepId" value={step.id} />
|
||||
<textarea name="notes" rows="2" class={inputCls} placeholder="Add a note...">{step.notes ?? ''}</textarea>
|
||||
<div class="mt-1 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (editingNoteId = null)} class="text-xs text-gray-500">Cancel</button>
|
||||
<button type="submit" class="text-xs font-medium text-blue-600">Save note</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button type="button" onclick={() => (editingNoteId = step.id)}
|
||||
class="mt-1 text-xs text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400">
|
||||
{step.notes ? 'Edit note' : 'Add note'}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if isLive}
|
||||
<div class="flex gap-3">
|
||||
<form method="POST" action="?/completeInstance" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<button type="submit" class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
|
||||
Complete Instance
|
||||
</button>
|
||||
</form>
|
||||
{#if data.canManage}
|
||||
<form method="POST" action="?/cancelInstance" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<button type="submit" class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Cancel Instance
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses } from '$lib/server/db/schema.js';
|
||||
import { projects, expenses, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
@@ -13,12 +13,13 @@ export const load: PageServerLoad = async ({ parent }) => {
|
||||
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)`,
|
||||
spent: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
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))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(projects.companyId, company.id))
|
||||
.groupBy(projects.id)
|
||||
.orderBy(projects.name);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { projects, expenses, users, categories } from '$lib/server/db/schema.js';
|
||||
import { projects, expenses, users, categories, companyAccounts } 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';
|
||||
@@ -41,11 +41,12 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
|
||||
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)`,
|
||||
totalApproved: sql<string>`coalesce(sum(case when ${expenses.status} = 'approved' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
totalPending: sql<string>`coalesce(sum(case when ${expenses.status} = 'pending' then ${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1) else 0 end), 0)::text`,
|
||||
count: sql<number>`count(*)::int`
|
||||
})
|
||||
.from(expenses)
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(eq(expenses.projectId, params.projectId));
|
||||
|
||||
return { project, expenses: expenseList, stats };
|
||||
|
||||
@@ -122,8 +122,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.expenses as expense}
|
||||
<tr class="border-t border-gray-100 dark:border-gray-700">
|
||||
{#each data.expenses as expense (expense.id)}
|
||||
<tr class="cursor-pointer border-t border-gray-100 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/40"
|
||||
onclick={() => (window.location.href = `/companies/${data.company.id}/expenses/${expense.id}`)}>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">{expense.title}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">{expense.categoryName ?? '—'}</td>
|
||||
<td class="px-4 py-3 dark:text-white">{formatCurrency(expense.amount, expense.currency)}</td>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { expenses, projects, categories } from '$lib/server/db/schema.js';
|
||||
import { expenses, projects, categories, companyAccounts } from '$lib/server/db/schema.js';
|
||||
import { eq, and, sql, gte, lte } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
@@ -9,16 +9,20 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
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];
|
||||
|
||||
// All amounts converted to company base currency via account FX rate.
|
||||
const convertedAmount = sql<string>`${expenses.amount} * coalesce(${companyAccounts.fxRateToBase}, 1)`;
|
||||
|
||||
// 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})`
|
||||
total: sql<string>`sum(${convertedAmount})::text`
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(categories, eq(expenses.categoryId, categories.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.companyId, params.companyId),
|
||||
@@ -34,10 +38,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
.select({
|
||||
projectName: projects.name,
|
||||
allocated: projects.allocatedBudget,
|
||||
spent: sql<string>`sum(${expenses.amount})`
|
||||
spent: sql<string>`sum(${convertedAmount})::text`
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.companyId, params.companyId),
|
||||
@@ -52,10 +57,11 @@ export const load: PageServerLoad = async ({ parent, params, url }) => {
|
||||
const byMonth = await db
|
||||
.select({
|
||||
month: sql<string>`to_char(${expenses.expenseDate}::date, 'YYYY-MM')`,
|
||||
total: sql<string>`sum(${expenses.amount})`
|
||||
total: sql<string>`sum(${convertedAmount})::text`
|
||||
})
|
||||
.from(expenses)
|
||||
.innerJoin(projects, eq(expenses.projectId, projects.id))
|
||||
.leftJoin(companyAccounts, eq(expenses.accountId, companyAccounts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(projects.companyId, params.companyId),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { sales, saleLineItems, parties, projects, users } from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent, url }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
|
||||
const whereClauses = [eq(sales.companyId, params.companyId), isNull(sales.deletedAt)];
|
||||
if (status !== 'all' && ['draft', 'confirmed', 'voided'].includes(status)) {
|
||||
whereClauses.push(eq(sales.status, status as 'draft' | 'confirmed' | 'voided'));
|
||||
}
|
||||
|
||||
const salesList = await db
|
||||
.select({
|
||||
id: sales.id,
|
||||
title: sales.title,
|
||||
saleDate: sales.saleDate,
|
||||
status: sales.status,
|
||||
currency: sales.currency,
|
||||
projectId: sales.projectId,
|
||||
projectName: projects.name,
|
||||
partyId: sales.partyId,
|
||||
partyName: parties.name,
|
||||
withholdingTaxRate: sales.withholdingTaxRate,
|
||||
createdByName: users.displayName,
|
||||
createdAt: sales.createdAt,
|
||||
grossTotal: sql<string>`coalesce((
|
||||
select sum(${saleLineItems.quantity} * ${saleLineItems.unitPrice} * (1 + ${saleLineItems.taxRate}))::text
|
||||
from sale_line_items
|
||||
where sale_id = ${sales.id}
|
||||
), '0')`
|
||||
})
|
||||
.from(sales)
|
||||
.leftJoin(projects, eq(sales.projectId, projects.id))
|
||||
.leftJoin(parties, eq(sales.partyId, parties.id))
|
||||
.leftJoin(users, eq(sales.createdBy, users.id))
|
||||
.where(and(...whereClauses))
|
||||
.orderBy(desc(sales.saleDate));
|
||||
|
||||
const partyList = await db
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||
.orderBy(asc(parties.name));
|
||||
|
||||
const projectList = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
|
||||
.orderBy(asc(projects.name));
|
||||
|
||||
return {
|
||||
sales: salesList,
|
||||
statusFilter: status,
|
||||
parties: partyList,
|
||||
projects: projectList
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createSale: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const saleDate = trimOrNull(fd.get('saleDate'));
|
||||
const projectId = trimOrNull(fd.get('projectId'));
|
||||
const partyId = trimOrNull(fd.get('partyId'));
|
||||
const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim();
|
||||
const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0;
|
||||
|
||||
if (!title) return fail(400, { action: 'createSale', error: 'Title is required' });
|
||||
if (!saleDate) return fail(400, { action: 'createSale', error: 'Sale date is required' });
|
||||
if (withholdingTaxRate < 0 || withholdingTaxRate > 1) {
|
||||
return fail(400, { action: 'createSale', error: 'Withholding rate must be 0–100%' });
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(sales)
|
||||
.values({
|
||||
companyId: params.companyId,
|
||||
title,
|
||||
saleDate,
|
||||
projectId,
|
||||
partyId,
|
||||
withholdingTaxRate: withholdingTaxRate.toFixed(4),
|
||||
createdBy: user.id,
|
||||
status: 'draft'
|
||||
})
|
||||
.returning({ id: sales.id });
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'sale_created',
|
||||
`Sale "${title}" created`, { saleId: inserted.id });
|
||||
|
||||
redirect(303, `/companies/${params.companyId}/sales/${inserted.id}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let showAddForm = $state(false);
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
confirmed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
voided: 'bg-red-200 text-red-800 line-through dark:bg-red-900/50 dark:text-red-300'
|
||||
};
|
||||
|
||||
const inputCls = '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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sales - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Sales</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Revenue events. Confirmed sales contribute to project budget.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => (showAddForm = !showAddForm)}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
{showAddForm ? 'Cancel' : '+ New Sale'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if showAddForm}
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<form method="POST" action="?/createSale" use:enhance class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label for="sale-title" class={labelCls}>Title <span class="text-red-500">*</span></label>
|
||||
<input id="sale-title" name="title" type="text" required class={inputCls} placeholder="e.g. 15 widgets to ABC Corp" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="sale-date" class={labelCls}>Sale Date <span class="text-red-500">*</span></label>
|
||||
<input id="sale-date" name="saleDate" type="date" required value={todayIso} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="sale-wht" class={labelCls}>Withholding Tax %</label>
|
||||
<input id="sale-wht" name="withholdingTaxRate" type="number" step="0.01" min="0" max="100" value="0" class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="sale-project" class={labelCls}>Project</label>
|
||||
<select id="sale-project" name="projectId" class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.projects as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="sale-party" class={labelCls}>Customer</label>
|
||||
<select id="sale-party" name="partyId" class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.parties as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Create Draft
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
After creating the sale, add line items on the detail page.
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Status filter -->
|
||||
<div class="flex gap-2">
|
||||
{#each ['all', 'draft', 'confirmed', 'voided'] as s (s)}
|
||||
<a href="?status={s}"
|
||||
class="rounded-full px-3 py-1 text-xs font-medium {data.statusFilter === s
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'}">
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if data.sales.length === 0}
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No sales yet.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Title</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Date</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Customer</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Gross</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.sales as sale (sale.id)}
|
||||
<tr class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40"
|
||||
onclick={() => (window.location.href = `/companies/${data.company.id}/sales/${sale.id}`)}>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{sale.title}</div>
|
||||
{#if sale.projectName}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Project: {sale.projectName}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{sale.saleDate}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-300">{sale.partyName ?? '—'}</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-gray-900 dark:text-white">
|
||||
{formatCurrency(sale.grossTotal, sale.currency)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[sale.status]}">
|
||||
{sale.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,297 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import {
|
||||
sales,
|
||||
saleLineItems,
|
||||
salePackages,
|
||||
parties,
|
||||
projects,
|
||||
packages,
|
||||
invoices
|
||||
} from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { and, asc, eq, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const [sale] = await db
|
||||
.select()
|
||||
.from(sales)
|
||||
.where(
|
||||
and(
|
||||
eq(sales.id, params.saleId),
|
||||
eq(sales.companyId, params.companyId),
|
||||
isNull(sales.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!sale) error(404, 'Sale not found');
|
||||
|
||||
const lineItems = await db
|
||||
.select()
|
||||
.from(saleLineItems)
|
||||
.where(eq(saleLineItems.saleId, params.saleId))
|
||||
.orderBy(asc(saleLineItems.sortOrder), asc(saleLineItems.createdAt));
|
||||
|
||||
const linkedPkgRows = await db
|
||||
.select({
|
||||
packageId: salePackages.packageId,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
status: packages.status,
|
||||
direction: packages.direction
|
||||
})
|
||||
.from(salePackages)
|
||||
.innerJoin(packages, eq(salePackages.packageId, packages.id))
|
||||
.where(eq(salePackages.saleId, params.saleId));
|
||||
|
||||
const availablePackages = await db
|
||||
.select({
|
||||
id: packages.id,
|
||||
trackingNumber: packages.trackingNumber,
|
||||
carrier: packages.carrier,
|
||||
direction: packages.direction
|
||||
})
|
||||
.from(packages)
|
||||
.where(eq(packages.companyId, params.companyId))
|
||||
.orderBy(sql`${packages.createdAt} desc`);
|
||||
|
||||
const [party] = sale.partyId
|
||||
? await db
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(eq(parties.id, sale.partyId))
|
||||
.limit(1)
|
||||
: [null];
|
||||
|
||||
const [project] = sale.projectId
|
||||
? await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, sale.projectId))
|
||||
.limit(1)
|
||||
: [null];
|
||||
|
||||
const partyList = await db
|
||||
.select({ id: parties.id, name: parties.name })
|
||||
.from(parties)
|
||||
.where(and(eq(parties.companyId, params.companyId), isNull(parties.deletedAt)))
|
||||
.orderBy(asc(parties.name));
|
||||
|
||||
const projectList = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.companyId, params.companyId), eq(projects.isActive, true)))
|
||||
.orderBy(asc(projects.name));
|
||||
|
||||
const invoiceList = await db
|
||||
.select({
|
||||
id: invoices.id,
|
||||
invoiceNumber: invoices.invoiceNumber,
|
||||
direction: invoices.direction
|
||||
})
|
||||
.from(invoices)
|
||||
.where(
|
||||
and(
|
||||
eq(invoices.companyId, params.companyId),
|
||||
eq(invoices.direction, 'outgoing'),
|
||||
ne(invoices.status, 'voided')
|
||||
)
|
||||
)
|
||||
.orderBy(asc(invoices.invoiceNumber));
|
||||
|
||||
return {
|
||||
sale,
|
||||
lineItems,
|
||||
linkedPackages: linkedPkgRows,
|
||||
availablePackages,
|
||||
party,
|
||||
project,
|
||||
parties: partyList,
|
||||
projects: projectList,
|
||||
invoices: invoiceList
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateSale: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const title = trimOrNull(fd.get('title'));
|
||||
const saleDate = trimOrNull(fd.get('saleDate'));
|
||||
const projectId = trimOrNull(fd.get('projectId'));
|
||||
const partyId = trimOrNull(fd.get('partyId'));
|
||||
const invoiceId = trimOrNull(fd.get('invoiceId'));
|
||||
const notes = trimOrNull(fd.get('notes'));
|
||||
const withholdingRaw = fd.get('withholdingTaxRate')?.toString().trim();
|
||||
const withholdingTaxRate = withholdingRaw ? Number(withholdingRaw) / 100 : 0;
|
||||
|
||||
if (!title) return fail(400, { action: 'updateSale', error: 'Title is required' });
|
||||
if (!saleDate) return fail(400, { action: 'updateSale', error: 'Date is required' });
|
||||
|
||||
await db
|
||||
.update(sales)
|
||||
.set({
|
||||
title,
|
||||
saleDate,
|
||||
projectId,
|
||||
partyId,
|
||||
invoiceId,
|
||||
notes,
|
||||
withholdingTaxRate: withholdingTaxRate.toFixed(4),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId))
|
||||
);
|
||||
|
||||
return { success: true, action: 'updateSale' };
|
||||
},
|
||||
|
||||
addLineItem: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const productName = trimOrNull(fd.get('productName'));
|
||||
const description = trimOrNull(fd.get('description'));
|
||||
const quantity = fd.get('quantity')?.toString().trim();
|
||||
const unitPrice = fd.get('unitPrice')?.toString().trim();
|
||||
const taxPct = fd.get('taxRate')?.toString().trim();
|
||||
|
||||
if (!productName) return fail(400, { action: 'addLineItem', error: 'Product name required' });
|
||||
if (!quantity || Number(quantity) <= 0) return fail(400, { action: 'addLineItem', error: 'Valid quantity required' });
|
||||
if (!unitPrice || Number(unitPrice) < 0) return fail(400, { action: 'addLineItem', error: 'Valid unit price required' });
|
||||
|
||||
const taxRate = taxPct ? Number(taxPct) / 100 : 0;
|
||||
|
||||
const [maxRow] = await db
|
||||
.select({ max: sql<number>`coalesce(max(${saleLineItems.sortOrder}), -1)::int` })
|
||||
.from(saleLineItems)
|
||||
.where(eq(saleLineItems.saleId, params.saleId));
|
||||
|
||||
await db.insert(saleLineItems).values({
|
||||
saleId: params.saleId,
|
||||
productName,
|
||||
description,
|
||||
quantity: Number(quantity).toFixed(4),
|
||||
unitPrice: Number(unitPrice).toFixed(2),
|
||||
taxRate: taxRate.toFixed(4),
|
||||
sortOrder: (maxRow?.max ?? -1) + 1
|
||||
});
|
||||
|
||||
return { success: true, action: 'addLineItem' };
|
||||
},
|
||||
|
||||
removeLineItem: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const itemId = trimOrNull(fd.get('itemId'));
|
||||
if (!itemId) return fail(400, { action: 'removeLineItem', error: 'Item id required' });
|
||||
|
||||
await db
|
||||
.delete(saleLineItems)
|
||||
.where(and(eq(saleLineItems.id, itemId), eq(saleLineItems.saleId, params.saleId)));
|
||||
|
||||
return { success: true, action: 'removeLineItem' };
|
||||
},
|
||||
|
||||
confirmSale: async ({ locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin', 'manager', 'accountant'
|
||||
]);
|
||||
|
||||
const [lineCount] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(saleLineItems)
|
||||
.where(eq(saleLineItems.saleId, params.saleId));
|
||||
|
||||
if (!lineCount || lineCount.count === 0) {
|
||||
return fail(400, { action: 'confirmSale', error: 'Add at least one line item before confirming' });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(sales)
|
||||
.set({ status: 'confirmed', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(sales.id, params.saleId),
|
||||
eq(sales.companyId, params.companyId),
|
||||
eq(sales.status, 'draft')
|
||||
)
|
||||
)
|
||||
.returning({ title: sales.title });
|
||||
|
||||
if (!updated) return fail(400, { action: 'confirmSale', error: 'Sale not found or not in draft' });
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'sale_confirmed',
|
||||
`Sale "${updated.title}" confirmed`, { saleId: params.saleId });
|
||||
|
||||
return { success: true, action: 'confirmSale' };
|
||||
},
|
||||
|
||||
voidSale: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager']);
|
||||
const fd = await request.formData();
|
||||
const reason = trimOrNull(fd.get('reason'));
|
||||
if (!reason) return fail(400, { action: 'voidSale', error: 'Void reason is required' });
|
||||
|
||||
const [updated] = await db
|
||||
.update(sales)
|
||||
.set({
|
||||
status: 'voided',
|
||||
voidedAt: new Date(),
|
||||
voidReason: reason,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(eq(sales.id, params.saleId), eq(sales.companyId, params.companyId))
|
||||
)
|
||||
.returning({ title: sales.title });
|
||||
|
||||
if (!updated) return fail(404, { action: 'voidSale', error: 'Sale not found' });
|
||||
|
||||
await logCompanyEvent(params.companyId, user.id, 'sale_voided',
|
||||
`Sale "${updated.title}" voided: ${reason}`,
|
||||
{ saleId: params.saleId, reason });
|
||||
|
||||
return { success: true, action: 'voidSale' };
|
||||
},
|
||||
|
||||
linkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const packageId = trimOrNull(fd.get('packageId'));
|
||||
if (!packageId) return fail(400, { error: 'Package id required' });
|
||||
|
||||
await db
|
||||
.insert(salePackages)
|
||||
.values({ saleId: params.saleId, packageId })
|
||||
.onConflictDoNothing();
|
||||
|
||||
return { success: true, action: 'linkPackage' };
|
||||
},
|
||||
|
||||
unlinkPackage: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const packageId = trimOrNull(fd.get('packageId'));
|
||||
if (!packageId) return fail(400, { error: 'Package id required' });
|
||||
|
||||
await db
|
||||
.delete(salePackages)
|
||||
.where(and(eq(salePackages.saleId, params.saleId), eq(salePackages.packageId, packageId)));
|
||||
|
||||
return { success: true, action: 'unlinkPackage' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { formatCurrency } from '$lib/utils/currency.js';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let showAddItem = $state(false);
|
||||
let editingMeta = $state(false);
|
||||
let showVoidForm = $state(false);
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||
confirmed: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
voided: 'bg-red-200 text-red-800 dark:bg-red-900/50 dark:text-red-300'
|
||||
};
|
||||
|
||||
const isLive = $derived(data.sale.status === 'draft');
|
||||
|
||||
const totals = $derived.by(() => {
|
||||
let subtotal = 0;
|
||||
let tax = 0;
|
||||
for (const li of data.lineItems) {
|
||||
const lineNet = Number(li.quantity) * Number(li.unitPrice);
|
||||
subtotal += lineNet;
|
||||
tax += lineNet * Number(li.taxRate);
|
||||
}
|
||||
const gross = subtotal + tax;
|
||||
const withholding = gross * Number(data.sale.withholdingTaxRate);
|
||||
const net = gross - withholding;
|
||||
return { subtotal, tax, gross, withholding, net };
|
||||
});
|
||||
|
||||
const inputCls = '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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.sale.title} - Sales</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<a href={`/companies/${data.company.id}/sales`} class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">← Sales</a>
|
||||
<div class="mt-1 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{data.sale.title}</h1>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {STATUS_BADGE[data.sale.status]}">
|
||||
{data.sale.status}
|
||||
</span>
|
||||
<span>{data.sale.saleDate}</span>
|
||||
{#if data.party}<span>Customer: {data.party.name}</span>{/if}
|
||||
{#if data.project}<span>Project: {data.project.name}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick={() => (editingMeta = !editingMeta)}
|
||||
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">
|
||||
{editingMeta ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
{#if data.sale.status === 'voided' && data.sale.voidReason}
|
||||
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-700 dark:bg-red-900/20">
|
||||
<span class="font-medium text-red-700 dark:text-red-300">Voided:</span>
|
||||
<span class="text-red-600 dark:text-red-400">{data.sale.voidReason}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if editingMeta}
|
||||
<form method="POST" action="?/updateSale"
|
||||
use:enhance={() => async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') editingMeta = false;
|
||||
}}
|
||||
class="grid grid-cols-1 gap-3 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label for="s-title" class={labelCls}>Title</label>
|
||||
<input id="s-title" name="title" type="text" required value={data.sale.title} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="s-date" class={labelCls}>Date</label>
|
||||
<input id="s-date" name="saleDate" type="date" required value={data.sale.saleDate} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="s-wht" class={labelCls}>Withholding %</label>
|
||||
<input id="s-wht" name="withholdingTaxRate" type="number" step="0.01" min="0" max="100"
|
||||
value={(Number(data.sale.withholdingTaxRate) * 100).toString()} class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label for="s-project" class={labelCls}>Project</label>
|
||||
<select id="s-project" name="projectId" value={data.sale.projectId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.projects as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="s-party" class={labelCls}>Customer</label>
|
||||
<select id="s-party" name="partyId" value={data.sale.partyId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.parties as p (p.id)}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="s-invoice" class={labelCls}>Outgoing Invoice</label>
|
||||
<select id="s-invoice" name="invoiceId" value={data.sale.invoiceId ?? ''} class={inputCls}>
|
||||
<option value="">—</option>
|
||||
{#each data.invoices as inv (inv.id)}
|
||||
<option value={inv.id}>{inv.invoiceNumber}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="s-notes" class={labelCls}>Notes</label>
|
||||
<textarea id="s-notes" name="notes" rows="2" class={inputCls}>{data.sale.notes ?? ''}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Line items -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-gray-700">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Line Items</h2>
|
||||
{#if isLive}
|
||||
<button type="button" onclick={() => (showAddItem = !showAddItem)}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{showAddItem ? 'Cancel' : '+ Add item'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddItem && isLive}
|
||||
<form method="POST" action="?/addLineItem"
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') { showAddItem = false; formElement.reset(); }
|
||||
}}
|
||||
class="grid grid-cols-1 gap-3 border-b border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-700/30 md:grid-cols-4">
|
||||
<div class="md:col-span-2">
|
||||
<label class={labelCls} for="li-product">Product</label>
|
||||
<input id="li-product" name="productName" type="text" required class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="li-qty">Quantity</label>
|
||||
<input id="li-qty" name="quantity" type="number" step="0.0001" min="0.0001" required value="1" class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="li-price">Unit Price</label>
|
||||
<input id="li-price" name="unitPrice" type="number" step="0.01" min="0" required class={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls} for="li-tax">Tax %</label>
|
||||
<input id="li-tax" name="taxRate" type="number" step="0.01" min="0" max="100" value="7" class={inputCls} />
|
||||
</div>
|
||||
<div class="md:col-span-3">
|
||||
<label class={labelCls} for="li-desc">Description</label>
|
||||
<input id="li-desc" name="description" type="text" class={inputCls} />
|
||||
</div>
|
||||
<div class="md:col-span-4 flex justify-end">
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if data.lineItems.length === 0}
|
||||
<p class="p-4 text-sm text-gray-500 dark:text-gray-400">No line items yet.</p>
|
||||
{:else}
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Product</th>
|
||||
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Qty</th>
|
||||
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Unit</th>
|
||||
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Tax %</th>
|
||||
<th class="px-4 py-2 text-right font-semibold text-gray-700 dark:text-gray-300">Line Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each data.lineItems as li (li.id)}
|
||||
{@const lineNet = Number(li.quantity) * Number(li.unitPrice)}
|
||||
{@const lineGross = lineNet * (1 + Number(li.taxRate))}
|
||||
<tr>
|
||||
<td class="px-4 py-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{li.productName}</div>
|
||||
{#if li.description}<div class="text-xs text-gray-500">{li.description}</div>{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{Number(li.quantity)}</td>
|
||||
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{formatCurrency(li.unitPrice, data.sale.currency)}</td>
|
||||
<td class="px-4 py-2 text-right text-gray-700 dark:text-gray-300">{(Number(li.taxRate) * 100).toFixed(1)}%</td>
|
||||
<td class="px-4 py-2 text-right font-medium text-gray-900 dark:text-white">{formatCurrency(lineGross.toFixed(2), data.sale.currency)}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
{#if isLive}
|
||||
<form method="POST" action="?/removeLineItem" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="itemId" value={li.id} />
|
||||
<button type="submit" class="text-xs text-red-600 hover:text-red-700 dark:text-red-400">Remove</button>
|
||||
</form>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr class="text-sm">
|
||||
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Subtotal</td>
|
||||
<td class="px-4 py-2 text-right text-gray-900 dark:text-white">{formatCurrency(totals.subtotal.toFixed(2), data.sale.currency)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="text-sm">
|
||||
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Tax</td>
|
||||
<td class="px-4 py-2 text-right text-gray-900 dark:text-white">{formatCurrency(totals.tax.toFixed(2), data.sale.currency)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="text-sm">
|
||||
<td colspan="4" class="px-4 py-2 text-right font-medium text-gray-700 dark:text-gray-300">Gross</td>
|
||||
<td class="px-4 py-2 text-right font-semibold text-gray-900 dark:text-white">{formatCurrency(totals.gross.toFixed(2), data.sale.currency)}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{#if Number(data.sale.withholdingTaxRate) > 0}
|
||||
<tr class="text-sm">
|
||||
<td colspan="4" class="px-4 py-2 text-right font-medium text-red-600 dark:text-red-400">
|
||||
Withholding ({(Number(data.sale.withholdingTaxRate) * 100).toFixed(2)}%)
|
||||
</td>
|
||||
<td class="px-4 py-2 text-right text-red-600 dark:text-red-400">
|
||||
-{formatCurrency(totals.withholding.toFixed(2), data.sale.currency)}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="text-sm">
|
||||
<td colspan="4" class="px-4 py-2 text-right font-bold text-gray-900 dark:text-white">Net Receivable</td>
|
||||
<td class="px-4 py-2 text-right font-bold text-green-600 dark:text-green-400">
|
||||
{formatCurrency(totals.net.toFixed(2), data.sale.currency)}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tfoot>
|
||||
</table>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Packages -->
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<h2 class="mb-3 font-semibold text-gray-900 dark:text-white">Linked Packages</h2>
|
||||
{#if data.linkedPackages.length > 0}
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
{#each data.linkedPackages as pkg (pkg.packageId)}
|
||||
<span class="inline-flex items-center gap-2 rounded-full bg-cyan-100 px-3 py-1 text-xs font-medium text-cyan-700 dark:bg-cyan-900/40 dark:text-cyan-300">
|
||||
📦 {pkg.trackingNumber} ({pkg.carrier})
|
||||
{#if isLive}
|
||||
<form method="POST" action="?/unlinkPackage" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="packageId" value={pkg.packageId} />
|
||||
<button type="submit" class="text-cyan-800 hover:text-red-600 dark:text-cyan-200">×</button>
|
||||
</form>
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLive && data.availablePackages.length > 0}
|
||||
<form method="POST" action="?/linkPackage" use:enhance class="flex items-center gap-2 text-sm">
|
||||
<select name="packageId" required class={inputCls + ' flex-1'}>
|
||||
<option value="" disabled selected>Select package to link</option>
|
||||
{#each data.availablePackages as pkg (pkg.id)}
|
||||
{#if !data.linkedPackages.find((l) => l.packageId === pkg.id)}
|
||||
<option value={pkg.id}>{pkg.trackingNumber} — {pkg.carrier} ({pkg.direction})</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Link
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if isLive}
|
||||
<div class="flex gap-3">
|
||||
<form method="POST" action="?/confirmSale" use:enhance>
|
||||
<button type="submit" class="rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
|
||||
Confirm Sale
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
|
||||
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Void Sale
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.sale.status === 'confirmed'}
|
||||
<div class="flex">
|
||||
<button type="button" onclick={() => (showVoidForm = !showVoidForm)}
|
||||
class="rounded-md border border-red-300 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20">
|
||||
Void Sale
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showVoidForm}
|
||||
<form method="POST" action="?/voidSale"
|
||||
use:enhance={() => async ({ update }) => { await update({ reset: false }); showVoidForm = false; }}
|
||||
class="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-700 dark:bg-red-900/20">
|
||||
<label for="void-reason" class={labelCls}>Void Reason <span class="text-red-500">*</span></label>
|
||||
<textarea id="void-reason" name="reason" rows="2" required class={inputCls}></textarea>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="button" onclick={() => (showVoidForm = false)}
|
||||
class="rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 dark:border-gray-600 dark:text-gray-200">Cancel</button>
|
||||
<button type="submit" class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700">Confirm Void</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,245 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { companyServiceAccounts } from '$lib/server/db/schema.js';
|
||||
import { requireCompanyRoleAny } from '$lib/server/authorization.js';
|
||||
import { logCompanyEvent } from '$lib/server/audit.js';
|
||||
import { and, asc, eq, isNull } from 'drizzle-orm';
|
||||
|
||||
const SERVICE_TYPES = [
|
||||
'electricity',
|
||||
'water',
|
||||
'gas',
|
||||
'internet',
|
||||
'phone',
|
||||
'shipping',
|
||||
'insurance',
|
||||
'tax_registration',
|
||||
'social_security',
|
||||
'customs',
|
||||
'other'
|
||||
] as const;
|
||||
|
||||
type ServiceType = (typeof SERVICE_TYPES)[number];
|
||||
|
||||
function trimOrNull(v: FormDataEntryValue | null): string | null {
|
||||
const s = v?.toString().trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
function parseType(v: FormDataEntryValue | null): ServiceType | null {
|
||||
const s = v?.toString();
|
||||
if (!s) return null;
|
||||
return (SERVICE_TYPES as readonly string[]).includes(s) ? (s as ServiceType) : null;
|
||||
}
|
||||
|
||||
function validateUrl(v: string | null): string | null {
|
||||
if (!v) return null;
|
||||
if (!v.startsWith('http://') && !v.startsWith('https://')) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, params, parent }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
await parent();
|
||||
|
||||
const accounts = await db
|
||||
.select()
|
||||
.from(companyServiceAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyServiceAccounts.companyId, params.companyId),
|
||||
isNull(companyServiceAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(companyServiceAccounts.type), asc(companyServiceAccounts.providerName));
|
||||
|
||||
return { serviceAccounts: accounts };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const type = parseType(fd.get('type'));
|
||||
const providerName = trimOrNull(fd.get('providerName'));
|
||||
const accountNumber = trimOrNull(fd.get('accountNumber'));
|
||||
const customLabel = trimOrNull(fd.get('customLabel'));
|
||||
const contactPhone = trimOrNull(fd.get('contactPhone'));
|
||||
const websiteUrlRaw = trimOrNull(fd.get('websiteUrl'));
|
||||
const notes = trimOrNull(fd.get('notes'));
|
||||
|
||||
if (!type) return fail(400, { action: 'create', error: 'Service type is required' });
|
||||
if (!providerName) return fail(400, { action: 'create', error: 'Provider name is required' });
|
||||
if (providerName.length > 200)
|
||||
return fail(400, { action: 'create', error: 'Provider name too long (max 200)' });
|
||||
if (!accountNumber)
|
||||
return fail(400, { action: 'create', error: 'Account number is required' });
|
||||
if (accountNumber.length > 200)
|
||||
return fail(400, { action: 'create', error: 'Account number too long (max 200)' });
|
||||
if (websiteUrlRaw && !validateUrl(websiteUrlRaw))
|
||||
return fail(400, { action: 'create', error: 'Website URL must start with http:// or https://' });
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(companyServiceAccounts)
|
||||
.values({
|
||||
companyId: params.companyId,
|
||||
type,
|
||||
providerName,
|
||||
accountNumber,
|
||||
customLabel,
|
||||
contactPhone,
|
||||
websiteUrl: websiteUrlRaw,
|
||||
notes,
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning({ id: companyServiceAccounts.id });
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'service_account_created',
|
||||
`Service account "${providerName}" (${type}) created`,
|
||||
{ accountId: inserted.id, type, providerName }
|
||||
);
|
||||
|
||||
return { success: true, action: 'create' };
|
||||
},
|
||||
|
||||
update: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'update', error: 'Account id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: companyServiceAccounts.id })
|
||||
.from(companyServiceAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyServiceAccounts.id, id),
|
||||
eq(companyServiceAccounts.companyId, params.companyId),
|
||||
isNull(companyServiceAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Service account not found');
|
||||
|
||||
const type = parseType(fd.get('type'));
|
||||
const providerName = trimOrNull(fd.get('providerName'));
|
||||
const accountNumber = trimOrNull(fd.get('accountNumber'));
|
||||
const customLabel = trimOrNull(fd.get('customLabel'));
|
||||
const contactPhone = trimOrNull(fd.get('contactPhone'));
|
||||
const websiteUrlRaw = trimOrNull(fd.get('websiteUrl'));
|
||||
const notes = trimOrNull(fd.get('notes'));
|
||||
|
||||
if (!type) return fail(400, { action: 'update', error: 'Service type is required' });
|
||||
if (!providerName) return fail(400, { action: 'update', error: 'Provider name is required' });
|
||||
if (providerName.length > 200)
|
||||
return fail(400, { action: 'update', error: 'Provider name too long (max 200)' });
|
||||
if (!accountNumber)
|
||||
return fail(400, { action: 'update', error: 'Account number is required' });
|
||||
if (accountNumber.length > 200)
|
||||
return fail(400, { action: 'update', error: 'Account number too long (max 200)' });
|
||||
if (websiteUrlRaw && !validateUrl(websiteUrlRaw))
|
||||
return fail(400, { action: 'update', error: 'Website URL must start with http:// or https://' });
|
||||
|
||||
await db
|
||||
.update(companyServiceAccounts)
|
||||
.set({
|
||||
type,
|
||||
providerName,
|
||||
accountNumber,
|
||||
customLabel,
|
||||
contactPhone,
|
||||
websiteUrl: websiteUrlRaw,
|
||||
notes,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(companyServiceAccounts.id, id));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'service_account_updated',
|
||||
`Service account "${providerName}" updated`,
|
||||
{ accountId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'update' };
|
||||
},
|
||||
|
||||
delete: async ({ request, locals, params }) => {
|
||||
const { user } = await requireCompanyRoleAny(locals, params.companyId, [
|
||||
'admin',
|
||||
'manager',
|
||||
'accountant'
|
||||
]);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'delete', error: 'Account id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: companyServiceAccounts.id, providerName: companyServiceAccounts.providerName })
|
||||
.from(companyServiceAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyServiceAccounts.id, id),
|
||||
eq(companyServiceAccounts.companyId, params.companyId),
|
||||
isNull(companyServiceAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Service account not found');
|
||||
|
||||
await db
|
||||
.update(companyServiceAccounts)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(companyServiceAccounts.id, id));
|
||||
|
||||
await logCompanyEvent(
|
||||
params.companyId,
|
||||
user.id,
|
||||
'service_account_deleted',
|
||||
`Service account "${existing.providerName}" deleted`,
|
||||
{ accountId: id }
|
||||
);
|
||||
|
||||
return { success: true, action: 'delete' };
|
||||
},
|
||||
|
||||
toggleActive: async ({ request, locals, params }) => {
|
||||
await requireCompanyRoleAny(locals, params.companyId, ['admin', 'manager', 'accountant']);
|
||||
const fd = await request.formData();
|
||||
const id = trimOrNull(fd.get('id'));
|
||||
if (!id) return fail(400, { action: 'toggleActive', error: 'Account id is required' });
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: companyServiceAccounts.id, isActive: companyServiceAccounts.isActive })
|
||||
.from(companyServiceAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(companyServiceAccounts.id, id),
|
||||
eq(companyServiceAccounts.companyId, params.companyId),
|
||||
isNull(companyServiceAccounts.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) error(404, 'Service account not found');
|
||||
|
||||
await db
|
||||
.update(companyServiceAccounts)
|
||||
.set({ isActive: !existing.isActive, updatedAt: new Date() })
|
||||
.where(eq(companyServiceAccounts.id, id));
|
||||
|
||||
return { success: true, action: 'toggleActive' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,383 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let typeFilter = $state<string>('all');
|
||||
let showAddForm = $state(false);
|
||||
let editingId = $state<string | null>(null);
|
||||
let deletingId = $state<string | null>(null);
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
electricity: 'Electricity',
|
||||
water: 'Water',
|
||||
gas: 'Gas',
|
||||
internet: 'Internet',
|
||||
phone: 'Phone',
|
||||
shipping: 'Shipping',
|
||||
insurance: 'Insurance',
|
||||
tax_registration: 'Tax Registration',
|
||||
social_security: 'Social Security',
|
||||
customs: 'Customs',
|
||||
other: 'Other'
|
||||
};
|
||||
|
||||
const TYPE_BADGE: Record<string, string> = {
|
||||
electricity: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
water: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
gas: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
internet: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
|
||||
phone: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
|
||||
shipping: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
insurance: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
tax_registration: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||
social_security: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
|
||||
customs: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
|
||||
other: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||
};
|
||||
|
||||
const ALL_TYPES = Object.keys(TYPE_LABELS);
|
||||
|
||||
const filteredAccounts = $derived(
|
||||
typeFilter === 'all'
|
||||
? data.serviceAccounts
|
||||
: data.serviceAccounts.filter((a) => a.type === typeFilter)
|
||||
);
|
||||
|
||||
const typesPresent = $derived([...new Set(data.serviceAccounts.map((a) => a.type))]);
|
||||
|
||||
function openAdd() {
|
||||
showAddForm = !showAddForm;
|
||||
editingId = null;
|
||||
deletingId = null;
|
||||
}
|
||||
|
||||
function startEdit(id: string) {
|
||||
editingId = id;
|
||||
showAddForm = false;
|
||||
deletingId = null;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'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';
|
||||
const labelCls = 'mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Service Accounts - {data.company.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#snippet accountForm(
|
||||
action: string,
|
||||
values: {
|
||||
type?: string;
|
||||
providerName?: string;
|
||||
accountNumber?: string;
|
||||
customLabel?: string;
|
||||
contactPhone?: string;
|
||||
websiteUrl?: string;
|
||||
notes?: string;
|
||||
} = {},
|
||||
accountId?: string
|
||||
)}
|
||||
<form
|
||||
method="POST"
|
||||
{action}
|
||||
use:enhance={() => async ({ result, update, formElement }) => {
|
||||
await update({ reset: false });
|
||||
if (result.type === 'success') {
|
||||
if (!accountId) {
|
||||
showAddForm = false;
|
||||
formElement.reset();
|
||||
} else {
|
||||
editingId = null;
|
||||
}
|
||||
}
|
||||
}}
|
||||
class="mt-3 grid grid-cols-1 gap-3 rounded-md bg-gray-50 p-4 dark:bg-gray-700/50 md:grid-cols-2"
|
||||
>
|
||||
{#if accountId}
|
||||
<input type="hidden" name="id" value={accountId} />
|
||||
{/if}
|
||||
<div>
|
||||
<label class={labelCls}>Type <span class="text-red-500">*</span></label>
|
||||
<select name="type" required value={values.type ?? ''} class={inputCls}>
|
||||
<option value="" disabled>Select type</option>
|
||||
{#each ALL_TYPES as t (t)}
|
||||
<option value={t}>{TYPE_LABELS[t]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Provider Name <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
name="providerName"
|
||||
type="text"
|
||||
required
|
||||
value={values.providerName ?? ''}
|
||||
placeholder="e.g. PEA, TRUE, UPS"
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Account Number <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
name="accountNumber"
|
||||
type="text"
|
||||
required
|
||||
value={values.accountNumber ?? ''}
|
||||
placeholder="e.g. 12-3456-7890"
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Custom Label</label>
|
||||
<input
|
||||
name="customLabel"
|
||||
type="text"
|
||||
value={values.customLabel ?? ''}
|
||||
placeholder="e.g. Main office"
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Contact Phone</label>
|
||||
<input
|
||||
name="contactPhone"
|
||||
type="tel"
|
||||
value={values.contactPhone ?? ''}
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class={labelCls}>Website</label>
|
||||
<input
|
||||
name="websiteUrl"
|
||||
type="url"
|
||||
value={values.websiteUrl ?? ''}
|
||||
placeholder="https://..."
|
||||
class={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class={labelCls}>Notes</label>
|
||||
<textarea name="notes" rows="2" class={inputCls}>{values.notes ?? ''}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (accountId) editingId = null;
|
||||
else showAddForm = false;
|
||||
}}
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
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"
|
||||
>
|
||||
{accountId ? 'Save' : 'Add Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-6">
|
||||
<header>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Service Accounts</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Utility accounts, shipping carrier numbers, insurance policies, and government registrations.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white">Add Service Account</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={openAdd}
|
||||
class="rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{showAddForm ? 'Cancel' : '+ New Account'}
|
||||
</button>
|
||||
</div>
|
||||
{#if showAddForm}
|
||||
{@render accountForm('?/create')}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if data.serviceAccounts.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (typeFilter = 'all')}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium {typeFilter === 'all'
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each typesPresent as t (t)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (typeFilter = t)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium {typeFilter === t
|
||||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{TYPE_LABELS[t] ?? t}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filteredAccounts.length === 0}
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No {typeFilter === 'all' ? '' : TYPE_LABELS[typeFilter]?.toLowerCase() + ' '}accounts yet.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Type</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Provider</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Account #</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Contact</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-700 dark:text-gray-300">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each filteredAccounts as acct (acct.id)}
|
||||
<tr class="align-top {acct.isActive ? '' : 'opacity-60'}">
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {TYPE_BADGE[acct.type] ?? TYPE_BADGE.other}">
|
||||
{TYPE_LABELS[acct.type] ?? acct.type}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900 dark:text-white">{acct.providerName}</div>
|
||||
{#if acct.customLabel}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{acct.customLabel}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-900 dark:text-white">{acct.accountNumber}</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{#if acct.contactPhone}
|
||||
<div>{acct.contactPhone}</div>
|
||||
{/if}
|
||||
{#if acct.websiteUrl}
|
||||
<a
|
||||
href={acct.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Website
|
||||
</a>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<form method="POST" action="?/toggleActive" use:enhance={() => async ({ update }) => await update({ reset: false })}>
|
||||
<input type="hidden" name="id" value={acct.id} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-medium {acct.isActive
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-400'}"
|
||||
>
|
||||
{acct.isActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => startEdit(acct.id)}
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (deletingId = deletingId === acct.id ? null : acct.id)}
|
||||
class="font-medium text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if deletingId === acct.id}
|
||||
<tr>
|
||||
<td colspan="6" class="bg-red-50 px-4 py-3 dark:bg-red-900/20">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() => async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
deletingId = null;
|
||||
}}
|
||||
class="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<input type="hidden" name="id" value={acct.id} />
|
||||
<p class="text-red-700 dark:text-red-300">Delete "{acct.providerName}"?</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (deletingId = null)}
|
||||
class="rounded border border-gray-300 bg-white px-2 py-1 text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if editingId === acct.id}
|
||||
<tr>
|
||||
<td colspan="6" class="bg-gray-50 px-4 py-3 dark:bg-gray-700/30">
|
||||
{@render accountForm(
|
||||
'?/update',
|
||||
{
|
||||
type: acct.type,
|
||||
providerName: acct.providerName,
|
||||
accountNumber: acct.accountNumber,
|
||||
customLabel: acct.customLabel ?? '',
|
||||
contactPhone: acct.contactPhone ?? '',
|
||||
websiteUrl: acct.websiteUrl ?? '',
|
||||
notes: acct.notes ?? ''
|
||||
},
|
||||
acct.id
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
isOIDCEnabled
|
||||
} from '$lib/server/auth/oidc.js';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
export const GET: RequestHandler = async ({ cookies, url: reqUrl }) => {
|
||||
const isSecure = reqUrl.protocol === 'https:';
|
||||
if (!isOIDCEnabled()) {
|
||||
redirect(302, '/login');
|
||||
}
|
||||
@@ -17,6 +18,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
cookies.set('oidc_state', state, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 600 // 10 minutes
|
||||
@@ -24,6 +26,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
||||
|
||||
cookies.set('oidc_code_verifier', codeVerifier, {
|
||||
httpOnly: true,
|
||||
secure: isSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 600
|
||||
|
||||
@@ -45,8 +45,8 @@ export const GET: RequestHandler = async (event) => {
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (!user) {
|
||||
// Check if a user with this email exists (link accounts)
|
||||
if (userInfo.email) {
|
||||
// Check if a user with this email exists — only auto-link if provider verified the email
|
||||
if (userInfo.email && userInfo.email_verified) {
|
||||
user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -55,7 +55,7 @@ export const GET: RequestHandler = async (event) => {
|
||||
.then((r) => r[0] ?? null);
|
||||
|
||||
if (user) {
|
||||
// Link OIDC identity to existing user
|
||||
// Link OIDC identity to existing user (email verified by provider)
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Constrain date inputs so browsers don't render yyyyyy-mm-dd.
|
||||
// Uses a MutationObserver so dynamically-rendered inputs get constrained too.
|
||||
function constrainEl(el: Element) {
|
||||
if (!(el instanceof HTMLInputElement)) return;
|
||||
if (el.type !== 'date') return;
|
||||
if (!el.hasAttribute('min')) el.setAttribute('min', '1900-01-01');
|
||||
if (!el.hasAttribute('max')) el.setAttribute('max', '2100-12-31');
|
||||
}
|
||||
function constrainRoot(root: ParentNode) {
|
||||
root.querySelectorAll<HTMLInputElement>('input[type="date"]').forEach(constrainEl);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
constrainRoot(document);
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
constrainEl(node as Element);
|
||||
if ((node as Element).querySelectorAll) constrainRoot(node as Element);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
|
||||
Reference in New Issue
Block a user