Add per-store sections to group list items by walk order
Each store gets a StoreSection catalog (Produce, Dairy, etc.). Default sections are seeded on store creation; admins can rename, reorder, add, or delete. ShoppingListItem.SectionId is a nullable FK that sets to null when the section is deleted, so items survive section churn. The list detail view groups items by section in walk order, with Uncategorized appended last. Section dropdowns on each row (and the add-item form) let users assign or reassign on the fly. SignalR broadcasts include sectionId on adds and a new ItemSectionChanged event for live re-grouping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,20 +13,32 @@
|
||||
isChecked: boolean;
|
||||
checkedByUserName: string | null;
|
||||
sortOrder: number;
|
||||
sectionId: number | null;
|
||||
recipeTitle: string | null;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: number;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface ShoppingListDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
store: { id: number; name: string };
|
||||
isArchived: boolean;
|
||||
sections: Section[];
|
||||
items: ListItem[];
|
||||
}
|
||||
|
||||
const UNCATEGORIZED_KEY = -1;
|
||||
|
||||
let list = $state<ShoppingListDetail | null>(null);
|
||||
let items = $state<ListItem[]>([]);
|
||||
let sections = $state<Section[]>([]);
|
||||
let newItemName = $state('');
|
||||
let newItemSectionId = $state<number | null>(null);
|
||||
let loading = $state(true);
|
||||
let connection: HubConnection | null = null;
|
||||
|
||||
@@ -34,16 +46,46 @@
|
||||
const uncheckedItems = $derived(items.filter((i) => !i.isChecked));
|
||||
const checkedItems = $derived(items.filter((i) => i.isChecked));
|
||||
|
||||
// Order: sections by SortOrder, with uncategorized bucket appended last.
|
||||
const orderedSectionKeys = $derived([
|
||||
...[...sections].sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((s) => s.id),
|
||||
UNCATEGORIZED_KEY,
|
||||
]);
|
||||
|
||||
const uncheckedGroups = $derived(groupBySection(uncheckedItems));
|
||||
const checkedGroups = $derived(groupBySection(checkedItems));
|
||||
|
||||
function groupBySection(source: ListItem[]) {
|
||||
const buckets = new Map<number, ListItem[]>();
|
||||
for (const item of source) {
|
||||
const key = item.sectionId ?? UNCATEGORIZED_KEY;
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) bucket.push(item);
|
||||
else buckets.set(key, [item]);
|
||||
}
|
||||
return orderedSectionKeys
|
||||
.map((key) => ({
|
||||
key,
|
||||
name:
|
||||
key === UNCATEGORIZED_KEY
|
||||
? 'Uncategorized'
|
||||
: sections.find((s) => s.id === key)?.name ?? 'Uncategorized',
|
||||
items: (buckets.get(key) ?? []).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
}))
|
||||
.filter((g) => g.items.length > 0);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
const data = await api<ShoppingListDetail>(`/api/lists/${listId}`);
|
||||
list = data;
|
||||
items = data.items;
|
||||
sections = data.sections;
|
||||
loading = false;
|
||||
|
||||
connection = await startConnection();
|
||||
await connection.invoke('JoinList', listId);
|
||||
|
||||
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; recipeTitle?: string }) => {
|
||||
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => {
|
||||
if (!items.find((i) => i.id === data.id)) {
|
||||
items = [
|
||||
...items,
|
||||
@@ -53,6 +95,7 @@
|
||||
isChecked: false,
|
||||
checkedByUserName: null,
|
||||
sortOrder: data.sortOrder,
|
||||
sectionId: data.sectionId ?? null,
|
||||
recipeTitle: data.recipeTitle ?? null
|
||||
}
|
||||
];
|
||||
@@ -67,20 +110,17 @@
|
||||
);
|
||||
});
|
||||
|
||||
connection.on('ItemSectionChanged', (data: { id: number; sectionId: number | null }) => {
|
||||
items = items.map((i) => (i.id === data.id ? { ...i, sectionId: data.sectionId } : i));
|
||||
});
|
||||
|
||||
connection.on('ItemRemoved', (data: { id: number }) => {
|
||||
items = items.filter((i) => i.id !== data.id);
|
||||
});
|
||||
|
||||
connection.on(
|
||||
'ItemRestored',
|
||||
(data: {
|
||||
id: number;
|
||||
name: string;
|
||||
isChecked: boolean;
|
||||
checkedByUserName: string | null;
|
||||
sortOrder: number;
|
||||
recipeTitle: string | null;
|
||||
}) => {
|
||||
(data: ListItem) => {
|
||||
if (!items.find((i) => i.id === data.id)) {
|
||||
items = [...items, data];
|
||||
}
|
||||
@@ -106,7 +146,11 @@
|
||||
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
|
||||
await api('/api/lists/' + listId + '/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newItemName, sortOrder: maxSort + 1 })
|
||||
body: JSON.stringify({
|
||||
name: newItemName,
|
||||
sortOrder: maxSort + 1,
|
||||
sectionId: newItemSectionId
|
||||
})
|
||||
});
|
||||
newItemName = '';
|
||||
}
|
||||
@@ -115,6 +159,19 @@
|
||||
await api(`/api/lists/${listId}/items/${itemId}/check`, { method: 'PATCH' });
|
||||
}
|
||||
|
||||
async function setItemSection(itemId: number, sectionId: number | null) {
|
||||
// Optimistic — the SignalR ItemSectionChanged echo will reconcile.
|
||||
items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i));
|
||||
try {
|
||||
await api(`/api/lists/${listId}/items/${itemId}/section`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ sectionId })
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to update section');
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(itemId: number, itemName: string) {
|
||||
await api(`/api/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
toast.info(`Removed "${itemName}"`, {
|
||||
@@ -152,13 +209,25 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex gap-2">
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newItemName}
|
||||
placeholder="Add an item..."
|
||||
class="flex-1 rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
|
||||
class="min-w-0 flex-1 rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{#if sections.length > 0}
|
||||
<select
|
||||
bind:value={newItemSectionId}
|
||||
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||
aria-label="Section"
|
||||
>
|
||||
<option value={null}>Uncategorized</option>
|
||||
{#each sections as section (section.id)}
|
||||
<option value={section.id}>{section.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
||||
@@ -167,31 +236,53 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if uncheckedItems.length > 0}
|
||||
<ul class="space-y-1">
|
||||
{#each uncheckedItems as item (item.id)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-gray-300"
|
||||
aria-label="Check {item.name}"
|
||||
></button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="text-base">{item.name}</span>
|
||||
{#if item.recipeTitle}
|
||||
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id, item.name)}
|
||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
{#if uncheckedGroups.length > 0}
|
||||
<div class="space-y-4">
|
||||
{#each uncheckedGroups as group (group.key)}
|
||||
<section>
|
||||
<h3 class="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
{group.name}
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each group.items as item (item.id)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-gray-300"
|
||||
aria-label="Check {item.name}"
|
||||
></button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="text-base">{item.name}</span>
|
||||
{#if item.recipeTitle}
|
||||
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sections.length > 0}
|
||||
<select
|
||||
value={item.sectionId ?? ''}
|
||||
onchange={(e) => setItemSection(item.id, (e.currentTarget.value === '' ? null : Number(e.currentTarget.value)))}
|
||||
class="shrink-0 rounded border border-gray-200 bg-white px-1 py-0.5 text-xs text-gray-500 focus:border-primary focus:outline-none"
|
||||
aria-label="Section for {item.name}"
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{#each sections as section (section.id)}
|
||||
<option value={section.id}>{section.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => removeItem(item.id, item.name)}
|
||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else if checkedItems.length === 0}
|
||||
<p class="py-8 text-center text-gray-400">No items yet — add some above</p>
|
||||
{/if}
|
||||
@@ -201,32 +292,41 @@
|
||||
<h3 class="mb-2 text-sm font-medium text-gray-400">
|
||||
Checked ({checkedItems.length})
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each checkedItems as item (item.id)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary text-white"
|
||||
aria-label="Uncheck {item.name}"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
||||
{#if item.checkedByUserName}
|
||||
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id, item.name)}
|
||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
<div class="space-y-3">
|
||||
{#each checkedGroups as group (group.key)}
|
||||
<section>
|
||||
<h4 class="mb-1 px-1 text-xs font-medium uppercase tracking-wide text-gray-300">
|
||||
{group.name}
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each group.items as item (item.id)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 border-primary bg-primary text-white"
|
||||
aria-label="Uncheck {item.name}"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
||||
{#if item.checkedByUserName}
|
||||
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
onclick={() => removeItem(item.id, item.name)}
|
||||
class="shrink-0 p-1 text-gray-300 active:text-danger"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
id: number;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
let stores = $state<Store[]>([]);
|
||||
let newName = $state('');
|
||||
let editingId = $state<number | null>(null);
|
||||
@@ -16,6 +22,13 @@
|
||||
let loading = $state(true);
|
||||
let pendingDelete = $state<Store | null>(null);
|
||||
|
||||
let expandedStoreId = $state<number | null>(null);
|
||||
let sectionsByStore = $state<Record<number, Section[]>>({});
|
||||
let sectionsLoading = $state<number | null>(null);
|
||||
let newSectionName = $state('');
|
||||
let editingSectionId = $state<number | null>(null);
|
||||
let editSectionName = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
stores = await api<Store[]>('/api/stores');
|
||||
loading = false;
|
||||
@@ -70,6 +83,72 @@
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to delete store');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSections(storeId: number) {
|
||||
if (expandedStoreId === storeId) {
|
||||
expandedStoreId = null;
|
||||
return;
|
||||
}
|
||||
expandedStoreId = storeId;
|
||||
editingSectionId = null;
|
||||
newSectionName = '';
|
||||
if (!sectionsByStore[storeId]) {
|
||||
sectionsLoading = storeId;
|
||||
try {
|
||||
sectionsByStore[storeId] = await api<Section[]>(`/api/stores/${storeId}/sections`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to load sections');
|
||||
} finally {
|
||||
sectionsLoading = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addSection(storeId: number) {
|
||||
if (!newSectionName.trim()) return;
|
||||
const existing = sectionsByStore[storeId] ?? [];
|
||||
try {
|
||||
const created = await api<Section>(`/api/stores/${storeId}/sections`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newSectionName, sortOrder: existing.length })
|
||||
});
|
||||
sectionsByStore[storeId] = [...existing, created];
|
||||
newSectionName = '';
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to add section');
|
||||
}
|
||||
}
|
||||
|
||||
function startEditSection(section: Section) {
|
||||
editingSectionId = section.id;
|
||||
editSectionName = section.name;
|
||||
}
|
||||
|
||||
async function saveSectionEdit(storeId: number) {
|
||||
if (!editSectionName.trim() || !editingSectionId) return;
|
||||
const section = sectionsByStore[storeId].find((s) => s.id === editingSectionId)!;
|
||||
try {
|
||||
const updated = await api<Section>(`/api/stores/${storeId}/sections/${editingSectionId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: editSectionName, sortOrder: section.sortOrder })
|
||||
});
|
||||
sectionsByStore[storeId] = sectionsByStore[storeId].map((s) =>
|
||||
s.id === updated.id ? updated : s
|
||||
);
|
||||
editingSectionId = null;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to update section');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSection(storeId: number, sectionId: number) {
|
||||
try {
|
||||
await api(`/api/stores/${storeId}/sections/${sectionId}`, { method: 'DELETE' });
|
||||
sectionsByStore[storeId] = sectionsByStore[storeId].filter((s) => s.id !== sectionId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to delete section');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -95,21 +174,79 @@
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each stores as store (store.id)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
|
||||
{#if editingId === store.id}
|
||||
<form onsubmit={e => { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="text-sm font-medium text-primary">Save</button>
|
||||
<button type="button" onclick={() => (editingId = null)} class="text-sm text-gray-400">Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="flex-1 font-medium">{store.name}</span>
|
||||
<button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button>
|
||||
<button onclick={() => requestDelete(store)} class="text-sm text-danger">Delete</button>
|
||||
<li class="rounded-lg bg-white shadow-sm">
|
||||
<div class="flex items-center gap-3 px-4 py-3">
|
||||
{#if editingId === store.id}
|
||||
<form onsubmit={e => { e.preventDefault(); saveEdit(); }} class="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="text-sm font-medium text-primary">Save</button>
|
||||
<button type="button" onclick={() => (editingId = null)} class="text-sm text-gray-400">Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleSections(store.id)}
|
||||
class="flex-1 text-left font-medium"
|
||||
aria-expanded={expandedStoreId === store.id}
|
||||
>
|
||||
<span class="mr-2 inline-block w-3 text-gray-400">{expandedStoreId === store.id ? '▾' : '▸'}</span>
|
||||
{store.name}
|
||||
</button>
|
||||
<button onclick={() => startEdit(store)} class="text-sm text-gray-400">Edit</button>
|
||||
<button onclick={() => requestDelete(store)} class="text-sm text-danger">Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if expandedStoreId === store.id}
|
||||
<div class="border-t border-gray-100 px-4 py-3">
|
||||
<h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Sections</h3>
|
||||
|
||||
{#if sectionsLoading === store.id}
|
||||
<p class="py-2 text-sm text-gray-400">Loading...</p>
|
||||
{:else}
|
||||
{#if (sectionsByStore[store.id] ?? []).length === 0}
|
||||
<p class="mb-3 text-sm text-gray-400">No sections — add one below</p>
|
||||
{:else}
|
||||
<ul class="mb-3 space-y-1">
|
||||
{#each sectionsByStore[store.id] as section (section.id)}
|
||||
<li class="flex items-center gap-2 rounded bg-gray-50 px-3 py-2 text-sm">
|
||||
{#if editingSectionId === section.id}
|
||||
<form onsubmit={e => { e.preventDefault(); saveSectionEdit(store.id); }} class="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editSectionName}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="font-medium text-primary">Save</button>
|
||||
<button type="button" onclick={() => (editingSectionId = null)} class="text-gray-400">Cancel</button>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="flex-1">{section.name}</span>
|
||||
<button onclick={() => startEditSection(section)} class="text-gray-400">Edit</button>
|
||||
<button onclick={() => deleteSection(store.id, section.id)} class="text-danger">Delete</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={e => { e.preventDefault(); addSection(store.id); }} class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newSectionName}
|
||||
placeholder="New section"
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<button type="submit" class="rounded bg-primary px-3 py-1.5 text-sm font-semibold text-white">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user