Residents frontend page (#82)

* create modal component

* refactor app layout and add residents page

* add residents page

* fix story

* fix error

* add resident edit

* allow resident delete

* remove page transitions, use default actions
This commit is contained in:
Baobeld 2025-02-11 17:15:19 -05:00 committed by GitHub
parent 0f95f330ef
commit 40bf33a9e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 415 additions and 39 deletions

View file

@ -2,6 +2,7 @@
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_greeting": "Hello {name}!",
"nav_menu_sms": "SMS",
"nav_menu_residents": "Residents",
"nav_menu_settings": "Settings",
"nav_menu_logout": "Logout",
"login_tab_login": "Login",
@ -21,6 +22,15 @@
"sms_label_phone": "Phone Number",
"sms_label_message": "Message",
"sms_button_submit": "Send Message",
"residents_title": "Residents",
"residents_button_new": "New Resident",
"residents_table_edit": "Edit",
"residents_modal_title_new": "Create a Resident",
"residents_modal_title_edit": "Edit Resident",
"residents_modal_submit": "Submit",
"residents_modal_delete": "Delete",
"residents_modal_label_name": "Name",
"residents_modal_label_phone": "Phone Number",
"settings_title": "Settings",
"settings_category_twilio": "Twilio Config",
"settings_twilio_account_sid": "Account SID",

View file

@ -0,0 +1,36 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import type { ComponentProps } from 'svelte';
import Modal from './Modal.svelte';
import ModalBody from './ModalBody.svelte';
import ModalActions from './ModalActions.svelte';
import Button from '../Button.svelte';
const { Story } = defineMeta({
title: 'Actions/Modal',
component: Modal,
argTypes: {
open: {
control: 'boolean',
defaultValue: false,
},
},
});
let dialog: HTMLDialogElement | undefined = $state(undefined);
</script>
{#snippet template({ children: _, ...props }: Partial<ComponentProps<typeof Modal>>)}
<Button onclick={() => dialog?.showModal()}>Open</Button>
<Modal {...props} backdrop bind:dialog>
<ModalBody>
<h3 class="text-lg font-bold">Hello!</h3>
<p class="py-4">Press ESC key or click the button below to close</p>
<ModalActions>
<Button onclick={() => dialog?.close()}>Close</Button>
</ModalActions>
</ModalBody>
</Modal>
{/snippet}
<Story name="Default" children={template} />

View file

@ -0,0 +1,21 @@
<script lang="ts">
import clsx from 'clsx';
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
type Props = SvelteHTMLElements['dialog'] & {
backdrop?: boolean;
dialog?: HTMLDialogElement;
};
let { backdrop, dialog = $bindable(), children, class: className, ...props }: Props = $props();
</script>
<dialog {...props} class={twMerge('modal', clsx(className))} bind:this={dialog}>
{@render children?.()}
{#if backdrop}
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
{/if}
</dialog>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import clsx from 'clsx';
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
type Props = SvelteHTMLElements['div'];
let { children, class: className, ...props }: Props = $props();
</script>
<div {...props} class={twMerge('modal-action', clsx(className))}>
{@render children?.()}
</div>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import clsx from 'clsx';
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
type Props = SvelteHTMLElements['div'];
let { children, class: className, ...props }: Props = $props();
</script>
<div {...props} class={twMerge('modal-box', clsx(className))}>
{@render children?.()}
</div>

View file

@ -0,0 +1,3 @@
export { default as Modal } from './Modal.svelte';
export { default as ModalActions } from './ModalActions.svelte';
export { default as ModalBody } from './ModalBody.svelte';

View file

@ -1 +1,2 @@
export { default as Button } from './Button.svelte';
export * from './Modal/';

View file

@ -4,7 +4,7 @@
let { start, center, end }: { start?: Snippet; center?: Snippet; end?: Snippet } = $props();
</script>
<header class="navbar justify-between bg-base-200 px-4">
<header class="navbar justify-between rounded-box bg-base-200 px-4">
<div class="navbar-start">{@render start?.()}</div>
<div class="navbar-center">{@render center?.()}</div>
<div class="navbar-end">{@render end?.()}</div>

View file

@ -0,0 +1,28 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import type { ComponentProps } from 'svelte';
import ResidentTable from './ResidentTable.svelte';
const { Story } = defineMeta({
title: 'Residents/Resident Table',
component: ResidentTable,
});
</script>
{#snippet template(props: ComponentProps<typeof ResidentTable>)}
<ResidentTable {...props} />
{/snippet}
<Story
name="Default"
children={template}
args={{
items: [
{
id: '99fc03e9-3ed1-4047-b9ad-0bf2a9025eaa',
name: 'Resident 1',
phoneNumber: '111-1111',
},
],
}}
/>

View file

@ -0,0 +1,53 @@
<script lang="ts" module>
export type ResidentItem = Pick<Resident, 'id' | 'name' | 'phoneNumber'>;
</script>
<script lang="ts">
import type { Resident } from '@prisma/client';
import clsx from 'clsx';
import { UserRoundPen } from 'lucide-svelte';
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
import Button from '../Actions/Button.svelte';
import { messages } from '$lib/i18n';
type Props = {
items?: ResidentItem[];
onEdit?: (resident: ResidentItem) => void;
} & Omit<SvelteHTMLElements['table'], 'children'>;
let { items, onEdit, class: className, ...props }: Props = $props();
</script>
<table {...props} class={twMerge('table', clsx(className))}>
<!-- head -->
<thead>
<tr class="bg-base-100">
<th>#</th>
<th>Name</th>
<th>Phone Number</th>
{#if onEdit}
<th class="flex justify-end"><UserRoundPen class="mx-3" /></th>
{/if}
</tr>
</thead>
<tbody>
<!-- items -->
{#if items}
{#each items as resident, index (resident.id)}
<tr class="hover">
<th>{index + 1}</th>
<td>{resident.name}</td>
<td>{resident.phoneNumber}</td>
{#if onEdit}
<td class="text-end">
<Button size="sm" color="accent" onclick={() => onEdit(resident)}>
{messages.residents_table_edit()}
</Button>
</td>
{/if}
</tr>
{/each}
{/if}
</tbody>
</table>

View file

@ -0,0 +1,2 @@
export { default as ResidentTable } from './ResidentTable.svelte';
export * from './ResidentTable.svelte';

View file

@ -8,16 +8,16 @@
let { children } = $props();
</script>
<div class="layout">
<ParaglideJS {i18n}>
<ClerkProvider publishableKey={PUBLIC_CLERK_PUBLISHABLE_KEY}>
<div class="layout">
{@render children()}
</div>
</ClerkProvider>
</ParaglideJS>
</div>
<style>
.layout {
@apply h-screen w-screen bg-base-100;
@apply h-screen w-screen p-8;
}
</style>

View file

@ -5,14 +5,14 @@
import { messages } from '$lib/i18n';
import 'clerk-sveltekit/client';
import SignOutButton from 'clerk-sveltekit/client/SignOutButton.svelte';
import { Cog, LogOut, MessageCircleMore } from 'lucide-svelte';
import { Cog, LogOut, MessageCircleMore, UsersRound } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import type { LayoutData } from './$types';
type Props = {
children: Snippet;
data: PageData;
data: LayoutData;
};
let { children, data }: Props = $props();
@ -65,7 +65,7 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="menu dropdown-content menu-lg z-[1] mt-4 w-52 rounded-box bg-base-200 p-2 text-right shadow"
class="menu dropdown-content menu-lg z-[1] mt-4 w-52 rounded-box border border-neutral bg-base-200 p-2 text-right shadow"
>
<li>
<button onclick={() => goto('/app/sms')}>
@ -73,6 +73,12 @@
{messages.nav_menu_sms()}
</button>
</li>
<li>
<button onclick={() => goto('/app/residents')}>
<UsersRound />
{messages.nav_menu_residents()}
</button>
</li>
<li>
<button onclick={() => goto('/app/settings')}>
<Cog />
@ -89,9 +95,10 @@
</div>
{/snippet}
<div class="flex h-full flex-col items-stretch gap-4">
<Navbar>
{#snippet start()}
<Button onclick={() => goto('/app')}>
<Button onclick={() => goto('/app')} class="rounded-box" color="ghost">
<h2 class="prose prose-xl">Hestia</h2>
</Button>
{/snippet}
@ -105,4 +112,7 @@
</div>
{/snippet}
</Navbar>
<div class="h-full rounded-box bg-base-200 p-8">
{@render children()}
</div>
</div>

View file

@ -0,0 +1,90 @@
import { PhoneRegex } from '$lib/regex/phone.js';
import { logger } from '$lib/server/logger/index.js';
import { prisma } from '$lib/server/prisma';
import { fail } from '@sveltejs/kit';
import zod from 'zod';
export const load = async ({ locals }) => {
const residents = await prisma.resident.findMany({
where: {
tenantId: locals.tenant.id,
},
select: {
id: true,
name: true,
phoneNumber: true,
},
});
return {
residents: residents,
};
};
export const actions = {
upsert: async (event) => {
const form = await event.request.formData();
if (!form.has('name')) {
return fail(400, { error: 'phone_missing' });
}
if (!form.get('phoneNumber')) {
return fail(400, { error: 'message_missing' });
}
const id = form.get('id');
if (id && typeof id !== 'string') {
return fail(400, { error: 'invalid_id' });
}
const name = form.get('name');
if (typeof name !== 'string') {
return fail(400, { error: 'invalid_name' });
}
const {
success: phoneSuccess,
data: phone,
error: phoneError,
} = zod.string().regex(PhoneRegex).safeParse(form.get('phoneNumber'));
if (!phoneSuccess) {
logger.error(phoneError);
return fail(400, { error: 'invalid_phone' });
}
await prisma.resident.upsert({
where: {
id: id ?? '',
},
create: {
name: name,
phoneNumber: phone,
tenantId: event.locals.tenant.id,
},
update: {
name: name,
phoneNumber: phone,
},
});
},
delete: async (event) => {
const form = await event.request.formData();
logger.info('Deleting Resident');
if (!form.has('id')) {
return fail(400, { error: 'id_missing' });
}
const id = form.get('id');
if (typeof id !== 'string') {
return fail(400, { error: 'invalid_id' });
}
await prisma.resident.delete({
where: {
id: id,
},
});
},
};

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button, Modal, ModalActions, ModalBody } from '$lib/components/Actions';
import { TextInput } from '$lib/components/DataInput';
import { ResidentTable, type ResidentItem } from '$lib/components/Residents';
import { messages } from '$lib/i18n';
import { Phone, UserRound, UserRoundPlus, X } from 'lucide-svelte';
import type { ActionData, PageData } from './$types';
type Props = {
data: PageData;
form: ActionData;
};
let { data }: Props = $props();
let residents = $derived(data.residents);
let dialog: HTMLDialogElement | undefined = $state(undefined);
let form: HTMLFormElement | undefined = $state(undefined);
let resident: ResidentItem | undefined = $state(undefined);
</script>
<Modal
backdrop
bind:dialog
onclose={() => {
resident = undefined;
form?.reset();
}}
>
<ModalBody>
<div class="flex items-center justify-between">
<h2 class="text-2xl">
{resident
? messages.residents_modal_title_edit()
: messages.residents_modal_title_new()}
</h2>
<form method="dialog">
<Button color="ghost" shape="square" size="sm">
<X />
</Button>
</form>
</div>
<form method="POST" action="?/upsert" bind:this={form} use:enhance>
{#if resident}
<input type="hidden" name="id" value={resident.id} />
{/if}
<TextInput bordered name="name" value={resident?.name}>
{#snippet label()}
<UserRound size="18" />
{messages.residents_modal_label_name()}
{/snippet}
</TextInput>
<TextInput bordered name="phoneNumber" type={'tel'} value={resident?.phoneNumber}>
{#snippet label()}
<Phone size="18" />
{messages.residents_modal_label_phone()}
{/snippet}
</TextInput>
<ModalActions class="flex">
<Button
class="grow"
type="submit"
formaction="?/delete"
color="error"
onclick={() => dialog?.close()}
>
{messages.residents_modal_delete()}
</Button>
<Button class="grow" type="submit" color="primary" onclick={() => dialog?.close()}>
{messages.residents_modal_submit()}
</Button>
</ModalActions>
</form>
</ModalBody>
</Modal>
<div class="mx-auto flex max-w-5xl flex-col items-stretch gap-2">
<div class="flex items-center justify-between">
<h1 class="text-4xl">{messages.residents_title()}</h1>
<Button
onclick={() => {
dialog?.showModal();
}}
color="primary"><UserRoundPlus size="18" />{messages.residents_button_new()}</Button
>
</div>
<div class="divider"></div>
<ResidentTable
items={residents}
onEdit={(r) => {
resident = r;
dialog?.showModal();
}}
/>
</div>

View file

@ -27,7 +27,7 @@ export const load = async (event) => {
};
export const actions = {
update: async (event) => {
default: async (event) => {
const form = await event.request.formData();
const tenantId = event.locals.tenant.id;

View file

@ -2,11 +2,10 @@
import { enhance } from '$app/forms';
import { Button } from '$lib/components/Actions';
import { TextInput } from '$lib/components/DataInput';
import Divider from '$lib/components/Layout/Divider.svelte';
import { messages } from '$lib/i18n';
import { Fingerprint, KeyRound, PhoneOutgoing } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ActionData, PageData } from './$types';
import Divider from '$lib/components/Layout/Divider.svelte';
type Props = {
data: PageData;
@ -17,18 +16,18 @@
let configs = $derived(form?.configs ?? data.configs);
</script>
<div class="page" transition:fade>
<div class="page">
<div class="card w-full max-w-xl bg-base-200 px-4 pt-4 shadow-xl">
<div class="card-title justify-center">
<h2 class="text-2xl font-semibold">{messages.settings_title()}</h2>
{#if form?.error}
<div role="alert" class="alert alert-error absolute -top-20" transition:fade>
<div role="alert" class="alert alert-error absolute -top-20">
<i class="fi fi-bs-octagon-xmark h-6 w-6 shrink-0"></i>
<span>{form.error}</span>
</div>
{/if}
</div>
<form id="sms" method="POST" action="?/update" use:enhance>
<form id="sms" method="POST" use:enhance>
<div class="card-body">
<Divider />
<!-- Twilio -->

View file

@ -37,7 +37,7 @@ export const load = async (event) => {
};
export const actions = {
push: async (event) => {
default: async (event) => {
const form = await event.request.formData();
if (!form.has('phone')) {

View file

@ -7,7 +7,6 @@
import { Link } from '$lib/components/Navigation';
import { messages } from '$lib/i18n';
import { CircleX, MessageCircleMore, PhoneOutgoing, TriangleAlert } from 'lucide-svelte';
import { fade } from 'svelte/transition';
import type { ActionData, PageData } from './$types';
type Props = {
@ -25,7 +24,7 @@
<MessageCircleMore size="18" /> {messages.sms_label_message()}
{/snippet}
<div class="page" transition:fade>
<div class="page">
<div class="card bg-base-200 px-4 pt-4 shadow-xl">
{#if form?.error}
<Alert status="error">
@ -49,7 +48,7 @@
<div class="card-title justify-center">
<h2 class="text-2xl font-semibold">{messages.sms_prompt()}</h2>
</div>
<form id="sms" method="POST" action="?/push" use:enhance>
<form id="sms" method="POST" use:enhance>
<div class="card-body">
<TextInput
disabled={!data.isTwilioConfigured}