diff options
Diffstat (limited to 'src/routes/groceries')
| -rw-r--r-- | src/routes/groceries/+page.server.ts | 71 | ||||
| -rw-r--r-- | src/routes/groceries/+page.svelte | 207 | ||||
| -rw-r--r-- | src/routes/groceries/catalog/+page.server.ts | 49 | ||||
| -rw-r--r-- | src/routes/groceries/catalog/+page.svelte | 211 | ||||
| -rw-r--r-- | src/routes/groceries/categories/+page.server.ts | 53 | ||||
| -rw-r--r-- | src/routes/groceries/categories/+page.svelte | 164 |
6 files changed, 755 insertions, 0 deletions
diff --git a/src/routes/groceries/+page.server.ts b/src/routes/groceries/+page.server.ts new file mode 100644 index 0000000..e462ab2 --- /dev/null +++ b/src/routes/groceries/+page.server.ts @@ -0,0 +1,71 @@ +import { fail, redirect, error } from '@sveltejs/kit'; +import { + loadUnchecked, + loadAll, + toggle, + addOrUncheck, +} from '$lib/server/services/groceries'; +import { + numericId, + parseFormString, + parseOptionalNumber, +} from '$lib/server/services/validation'; +import type { EnrichedGrocery } from '$lib/server/services/groceries'; +import type { Category } from '$lib/server/services/types'; +import type { Actions, PageServerLoad } from './$types'; + +export interface GroceriesOutput { + items: EnrichedGrocery[]; + categories: Category[]; + catalog: EnrichedGrocery[]; +} + +export const load: PageServerLoad = async () => { + const unchecked = await loadUnchecked(); + const all = await loadAll(); + return { + items: unchecked.items, + categories: unchecked.categories, + catalog: all.items, + }; +}; + +export const actions: Actions = { + add: async ({ request }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + const categoryId = parseOptionalNumber(data.get('category_id')); + + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + + await addOrUncheck(name, categoryId); + const { items } = await loadUnchecked(); + return { success: true, items }; + }, + + toggle: async ({ request }) => { + const data = await request.formData(); + try { + const id = numericId(data.get('id')); + await toggle(id); + } catch { + return fail(400, { error: 'ID invalide' }); + } + return { success: true }; + }, + + addAndEdit: async ({ request }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + const categoryId = parseOptionalNumber(data.get('category_id')); + + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + + const { item, created } = await addOrUncheck(name, categoryId); + throw redirect(303, `/grocery/${item.id}`); + }, +}; diff --git a/src/routes/groceries/+page.svelte b/src/routes/groceries/+page.svelte new file mode 100644 index 0000000..4471738 --- /dev/null +++ b/src/routes/groceries/+page.svelte @@ -0,0 +1,207 @@ +<script lang="ts"> + import { deserialize } from '$app/forms'; + import { goto } from '$app/navigation'; + import { enhance } from '$app/forms'; + import FuzzyAutocomplete from '$lib/components/FuzzyAutocomplete.svelte'; + import type { EnrichedGrocery } from '$lib/server/services/groceries'; + + let { data, form } = $props(); + let name = $state(''); + // eslint-disable-next-line svelte/prefer-writable-derived + let localItems = $state<EnrichedGrocery[]>([]); + let addError = $state(''); + // Sync local list with server data when it changes externally (toggle, navigation) + $effect(() => { + localItems = data?.items ?? []; + }); + + function groupByCategory(items: typeof localItems) { + const groups: Record<string, typeof localItems> = {}; + for (const item of items) { + const key = item.categoryName ?? 'Non catégorisé'; + (groups[key] ??= []).push(item); + } + const result: [string, typeof localItems][] = data.categories + .filter((cat) => groups[cat.name]?.length > 0) + .map((cat) => [cat.name, groups[cat.name]]); + if (groups['Non catégorisé']?.length > 0) { + result.unshift(['Non catégorisé', groups['Non catégorisé']]); + } + return result; + } + + async function handleAdd(redirectToEdit: boolean) { + if (!name.trim()) { + addError = 'Le nom est requis'; + return; + } + addError = ''; + + const formData = new FormData(); + formData.append('name', name); + + const action = redirectToEdit ? '?/addAndEdit' : '?/add'; + name = ''; + + try { + const response = await fetch(action, { + method: 'POST', + headers: { + accept: 'application/json', + 'x-sveltekit-action': 'true', + }, + body: formData, + }); + + const result = deserialize(await response.text()); + + if (result.type === 'error') { + addError = + typeof result.error === 'string' + ? result.error + : result.error?.message || 'Erreur'; + return; + } + + if (result.type === 'redirect') { + goto(result.location); + return; + } + + if (result.type === 'success' && result.data?.items) { + localItems = result.data.items as EnrichedGrocery[]; + } + } catch (err) { + addError = 'Erreur réseau'; + console.error(err); + } + } +</script> + +<div class="add-bar"> + <FuzzyAutocomplete + items={data.catalog} + bind:value={name} + placeholder="Ajouter un article..." + onselect={(item) => { + name = item.name; + handleAdd(false); + }} + /> + <button type="button" aria-label="Ajouter" onclick={() => handleAdd(false)} + >+</button + > + <button type="button" aria-label="Modifier" onclick={() => handleAdd(true)} + >→</button + > +</div> + +<main> + {#if addError || form?.error} + <p class="error">{addError || form?.error}</p> + {/if} + + {#if localItems.length === 0} + <p class="empty">Rien à acheter</p> + {:else} + <form method="POST" action="?/toggle" use:enhance> + {#each groupByCategory(localItems) as [cat, items] (cat)} + <h3 class="category-name">{cat}</h3> + <ul class="item-list"> + {#each items as item (item.id)} + <li class="item-row item-row--fixed"> + <button + class="item-btn item-btn--grow" + name="id" + value={item.id} + type="submit" + aria-pressed={item.checked} + > + <span class="item-text"> + {item.name} + {#if item.bestRef} + <span class="best-ref" + >{item.bestRef.reference} + {#if item.bestRef.price && item.bestRef.unit} + {item.bestRef.price.toFixed(2)} + {item.bestRef.unit} + {/if} + {#if item.bestRef.qualityScore != null} + ({item.bestRef.qualityScore}/100) + {/if} + </span> + {/if} + </span> + </button> + <a class="ref-link" href="/grocery/{item.id}?from=/groceries" + >→</a + > + </li> + {/each} + </ul> + {/each} + </form> + {/if} +</main> + +<style> + .add-bar { + display: flex; + gap: 0.5rem; + padding: 0.5rem 1rem; + position: sticky; + top: var(--nav-height); + background: var(--bg); + border-bottom: 1px solid var(--border); + z-index: 10; + } + + .add-bar button { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + .add-bar > button:nth-of-type(1) { + background: var(--accent); + color: var(--white); + box-shadow: 0 1px 2px var(--shadow); + } + + .add-bar > button:nth-of-type(1):hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .add-bar > button:nth-of-type(2) { + background: var(--surface); + color: var(--text-secondary); + border: 1.5px solid var(--border); + } + + .add-bar > button:nth-of-type(2):hover { + border-color: var(--accent); + color: var(--text); + } + + .error { + margin-bottom: 1rem; + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> diff --git a/src/routes/groceries/catalog/+page.server.ts b/src/routes/groceries/catalog/+page.server.ts new file mode 100644 index 0000000..22464dc --- /dev/null +++ b/src/routes/groceries/catalog/+page.server.ts @@ -0,0 +1,49 @@ +import { fail } from '@sveltejs/kit'; +import { + loadAll, + toggle, + bulkUpdateGroceryCategory, +} from '$lib/server/services/groceries'; +import { + numericId, + parseOptionalNumber, +} from '$lib/server/services/validation'; +import type { EnrichedGrocery } from '$lib/server/services/groceries'; +import type { Category } from '$lib/server/services/types'; +import type { Actions, PageServerLoad } from './$types'; + +export interface CatalogOutput { + items: EnrichedGrocery[]; + categories: Category[]; +} + +export const load: PageServerLoad = async () => { + return loadAll(); +}; + +export const actions: Actions = { + toggle: async ({ request }) => { + const data = await request.formData(); + try { + const id = numericId(data.get('id')); + await toggle(id); + } catch { + return fail(400, { error: 'ID invalide' }); + } + return { success: true }; + }, + + bulkUpdateCategory: async ({ request }) => { + const data = await request.formData(); + const ids = data.getAll('ids').map(numericId); + const rawCategoryId = parseOptionalNumber(data.get('category_id')); + const categoryId = rawCategoryId === -1 ? null : rawCategoryId; + + if (ids.length === 0) { + return fail(400, { error: 'Aucun article sélectionné' }); + } + + await bulkUpdateGroceryCategory(ids, categoryId); + return { success: true, type: 'bulkUpdate' as const }; + }, +}; diff --git a/src/routes/groceries/catalog/+page.svelte b/src/routes/groceries/catalog/+page.svelte new file mode 100644 index 0000000..4cbcc27 --- /dev/null +++ b/src/routes/groceries/catalog/+page.svelte @@ -0,0 +1,211 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import { SvelteSet } from 'svelte/reactivity'; + + let { data, form } = $props(); + + let catalogItems = $derived(data?.items ?? []); + let selectedIds = new SvelteSet<number>(); + let searchQuery = $state(''); + + let filteredItems = $derived( + searchQuery.trim() === '' + ? catalogItems + : catalogItems.filter((item) => + item.name.toLowerCase().includes(searchQuery.toLowerCase()), + ), + ); + + function toggleId(id: number) { + if (selectedIds.has(id)) selectedIds.delete(id); + else selectedIds.add(id); + } + + function groupByCategory(items: typeof catalogItems) { + const groups: Record<string, typeof catalogItems> = {}; + for (const item of items) { + const key = item.categoryName ?? 'Non catégorisé'; + (groups[key] ??= []).push(item); + } + const result: [string, typeof catalogItems][] = []; + for (const cat of data.categories) { + if (groups[cat.name]?.length > 0) { + result.push([cat.name, groups[cat.name]]); + } + } + if (groups['Non catégorisé']?.length > 0) { + result.unshift(['Non catégorisé', groups['Non catégorisé']]); + } + return result; + } +</script> + +{#if catalogItems.length > 0} + <div class="bulk-bar"> + <form + method="POST" + action="?/bulkUpdateCategory" + use:enhance={() => { + return async ({ update }) => { + await update({ reset: false }); + selectedIds.clear(); + }; + }} + > + {#each Array.from(selectedIds) as id (id)} + <input type="hidden" name="ids" value={id} /> + {/each} + <select name="category_id" class="form-select"> + <option value={-1}>Non catégorisé</option> + {#each data.categories as cat (cat.id)} + <option value={cat.id}>{cat.name}</option> + {/each} + </select> + <button + class="btn-primary" + type="submit" + disabled={selectedIds.size === 0} + > + Mettre à jour ({selectedIds.size}) + </button> + </form> + </div> +{/if} + +<main> + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + {#if catalogItems.length === 0} + <p class="empty">Aucun article</p> + {:else} + <input + type="search" + bind:value={searchQuery} + placeholder="Rechercher un article..." + class="search-input" + /> + + {#if filteredItems.length === 0} + <p class="empty">Aucun résultat</p> + {:else} + <form method="POST" action="?/toggle" use:enhance> + {#each groupByCategory(filteredItems) as [cat, items] (cat)} + <h3 class="category-name">{cat}</h3> + <ul class="item-list"> + {#each items as item (item.id)} + <li class="item-row item-row--fixed"> + <input + type="checkbox" + class="item-check" + checked={selectedIds.has(item.id)} + onchange={() => toggleId(item.id)} + /> + <button + class="item-btn item-btn--grow" + name="id" + value={item.id} + type="submit" + aria-pressed={item.checked} + > + <span class="item-text"> + <span class:checked={item.checked}>{item.name}</span> + {#if item.bestRef} + <span class="best-ref" + >{item.bestRef.reference} + {#if item.bestRef.price && item.bestRef.unit} + {item.bestRef.price.toFixed(2)} + {item.bestRef.unit} + {/if} + {#if item.bestRef.qualityScore != null} + ({item.bestRef.qualityScore}/100) + {/if} + </span> + {/if} + </span> + </button> + <a + class="ref-link" + href="/grocery/{item.id}?from=/groceries/catalog">→</a + > + </li> + {/each} + </ul> + {/each} + </form> + {/if} + {/if} +</main> + +<style> + .bulk-bar { + display: flex; + gap: 0.5rem; + padding: 0.5rem 1rem; + position: sticky; + top: var(--nav-height); + background: var(--bg); + border-bottom: 1px solid var(--border); + z-index: 10; + } + + .bulk-bar form { + display: flex; + gap: 0.5rem; + align-items: center; + width: 100%; + } + + .bulk-bar .form-select { + flex: 1; + min-width: 0; + } + + .bulk-bar .btn-primary { + padding: 0.75rem 1rem; + align-self: center; + white-space: nowrap; + } + + .bulk-bar .btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + box-shadow: none; + } + + .item-check { + width: 1.25rem; + height: 1.25rem; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; + } + + .checked { + text-decoration: line-through; + color: var(--text-secondary); + font-weight: 400; + } + + .error { + margin-bottom: 1rem; + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } + + .search-input { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--surface); + color: var(--text); + font-size: 1rem; + margin-top: 1rem; + } +</style> diff --git a/src/routes/groceries/categories/+page.server.ts b/src/routes/groceries/categories/+page.server.ts new file mode 100644 index 0000000..c97b619 --- /dev/null +++ b/src/routes/groceries/categories/+page.server.ts @@ -0,0 +1,53 @@ +import { fail } from '@sveltejs/kit'; +import { + loadAll, + add, + deleteCategory, + reorder, +} from '$lib/server/services/categories'; +import { numericId, parseFormString } from '$lib/server/services/validation'; +import type { Category } from '$lib/server/services/types'; +import type { Actions, PageServerLoad } from './$types'; + +export interface CategoriesOutput { + categories: Category[]; +} + +export const load: PageServerLoad = async () => { + return { categories: await loadAll() }; +}; + +export const actions: Actions = { + add: async ({ request }) => { + const data = await request.formData(); + const name = parseFormString(data.get('name')); + + if (!name) { + return fail(400, { error: 'Le nom est requis' }); + } + + await add(name); + return { success: true }; + }, + + delete: async ({ request }) => { + const data = await request.formData(); + try { + const id = numericId(data.get('id')); + await deleteCategory(id); + } catch { + return fail(400, { error: 'ID invalide' }); + } + return { success: true }; + }, + + reorder: async ({ request }) => { + const data = await request.formData(); + const ids = data.getAll('orderedIds').map(numericId); + if (ids.length === 0) { + return fail(400, { error: 'Aucune catégorie' }); + } + await reorder(ids); + return { success: true }; + }, +}; diff --git a/src/routes/groceries/categories/+page.svelte b/src/routes/groceries/categories/+page.svelte new file mode 100644 index 0000000..267a287 --- /dev/null +++ b/src/routes/groceries/categories/+page.svelte @@ -0,0 +1,164 @@ +<script lang="ts"> + import { enhance } from '$app/forms'; + import type { Category } from '$lib/server/services/types'; + + let { data, form } = $props(); + let name = $state(''); + let draggedIndex = $state<number | null>(null); + // eslint-disable-next-line svelte/prefer-writable-derived + let localCategories = $state<Category[]>([]); + + // Sync with server data on navigation + $effect(() => { + localCategories = data?.categories ?? []; + }); + + let reorderFormRef: HTMLFormElement; + + function handleDragStart(index: number) { + draggedIndex = index; + } + + function handleDragOver(e: DragEvent, index: number) { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === index) return; + + const next = [...localCategories]; + const [moved] = next.splice(draggedIndex, 1); + next.splice(index, 0, moved); + localCategories = next; + draggedIndex = index; + } + + function handleDrop() { + draggedIndex = null; + reorderFormRef?.requestSubmit(); + } +</script> + +<main> + <form class="cat-add-bar" method="POST" action="?/add" use:enhance> + <input + class="form-input" + type="text" + name="name" + bind:value={name} + placeholder="Nouvelle catégorie..." + autocomplete="off" + /> + <button class="btn-accent" type="submit" aria-label="Ajouter">+</button> + </form> + + {#if form?.error} + <p class="error">{form.error}</p> + {/if} + + <form + bind:this={reorderFormRef} + method="POST" + action="?/reorder" + use:enhance={() => { + return async ({ update }) => { + await update({ reset: false }); + }; + }} + > + {#each localCategories as cat (cat.id)} + <input type="hidden" name="orderedIds" value={cat.id} /> + {/each} + </form> + + <form method="POST" action="?/delete" use:enhance> + <ul class="item-list"> + {#each localCategories as cat, index (cat.id)} + <li + class="item-row item-row--card" + draggable="true" + ondragstart={() => handleDragStart(index)} + ondragover={(e) => handleDragOver(e, index)} + ondrop={handleDrop} + ondragend={() => (draggedIndex = null)} + > + <span class="drag-handle">⋮⋮</span> + <span class="cat-name">{cat.name}</span> + <button + class="btn-ghost" + name="id" + value={cat.id} + type="submit" + aria-label="Supprimer" + > + × + </button> + </li> + {/each} + </ul> + </form> + + {#if (localCategories.length ?? 0) === 0} + <p class="empty">Aucune catégorie</p> + {/if} +</main> + +<style> + .cat-name { + flex-grow: 1; + font-weight: 500; + color: var(--text); + } + + .drag-handle { + color: var(--text-secondary); + font-size: 0.875rem; + cursor: grab; + padding-right: 0.5rem; + user-select: none; + } + + .error { + margin-top: -0.25rem; + margin-bottom: 0.5rem; + } + + .cat-add-bar { + display: flex; + gap: 0.5rem; + padding: 0.75rem 0; + } + + .cat-add-bar input { + flex: 1; + background: var(--surface); + } + + .btn-accent { + width: 3rem; + height: 3rem; + flex-shrink: 0; + padding: 0; + border: none; + border-radius: 0.75rem; + background: var(--accent); + color: var(--white); + font-size: 1.25rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow); + } + + .btn-accent:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow-accent-lg); + } + + .empty { + margin-top: 1rem; + padding: 2rem; + } +</style> |
