Spaces:
Sleeping
Sleeping
Commit ·
edcd76e
1
Parent(s): 5517d3b
feat(ui): add settlement create, detail, edit and delete
Browse files- src/lib/components/SettlementForm.svelte +74 -0
- src/routes/settlements/[id]/+page.server.ts +31 -0
- src/routes/settlements/[id]/+page.svelte +56 -0
- src/routes/settlements/[id]/edit/+page.server.ts +49 -0
- src/routes/settlements/[id]/edit/+page.svelte +21 -0
- src/routes/settlements/new/+page.server.ts +25 -0
- src/routes/settlements/new/+page.svelte +20 -0
src/lib/components/SettlementForm.svelte
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { enhance } from '$app/forms';
|
| 3 |
+
import type { PublicUser } from '$lib/domain/types';
|
| 4 |
+
import type { FieldErrors, SettlementField, SettlementFormValues } from '$lib/forms/types';
|
| 5 |
+
import FormError from './FormError.svelte';
|
| 6 |
+
|
| 7 |
+
let {
|
| 8 |
+
members,
|
| 9 |
+
currency,
|
| 10 |
+
values,
|
| 11 |
+
errors = {},
|
| 12 |
+
version,
|
| 13 |
+
submitLabel
|
| 14 |
+
}: {
|
| 15 |
+
members: PublicUser[];
|
| 16 |
+
currency: string;
|
| 17 |
+
values: SettlementFormValues;
|
| 18 |
+
errors?: FieldErrors<SettlementField>;
|
| 19 |
+
version?: number;
|
| 20 |
+
submitLabel: string;
|
| 21 |
+
} = $props();
|
| 22 |
+
</script>
|
| 23 |
+
|
| 24 |
+
<form method="POST" use:enhance class="space-y-4">
|
| 25 |
+
{#if version !== undefined}<input type="hidden" name="version" value={version} />{/if}
|
| 26 |
+
|
| 27 |
+
<div class="grid grid-cols-2 gap-3">
|
| 28 |
+
<div>
|
| 29 |
+
<label for="from">Who paid</label>
|
| 30 |
+
<select id="from" name="from" value={values.from}>
|
| 31 |
+
{#each members as m (m.id)}<option value={m.id}>{m.displayName}</option>{/each}
|
| 32 |
+
</select>
|
| 33 |
+
<FormError message={errors.from} />
|
| 34 |
+
</div>
|
| 35 |
+
<div>
|
| 36 |
+
<label for="to">Who received</label>
|
| 37 |
+
<select id="to" name="to" value={values.to}>
|
| 38 |
+
<option value="">Choose…</option>
|
| 39 |
+
{#each members as m (m.id)}<option value={m.id}>{m.displayName}</option>{/each}
|
| 40 |
+
</select>
|
| 41 |
+
<FormError message={errors.to} />
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div class="grid grid-cols-2 gap-3">
|
| 46 |
+
<div>
|
| 47 |
+
<label for="amount">Amount ({currency})</label>
|
| 48 |
+
<input
|
| 49 |
+
id="amount"
|
| 50 |
+
name="amount"
|
| 51 |
+
inputmode="decimal"
|
| 52 |
+
placeholder="0.00"
|
| 53 |
+
required
|
| 54 |
+
value={values.amount}
|
| 55 |
+
/>
|
| 56 |
+
<FormError message={errors.amount} />
|
| 57 |
+
</div>
|
| 58 |
+
<div>
|
| 59 |
+
<label for="date">Date</label>
|
| 60 |
+
<input id="date" name="date" type="date" required value={values.date} />
|
| 61 |
+
<FormError message={errors.date} />
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div>
|
| 66 |
+
<label for="note">Note (optional)</label>
|
| 67 |
+
<input id="note" name="note" maxlength="2000" value={values.note} />
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<FormError message={errors.form} />
|
| 71 |
+
<button type="submit" class="w-full rounded-lg bg-green-700 py-3 font-semibold text-white"
|
| 72 |
+
>{submitLabel}</button
|
| 73 |
+
>
|
| 74 |
+
</form>
|
src/routes/settlements/[id]/+page.server.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
|
| 2 |
+
import { error, fail, redirect } from '@sveltejs/kit';
|
| 3 |
+
import type { Actions, PageServerLoad } from './$types';
|
| 4 |
+
|
| 5 |
+
export const load: PageServerLoad = ({ params, locals }) => {
|
| 6 |
+
const { ledger, users } = locals.app;
|
| 7 |
+
const settlement = ledger.getSettlement(params.id);
|
| 8 |
+
if (!settlement) error(404, 'Payment not found');
|
| 9 |
+
const name = (id: string) => users.byId(id)?.displayName ?? 'Unknown';
|
| 10 |
+
return {
|
| 11 |
+
settlement,
|
| 12 |
+
fromName: name(settlement.from),
|
| 13 |
+
toName: name(settlement.to),
|
| 14 |
+
revisions: ledger.history(settlement.id).length,
|
| 15 |
+
updatedByName: name(settlement.updatedBy)
|
| 16 |
+
};
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export const actions: Actions = {
|
| 20 |
+
delete: async ({ params, request, locals }) => {
|
| 21 |
+
const version = Number((await request.formData()).get('version'));
|
| 22 |
+
try {
|
| 23 |
+
await locals.app.ledger.deleteSettlement(locals.user!.id, params.id, version);
|
| 24 |
+
} catch (e) {
|
| 25 |
+
if (e instanceof ConflictError) return fail(409, { conflict: true });
|
| 26 |
+
if (e instanceof NotFoundError) redirect(303, '/');
|
| 27 |
+
throw e;
|
| 28 |
+
}
|
| 29 |
+
redirect(303, '/');
|
| 30 |
+
}
|
| 31 |
+
};
|
src/routes/settlements/[id]/+page.svelte
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import { enhance } from '$app/forms';
|
| 3 |
+
import { page } from '$app/state';
|
| 4 |
+
import MoneyText from '$lib/components/MoneyText.svelte';
|
| 5 |
+
import type { PageProps } from './$types';
|
| 6 |
+
|
| 7 |
+
let { data, form }: PageProps = $props();
|
| 8 |
+
const s = $derived(data.settlement);
|
| 9 |
+
const confirming = $derived(page.url.searchParams.has('confirm'));
|
| 10 |
+
</script>
|
| 11 |
+
|
| 12 |
+
<main class="space-y-6 p-4">
|
| 13 |
+
<header class="flex items-center gap-3">
|
| 14 |
+
<a href="/" class="text-gray-500" aria-label="Back">←</a>
|
| 15 |
+
<h1 class="flex-1 text-2xl font-bold">Payment</h1>
|
| 16 |
+
<a
|
| 17 |
+
href="/settlements/{s.id}/edit"
|
| 18 |
+
class="rounded-lg border border-gray-300 px-3 py-1 text-sm">Edit</a
|
| 19 |
+
>
|
| 20 |
+
</header>
|
| 21 |
+
|
| 22 |
+
<section class="rounded-xl bg-white p-4 shadow-sm">
|
| 23 |
+
<MoneyText cents={s.amountCents} currency={data.currency} class="block text-3xl font-bold" />
|
| 24 |
+
<p class="mt-1 text-gray-600">
|
| 25 |
+
<span class="font-medium">{data.fromName}</span> paid
|
| 26 |
+
<span class="font-medium">{data.toName}</span> on {s.date}
|
| 27 |
+
</p>
|
| 28 |
+
{#if s.note}<p class="mt-3 text-gray-800">{s.note}</p>{/if}
|
| 29 |
+
</section>
|
| 30 |
+
|
| 31 |
+
<p class="text-xs text-gray-500">
|
| 32 |
+
{data.revisions > 1 ? `Edited by ${data.updatedByName}, ${data.revisions} revisions` : 'Never edited'}
|
| 33 |
+
</p>
|
| 34 |
+
|
| 35 |
+
<section class="rounded-xl border border-red-200 p-4">
|
| 36 |
+
{#if form?.conflict}
|
| 37 |
+
<p class="mb-2 text-sm text-red-700" role="alert">
|
| 38 |
+
This payment was changed by someone else.
|
| 39 |
+
<a href="/settlements/{s.id}" class="underline" data-sveltekit-reload>Reload</a>.
|
| 40 |
+
</p>
|
| 41 |
+
{/if}
|
| 42 |
+
{#if !confirming}
|
| 43 |
+
<a href="?confirm" class="block text-center text-red-700">Delete payment</a>
|
| 44 |
+
{:else}
|
| 45 |
+
<form method="POST" action="?/delete" use:enhance class="flex gap-2">
|
| 46 |
+
<input type="hidden" name="version" value={s.version} />
|
| 47 |
+
<button type="submit" class="flex-1 rounded-lg bg-red-700 py-2 font-semibold text-white"
|
| 48 |
+
>Confirm delete</button
|
| 49 |
+
>
|
| 50 |
+
<a href="/settlements/{s.id}" class="flex-1 rounded-lg border border-gray-300 py-2 text-center"
|
| 51 |
+
>Cancel</a
|
| 52 |
+
>
|
| 53 |
+
</form>
|
| 54 |
+
{/if}
|
| 55 |
+
</section>
|
| 56 |
+
</main>
|
src/routes/settlements/[id]/edit/+page.server.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { toPublicUser } from '$lib/domain/types';
|
| 2 |
+
import {
|
| 3 |
+
buildSettlementBody,
|
| 4 |
+
readSettlementForm,
|
| 5 |
+
settlementToFormValues
|
| 6 |
+
} from '$lib/server/forms/settlement-form';
|
| 7 |
+
import { ConflictError, NotFoundError } from '$lib/server/ledger/errors';
|
| 8 |
+
import { error, fail, redirect } from '@sveltejs/kit';
|
| 9 |
+
import type { Actions, PageServerLoad } from './$types';
|
| 10 |
+
|
| 11 |
+
export const load: PageServerLoad = ({ params, locals }) => {
|
| 12 |
+
const settlement = locals.app.ledger.getSettlement(params.id);
|
| 13 |
+
if (!settlement) error(404, 'Payment not found');
|
| 14 |
+
return {
|
| 15 |
+
settlement,
|
| 16 |
+
members: locals.app.users.activeMembers().map(toPublicUser),
|
| 17 |
+
values: settlementToFormValues(settlement)
|
| 18 |
+
};
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export const actions: Actions = {
|
| 22 |
+
default: async ({ params, request, locals }) => {
|
| 23 |
+
const active = locals.app.users.activeMembers().map((u) => u.id);
|
| 24 |
+
const form = await request.formData();
|
| 25 |
+
const expectedVersion = Number(form.get('version'));
|
| 26 |
+
const values = readSettlementForm(form);
|
| 27 |
+
const built = buildSettlementBody(values, active);
|
| 28 |
+
if (!built.ok) return fail(400, { values, errors: built.errors });
|
| 29 |
+
try {
|
| 30 |
+
await locals.app.ledger.updateSettlement(
|
| 31 |
+
locals.user!.id,
|
| 32 |
+
params.id,
|
| 33 |
+
built.body,
|
| 34 |
+
expectedVersion
|
| 35 |
+
);
|
| 36 |
+
} catch (e) {
|
| 37 |
+
if (e instanceof ConflictError)
|
| 38 |
+
return fail(409, {
|
| 39 |
+
values,
|
| 40 |
+
errors: {
|
| 41 |
+
form: 'This payment was changed by someone else. Reload the page and re-apply your edit.'
|
| 42 |
+
}
|
| 43 |
+
});
|
| 44 |
+
if (e instanceof NotFoundError) redirect(303, '/');
|
| 45 |
+
throw e;
|
| 46 |
+
}
|
| 47 |
+
redirect(303, `/settlements/${params.id}`);
|
| 48 |
+
}
|
| 49 |
+
};
|
src/routes/settlements/[id]/edit/+page.svelte
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import SettlementForm from '$lib/components/SettlementForm.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="/settlements/{data.settlement.id}" class="text-gray-500" aria-label="Back">←</a>
|
| 11 |
+
<h1 class="text-2xl font-bold">Edit payment</h1>
|
| 12 |
+
</header>
|
| 13 |
+
<SettlementForm
|
| 14 |
+
members={data.members}
|
| 15 |
+
currency={data.currency}
|
| 16 |
+
values={form?.values ?? data.values}
|
| 17 |
+
errors={form?.errors ?? {}}
|
| 18 |
+
version={data.settlement.version}
|
| 19 |
+
submitLabel="Save changes"
|
| 20 |
+
/>
|
| 21 |
+
</main>
|
src/routes/settlements/new/+page.server.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { toPublicUser } from '$lib/domain/types';
|
| 2 |
+
import { today } from '$lib/server/forms/form-utils';
|
| 3 |
+
import {
|
| 4 |
+
buildSettlementBody,
|
| 5 |
+
readSettlementForm,
|
| 6 |
+
settlementFormFromQuery
|
| 7 |
+
} from '$lib/server/forms/settlement-form';
|
| 8 |
+
import { fail, redirect } from '@sveltejs/kit';
|
| 9 |
+
import type { Actions, PageServerLoad } from './$types';
|
| 10 |
+
|
| 11 |
+
export const load: PageServerLoad = ({ locals, url }) => ({
|
| 12 |
+
members: locals.app.users.activeMembers().map(toPublicUser),
|
| 13 |
+
values: settlementFormFromQuery(url.searchParams, locals.user!.id, today())
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
export const actions: Actions = {
|
| 17 |
+
default: async ({ request, locals }) => {
|
| 18 |
+
const active = locals.app.users.activeMembers().map((u) => u.id);
|
| 19 |
+
const values = readSettlementForm(await request.formData());
|
| 20 |
+
const built = buildSettlementBody(values, active);
|
| 21 |
+
if (!built.ok) return fail(400, { values, errors: built.errors });
|
| 22 |
+
const s = await locals.app.ledger.createSettlement(locals.user!.id, built.body);
|
| 23 |
+
redirect(303, `/settlements/${s.id}`);
|
| 24 |
+
}
|
| 25 |
+
};
|
src/routes/settlements/new/+page.svelte
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script lang="ts">
|
| 2 |
+
import SettlementForm from '$lib/components/SettlementForm.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="/balances" class="text-gray-500" aria-label="Back">←</a>
|
| 11 |
+
<h1 class="text-2xl font-bold">Record a payment</h1>
|
| 12 |
+
</header>
|
| 13 |
+
<SettlementForm
|
| 14 |
+
members={data.members}
|
| 15 |
+
currency={data.currency}
|
| 16 |
+
values={form?.values ?? data.values}
|
| 17 |
+
errors={form?.errors ?? {}}
|
| 18 |
+
submitLabel="Save payment"
|
| 19 |
+
/>
|
| 20 |
+
</main>
|