Spaces:
Running
Running
Commit ·
4ae345a
1
Parent(s): 650ab40
feat(ui): add expense form with live split editor and photo upload
Browse files
src/lib/client/downscale.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Downscales an image file in the browser to at most `maxEdge` px on its longest side
|
| 3 |
+
* and re-encodes it as JPEG. Returns null when no processing is needed or possible.
|
| 4 |
+
*/
|
| 5 |
+
export async function downscaleImage(file: File, maxEdge = 1600, quality = 0.85): Promise<Blob | null> {
|
| 6 |
+
if (typeof createImageBitmap === 'undefined' || typeof document === 'undefined') return null;
|
| 7 |
+
try {
|
| 8 |
+
const bitmap = await createImageBitmap(file);
|
| 9 |
+
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
|
| 10 |
+
if (scale === 1 && file.type === 'image/jpeg') {
|
| 11 |
+
bitmap.close();
|
| 12 |
+
return null;
|
| 13 |
+
}
|
| 14 |
+
const canvas = document.createElement('canvas');
|
| 15 |
+
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
| 16 |
+
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
| 17 |
+
const ctx = canvas.getContext('2d');
|
| 18 |
+
if (!ctx) return null;
|
| 19 |
+
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
| 20 |
+
bitmap.close();
|
| 21 |
+
return await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
|
| 22 |
+
} catch {
|
| 23 |
+
return null;
|
| 24 |
+
}
|
| 25 |
+
}
|
src/lib/components/ExpenseForm.svelte
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { enhance } from '$app/forms';
|
| 3 |
+
import { downscaleImage } from '$lib/client/downscale';
|
| 4 |
+
import type { ExpensePhoto, PublicUser } from '$lib/domain/types';
|
| 5 |
+
import type { ExpenseFormValues, FieldErrors } from '$lib/forms/types';
|
| 6 |
+
import FormError from './FormError.svelte';
|
| 7 |
+
import SplitEditor from './SplitEditor.svelte';
|
| 8 |
+
|
| 9 |
+
let {
|
| 10 |
+
members,
|
| 11 |
+
currency,
|
| 12 |
+
values,
|
| 13 |
+
errors = {},
|
| 14 |
+
existingPhoto,
|
| 15 |
+
version,
|
| 16 |
+
submitLabel
|
| 17 |
+
}: {
|
| 18 |
+
members: PublicUser[];
|
| 19 |
+
currency: string;
|
| 20 |
+
values: ExpenseFormValues;
|
| 21 |
+
errors?: FieldErrors;
|
| 22 |
+
existingPhoto?: ExpensePhoto;
|
| 23 |
+
version?: number;
|
| 24 |
+
submitLabel: string;
|
| 25 |
+
} = $props();
|
| 26 |
+
|
| 27 |
+
let amount = $state(values.amount);
|
| 28 |
+
let submitting = $state(false);
|
| 29 |
+
</script>
|
| 30 |
+
|
| 31 |
+
<form
|
| 32 |
+
method="POST"
|
| 33 |
+
enctype="multipart/form-data"
|
| 34 |
+
class="space-y-4"
|
| 35 |
+
use:enhance={async ({ formData }) => {
|
| 36 |
+
submitting = true;
|
| 37 |
+
const photo = formData.get('photo');
|
| 38 |
+
if (photo instanceof File && photo.size > 0 && photo.type.startsWith('image/')) {
|
| 39 |
+
const small = await downscaleImage(photo);
|
| 40 |
+
if (small) formData.set('photo', small, 'photo.jpg');
|
| 41 |
+
}
|
| 42 |
+
return async ({ update }) => {
|
| 43 |
+
submitting = false;
|
| 44 |
+
await update();
|
| 45 |
+
};
|
| 46 |
+
}}
|
| 47 |
+
>
|
| 48 |
+
{#if version !== undefined}
|
| 49 |
+
<input type="hidden" name="version" value={version} />
|
| 50 |
+
{/if}
|
| 51 |
+
|
| 52 |
+
<div>
|
| 53 |
+
<label for="description">Description</label>
|
| 54 |
+
<input
|
| 55 |
+
id="description"
|
| 56 |
+
name="description"
|
| 57 |
+
required
|
| 58 |
+
maxlength="120"
|
| 59 |
+
value={values.description}
|
| 60 |
+
placeholder="Dinner, groceries, tickets…"
|
| 61 |
+
/>
|
| 62 |
+
<FormError message={errors.description} />
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="grid grid-cols-2 gap-3">
|
| 66 |
+
<div>
|
| 67 |
+
<label for="amount">Amount ({currency})</label>
|
| 68 |
+
<input id="amount" name="amount" inputmode="decimal" placeholder="0.00" required bind:value={amount} />
|
| 69 |
+
<FormError message={errors.amount} />
|
| 70 |
+
</div>
|
| 71 |
+
<div>
|
| 72 |
+
<label for="date">Date</label>
|
| 73 |
+
<input id="date" name="date" type="date" required value={values.date} />
|
| 74 |
+
<FormError message={errors.date} />
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div>
|
| 79 |
+
<label for="paidBy">Paid by</label>
|
| 80 |
+
<select id="paidBy" name="paidBy" value={values.paidBy}>
|
| 81 |
+
{#each members as m (m.id)}
|
| 82 |
+
<option value={m.id}>{m.displayName}</option>
|
| 83 |
+
{/each}
|
| 84 |
+
</select>
|
| 85 |
+
<FormError message={errors.paidBy} />
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<SplitEditor {members} {currency} {values} {amount} error={errors.participants ?? errors.shares} />
|
| 89 |
+
|
| 90 |
+
<div>
|
| 91 |
+
<label for="notes">Notes (optional)</label>
|
| 92 |
+
<textarea id="notes" name="notes" rows="2" maxlength="2000" value={values.notes}></textarea>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div>
|
| 96 |
+
<label for="photo">Receipt photo (optional)</label>
|
| 97 |
+
{#if existingPhoto}
|
| 98 |
+
<img src="/{existingPhoto.key}" alt="Current receipt" class="mb-2 max-h-40 rounded-lg" />
|
| 99 |
+
<label class="mb-2 flex items-center gap-2 font-normal"
|
| 100 |
+
><input type="checkbox" name="removePhoto" class="h-5 w-5" /> Remove current photo</label
|
| 101 |
+
>
|
| 102 |
+
{/if}
|
| 103 |
+
<input id="photo" name="photo" type="file" accept="image/*" />
|
| 104 |
+
<FormError message={errors.photo} />
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<FormError message={errors.form} />
|
| 108 |
+
<button
|
| 109 |
+
type="submit"
|
| 110 |
+
disabled={submitting}
|
| 111 |
+
class="w-full rounded-lg bg-green-700 py-3 font-semibold text-white disabled:opacity-60"
|
| 112 |
+
>
|
| 113 |
+
{submitting ? 'Saving…' : submitLabel}
|
| 114 |
+
</button>
|
| 115 |
+
</form>
|
src/lib/components/SplitEditor.svelte
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { formatCents, parseAmountToCents, parsePercentToBp } from '$lib/domain/money';
|
| 3 |
+
import { allocate } from '$lib/domain/split';
|
| 4 |
+
import type { PublicUser, SplitMode } from '$lib/domain/types';
|
| 5 |
+
import type { ExpenseFormValues } from '$lib/forms/types';
|
| 6 |
+
import FormError from './FormError.svelte';
|
| 7 |
+
|
| 8 |
+
let {
|
| 9 |
+
members,
|
| 10 |
+
currency,
|
| 11 |
+
values,
|
| 12 |
+
amount,
|
| 13 |
+
error
|
| 14 |
+
}: {
|
| 15 |
+
members: PublicUser[];
|
| 16 |
+
currency: string;
|
| 17 |
+
values: ExpenseFormValues;
|
| 18 |
+
amount: string;
|
| 19 |
+
error?: string;
|
| 20 |
+
} = $props();
|
| 21 |
+
|
| 22 |
+
const modes: { value: SplitMode; label: string }[] = [
|
| 23 |
+
{ value: 'equal', label: 'Equally' },
|
| 24 |
+
{ value: 'percent', label: 'By %' },
|
| 25 |
+
{ value: 'exact', label: 'Exact' }
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
let mode = $state<SplitMode>(values.mode);
|
| 29 |
+
let participants = $state<string[]>([...values.participants]);
|
| 30 |
+
let pct = $state<Record<string, string>>({ ...values.pct });
|
| 31 |
+
let exact = $state<Record<string, string>>({ ...values.exact });
|
| 32 |
+
|
| 33 |
+
const amountCents = $derived(parseAmountToCents(amount) ?? 0);
|
| 34 |
+
const selected = $derived(members.filter((m) => participants.includes(m.id)));
|
| 35 |
+
const equalShares = $derived<Record<string, number>>(
|
| 36 |
+
amountCents > 0 && selected.length > 0
|
| 37 |
+
? allocate(
|
| 38 |
+
amountCents,
|
| 39 |
+
selected.map((m) => ({ id: m.id, weight: 1 }))
|
| 40 |
+
)
|
| 41 |
+
: {}
|
| 42 |
+
);
|
| 43 |
+
const pctTotalBp = $derived(
|
| 44 |
+
selected.reduce((acc, m) => acc + (parsePercentToBp(pct[m.id] ?? '') ?? 0), 0)
|
| 45 |
+
);
|
| 46 |
+
const exactTotal = $derived(
|
| 47 |
+
selected.reduce((acc, m) => acc + (parseAmountToCents(exact[m.id] ?? '') ?? 0), 0)
|
| 48 |
+
);
|
| 49 |
+
</script>
|
| 50 |
+
|
| 51 |
+
<fieldset class="space-y-3">
|
| 52 |
+
<legend class="text-sm font-medium text-gray-700">Split</legend>
|
| 53 |
+
|
| 54 |
+
<div class="grid grid-cols-3 gap-1 rounded-lg bg-gray-100 p-1">
|
| 55 |
+
{#each modes as m (m.value)}
|
| 56 |
+
<label
|
| 57 |
+
class="cursor-pointer rounded-md py-2 text-center text-sm {mode === m.value
|
| 58 |
+
? 'bg-white font-semibold shadow'
|
| 59 |
+
: 'text-gray-600'}"
|
| 60 |
+
>
|
| 61 |
+
<input type="radio" name="mode" value={m.value} bind:group={mode} class="sr-only" />
|
| 62 |
+
{m.label}
|
| 63 |
+
</label>
|
| 64 |
+
{/each}
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<ul class="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
| 68 |
+
{#each members as m (m.id)}
|
| 69 |
+
{@const on = participants.includes(m.id)}
|
| 70 |
+
<li class="flex items-center gap-3 px-3 py-2">
|
| 71 |
+
<input
|
| 72 |
+
type="checkbox"
|
| 73 |
+
name="participants"
|
| 74 |
+
value={m.id}
|
| 75 |
+
bind:group={participants}
|
| 76 |
+
id="p_{m.id}"
|
| 77 |
+
class="h-5 w-5 shrink-0"
|
| 78 |
+
/>
|
| 79 |
+
<label for="p_{m.id}" class="flex-1 font-normal {on ? '' : 'text-gray-400'}"
|
| 80 |
+
>{m.displayName}</label
|
| 81 |
+
>
|
| 82 |
+
{#if mode === 'equal'}
|
| 83 |
+
<span class="text-sm text-gray-500 tabular-nums"
|
| 84 |
+
>{on && amountCents > 0 ? formatCents(equalShares[m.id] ?? 0, currency) : ''}</span
|
| 85 |
+
>
|
| 86 |
+
{:else if mode === 'percent'}
|
| 87 |
+
<div class="flex w-28 items-center gap-1">
|
| 88 |
+
<input
|
| 89 |
+
name="pct_{m.id}"
|
| 90 |
+
inputmode="decimal"
|
| 91 |
+
placeholder="0"
|
| 92 |
+
bind:value={pct[m.id]}
|
| 93 |
+
disabled={!on}
|
| 94 |
+
class="text-right"
|
| 95 |
+
aria-label="Percent for {m.displayName}"
|
| 96 |
+
/>
|
| 97 |
+
<span class="text-sm text-gray-500">%</span>
|
| 98 |
+
</div>
|
| 99 |
+
{:else}
|
| 100 |
+
<input
|
| 101 |
+
name="exact_{m.id}"
|
| 102 |
+
inputmode="decimal"
|
| 103 |
+
placeholder="0.00"
|
| 104 |
+
bind:value={exact[m.id]}
|
| 105 |
+
disabled={!on}
|
| 106 |
+
class="w-28 text-right"
|
| 107 |
+
aria-label="Amount for {m.displayName}"
|
| 108 |
+
/>
|
| 109 |
+
{/if}
|
| 110 |
+
</li>
|
| 111 |
+
{/each}
|
| 112 |
+
</ul>
|
| 113 |
+
|
| 114 |
+
{#if mode === 'percent'}
|
| 115 |
+
<p class="text-sm tabular-nums {pctTotalBp === 10000 ? 'text-green-700' : 'text-amber-700'}">
|
| 116 |
+
Total {(pctTotalBp / 100).toFixed(2)}% · {((10000 - pctTotalBp) / 100).toFixed(2)}% remaining
|
| 117 |
+
</p>
|
| 118 |
+
{:else if mode === 'exact'}
|
| 119 |
+
<p
|
| 120 |
+
class="text-sm tabular-nums {exactTotal === amountCents && amountCents > 0
|
| 121 |
+
? 'text-green-700'
|
| 122 |
+
: 'text-amber-700'}"
|
| 123 |
+
>
|
| 124 |
+
Assigned {formatCents(exactTotal, currency)} · {formatCents(
|
| 125 |
+
amountCents - exactTotal,
|
| 126 |
+
currency
|
| 127 |
+
)} remaining
|
| 128 |
+
</p>
|
| 129 |
+
{/if}
|
| 130 |
+
<FormError message={error} />
|
| 131 |
+
</fieldset>
|
src/lib/server/photos.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
|
| 2 |
export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp';
|
| 3 |
|
|
@@ -78,3 +82,23 @@ function u24le(b: Uint8Array, i: number): number {
|
|
| 78 |
function check(width: number, height: number): { width: number; height: number } | null {
|
| 79 |
return width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION ? { width, height } : null;
|
| 80 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ulid } from '../domain/ulid';
|
| 2 |
+
import type { ExpensePhoto } from '../domain/types';
|
| 3 |
+
import type { BucketStore } from './storage/store';
|
| 4 |
+
|
| 5 |
export const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
|
| 6 |
export type ImageType = 'image/jpeg' | 'image/png' | 'image/webp';
|
| 7 |
|
|
|
|
| 82 |
function check(width: number, height: number): { width: number; height: number } | null {
|
| 83 |
return width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION ? { width, height } : null;
|
| 84 |
}
|
| 85 |
+
|
| 86 |
+
/** Returns the uploaded file for `name`, or null when the field is absent/empty. */
|
| 87 |
+
export function uploadedFile(form: FormData, name: string): File | null {
|
| 88 |
+
const f = form.get(name);
|
| 89 |
+
return f instanceof File && f.size > 0 ? f : null;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
export type StoredPhoto = { ok: true; photo: ExpensePhoto } | { ok: false; error: string };
|
| 93 |
+
|
| 94 |
+
/** Validates and stores an uploaded image under photos/<expenseId>/<ulid>.<ext>. */
|
| 95 |
+
export async function storeUploadedPhoto(store: BucketStore, expenseId: string, file: File): Promise<StoredPhoto> {
|
| 96 |
+
if (file.size > MAX_PHOTO_BYTES) return { ok: false, error: 'Photo must be 5 MB or smaller' };
|
| 97 |
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
| 98 |
+
const type = sniffImageType(bytes);
|
| 99 |
+
if (!type) return { ok: false, error: 'Photo must be a JPEG, PNG or WebP image' };
|
| 100 |
+
const key = photoKey(expenseId, ulid(), type);
|
| 101 |
+
await store.put(key, bytes, type);
|
| 102 |
+
const dims = imageDimensions(bytes, type);
|
| 103 |
+
return { ok: true, photo: { key, contentType: type, ...(dims ?? {}) } };
|
| 104 |
+
}
|
src/routes/expenses/new/+page.server.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { toPublicUser } from '$lib/domain/types';
|
| 2 |
+
import { ulid } from '$lib/domain/ulid';
|
| 3 |
+
import { buildExpenseBody, emptyExpenseForm, readExpenseForm } from '$lib/server/forms/expense-form';
|
| 4 |
+
import { today } from '$lib/server/forms/form-utils';
|
| 5 |
+
import { storeUploadedPhoto, uploadedFile } from '$lib/server/photos';
|
| 6 |
+
import { fail, redirect } from '@sveltejs/kit';
|
| 7 |
+
import type { Actions, PageServerLoad } from './$types';
|
| 8 |
+
|
| 9 |
+
export const load: PageServerLoad = ({ locals }) => {
|
| 10 |
+
const members = locals.app.users.activeMembers().map(toPublicUser);
|
| 11 |
+
return {
|
| 12 |
+
members,
|
| 13 |
+
values: emptyExpenseForm(
|
| 14 |
+
locals.user!.id,
|
| 15 |
+
members.map((m) => m.id),
|
| 16 |
+
today()
|
| 17 |
+
)
|
| 18 |
+
};
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export const actions: Actions = {
|
| 22 |
+
default: async ({ request, locals }) => {
|
| 23 |
+
const { users, ledger, store, config } = locals.app;
|
| 24 |
+
const active = users.activeMembers().map((u) => u.id);
|
| 25 |
+
const form = await request.formData();
|
| 26 |
+
const values = readExpenseForm(form, active);
|
| 27 |
+
const built = buildExpenseBody(values, { activeMemberIds: active, currency: config.currency });
|
| 28 |
+
if (!built.ok) return fail(400, { values, errors: built.errors });
|
| 29 |
+
|
| 30 |
+
const expenseId = `e_${ulid()}`;
|
| 31 |
+
const file = uploadedFile(form, 'photo');
|
| 32 |
+
if (file) {
|
| 33 |
+
const saved = await storeUploadedPhoto(store, expenseId, file);
|
| 34 |
+
if (!saved.ok) return fail(400, { values, errors: { photo: saved.error } });
|
| 35 |
+
built.body.photo = saved.photo;
|
| 36 |
+
}
|
| 37 |
+
await ledger.createExpense(locals.user!.id, built.body, expenseId);
|
| 38 |
+
redirect(303, `/expenses/${expenseId}`);
|
| 39 |
+
}
|
| 40 |
+
};
|
src/routes/expenses/new/+page.svelte
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import ExpenseForm from '$lib/components/ExpenseForm.svelte';
|
| 3 |
+
import type { PageProps } from './$types';
|
| 4 |
+
|
| 5 |
+
let { data, form }: PageProps = $props();
|
| 6 |
+
</script>
|
| 7 |
+
|
| 8 |
+
<main class="p-4">
|
| 9 |
+
<header class="mb-4 flex items-center gap-3">
|
| 10 |
+
<a href="/" class="text-gray-500" aria-label="Back">←</a>
|
| 11 |
+
<h1 class="text-2xl font-bold">Add expense</h1>
|
| 12 |
+
</header>
|
| 13 |
+
{#if data.members.length === 0}
|
| 14 |
+
<p class="text-gray-600">No members yet. Ask the admin to add users first.</p>
|
| 15 |
+
{:else}
|
| 16 |
+
<ExpenseForm
|
| 17 |
+
members={data.members}
|
| 18 |
+
currency={data.currency}
|
| 19 |
+
values={form?.values ?? data.values}
|
| 20 |
+
errors={form?.errors ?? {}}
|
| 21 |
+
submitLabel="Save expense"
|
| 22 |
+
/>
|
| 23 |
+
{/if}
|
| 24 |
+
</main>
|