assafvayner HF Staff commited on
Commit
edcd76e
·
1 Parent(s): 5517d3b

feat(ui): add settlement create, detail, edit and delete

Browse files
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>