Add product catalog management page
Surfaces the family-scoped product catalog: search, add a family product,
edit a global catalog entry (writes a FamilyProductOverride), and reset
or delete. Adds an "Edited" badge in both the catalog list and the
shopping-list typeahead so overridden entries are distinguishable from
their global defaults. Backend gains DELETE /family/{id} and
DELETE /global/{id}/override to support the new flows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,48 @@ public static class ProductEndpoints
|
|||||||
return Results.Ok(ToDto(product));
|
return Results.Ok(ToDto(product));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Remove a family-owned product. ShoppingListItem / RecipeIngredient
|
||||||
|
// FKs are configured ON DELETE SET NULL, and ProductStoreSection rows
|
||||||
|
// cascade — so deletion is safe; previously-linked rows just lose the
|
||||||
|
// product link and continue to render their free-form Name.
|
||||||
|
group.MapDelete("/family/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var product = await db.FamilyProducts.FirstOrDefaultAsync(p => p.Id == id && p.FamilyId == familyId);
|
||||||
|
if (product is null) return Results.NotFound();
|
||||||
|
db.FamilyProducts.Remove(product);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset a family's view of a global product to the catalog default by
|
||||||
|
// deleting any FamilyProductOverride row. Returns the now-unmodified
|
||||||
|
// effective DTO so the client can swap state without a refetch. No-op
|
||||||
|
// (still 200) when there's nothing to reset — keeps the UI idempotent.
|
||||||
|
group.MapDelete("/global/{id:int}/override", async (int id, YesChefDb db, HttpContext http) =>
|
||||||
|
{
|
||||||
|
var familyId = http.User.GetFamilyId();
|
||||||
|
var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
|
||||||
|
if (product is null) return Results.NotFound();
|
||||||
|
|
||||||
|
var ovr = await db.FamilyProductOverrides
|
||||||
|
.FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == id);
|
||||||
|
if (ovr is not null)
|
||||||
|
{
|
||||||
|
db.FamilyProductOverrides.Remove(ovr);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new ProductDto(
|
||||||
|
product.Id,
|
||||||
|
ProductKind.Global,
|
||||||
|
product.Name,
|
||||||
|
product.Brand,
|
||||||
|
product.Notes,
|
||||||
|
IsOverridden: false,
|
||||||
|
product.AllowedUnitCategories));
|
||||||
|
});
|
||||||
|
|
||||||
// Update a global product for this family by upserting an override.
|
// Update a global product for this family by upserting an override.
|
||||||
// Each non-null field becomes the override; null fields fall back to
|
// Each non-null field becomes the override; null fields fall back to
|
||||||
// the global value. To "reset" a field to the global, send null —
|
// the global value. To "reset" a field to the global, send null —
|
||||||
|
|||||||
@@ -183,6 +183,12 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{suggestion.name}</span>
|
<span>{suggestion.name}</span>
|
||||||
|
{#if suggestion.isOverridden}
|
||||||
|
<span
|
||||||
|
class="ml-2 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800"
|
||||||
|
title="Edited by your family">Edited</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
{#if suggestion.brand}
|
{#if suggestion.brand}
|
||||||
<span class="ml-2 text-xs text-gray-400">{suggestion.brand}</span>
|
<span class="ml-2 text-xs text-gray-400">{suggestion.brand}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -165,10 +165,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="mt-6 border-t border-gray-100 pt-4">
|
<div class="mt-6 space-y-3 border-t border-gray-100 pt-4">
|
||||||
<a href="/stores" class="flex items-center justify-between text-sm text-gray-500">
|
<a href="/stores" class="flex items-center justify-between text-sm text-gray-500">
|
||||||
<span>Manage stores</span>
|
<span>Manage stores</span>
|
||||||
<span>›</span>
|
<span>›</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/products" class="flex items-center justify-between text-sm text-gray-500">
|
||||||
|
<span>Manage products</span>
|
||||||
|
<span>›</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import { UnitCategoryFlag, type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||||
|
|
||||||
|
let products = $state<ProductSuggestion[]>([]);
|
||||||
|
let query = $state('');
|
||||||
|
let loading = $state(true);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
// Increment per request; only the most recent response wins. Same pattern as the typeahead.
|
||||||
|
let requestSeq = 0;
|
||||||
|
|
||||||
|
// Form modal — used for both add (editingProduct === null) and edit.
|
||||||
|
let formOpen = $state(false);
|
||||||
|
let editingProduct = $state<ProductSuggestion | null>(null);
|
||||||
|
let formName = $state('');
|
||||||
|
let formBrand = $state('');
|
||||||
|
let formNotes = $state('');
|
||||||
|
// Bitfield matching the backend UnitCategoryFlags. 0 = "any unit allowed".
|
||||||
|
let formCategories = $state(0);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// Confirmation modals — rendered after the form closes so we only ever show one dialog at a time.
|
||||||
|
let pendingResetGlobal = $state<ProductSuggestion | null>(null);
|
||||||
|
let pendingDeleteFamily = $state<ProductSuggestion | null>(null);
|
||||||
|
|
||||||
|
const categoryOptions = [
|
||||||
|
{ flag: UnitCategoryFlag.Count, label: 'Count' },
|
||||||
|
{ flag: UnitCategoryFlag.Weight, label: 'Weight' },
|
||||||
|
{ flag: UnitCategoryFlag.Volume, label: 'Volume' },
|
||||||
|
{ flag: UnitCategoryFlag.Packaging, label: 'Packaging' }
|
||||||
|
];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void search('');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function search(q: string) {
|
||||||
|
const seq = ++requestSeq;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const results = await api<ProductSuggestion[]>(
|
||||||
|
`/api/products?q=${encodeURIComponent(q)}`
|
||||||
|
);
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
|
products = results;
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== requestSeq) return;
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to load products');
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq) loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueryInput() {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => void search(query.trim()), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
editingProduct = null;
|
||||||
|
formName = '';
|
||||||
|
formBrand = '';
|
||||||
|
formNotes = '';
|
||||||
|
formCategories = 0;
|
||||||
|
formOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(p: ProductSuggestion) {
|
||||||
|
editingProduct = p;
|
||||||
|
formName = p.name;
|
||||||
|
formBrand = p.brand ?? '';
|
||||||
|
formNotes = p.notes ?? '';
|
||||||
|
formCategories = p.allowedUnitCategories;
|
||||||
|
formOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCategory(flag: number) {
|
||||||
|
formCategories ^= flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
formOpen = false;
|
||||||
|
editingProduct = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm() {
|
||||||
|
const name = formName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
brand: formBrand.trim() || null,
|
||||||
|
notes: formNotes.trim() || null,
|
||||||
|
allowedUnitCategories: formCategories
|
||||||
|
};
|
||||||
|
if (editingProduct === null) {
|
||||||
|
await api('/api/products', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
} else if (editingProduct.kind === 'Family') {
|
||||||
|
await api(`/api/products/family/${editingProduct.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api(`/api/products/global/${editingProduct.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
await search(query.trim());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to save product');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestResetGlobal() {
|
||||||
|
if (editingProduct?.kind !== 'Global') return;
|
||||||
|
pendingResetGlobal = editingProduct;
|
||||||
|
closeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteFamily() {
|
||||||
|
if (editingProduct?.kind !== 'Family') return;
|
||||||
|
pendingDeleteFamily = editingProduct;
|
||||||
|
closeForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmResetGlobal() {
|
||||||
|
if (!pendingResetGlobal) return;
|
||||||
|
const id = pendingResetGlobal.id;
|
||||||
|
pendingResetGlobal = null;
|
||||||
|
try {
|
||||||
|
await api(`/api/products/global/${id}/override`, { method: 'DELETE' });
|
||||||
|
await search(query.trim());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to reset product');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteFamily() {
|
||||||
|
if (!pendingDeleteFamily) return;
|
||||||
|
const id = pendingDeleteFamily.id;
|
||||||
|
pendingDeleteFamily = null;
|
||||||
|
try {
|
||||||
|
await api(`/api/products/family/${id}`, { method: 'DELETE' });
|
||||||
|
await search(query.trim());
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Failed to delete product');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/lists" class="text-sm text-gray-500">← Back to lists</a>
|
||||||
|
<h2 class="mt-1 text-2xl font-bold">Products</h2>
|
||||||
|
<p class="text-sm text-gray-500">Search the catalog, add your own, or edit how a catalog item shows up for your family.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={query}
|
||||||
|
oninput={onQueryInput}
|
||||||
|
placeholder="Search products"
|
||||||
|
aria-label="Search products"
|
||||||
|
class="field flex-1 py-2.5"
|
||||||
|
/>
|
||||||
|
<button type="button" onclick={startAdd} class="btn-primary py-2.5">Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-8 text-center text-gray-400">Loading...</p>
|
||||||
|
{:else if products.length === 0}
|
||||||
|
<p class="py-12 text-center text-gray-400">
|
||||||
|
{query.trim() ? 'No products match' : 'No products in catalog yet'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each products as product (product.kind + product.id)}
|
||||||
|
<li class="flex items-center gap-3 rounded-lg bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="truncate font-medium">{product.name}</span>
|
||||||
|
{#if product.isOverridden}
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||||
|
title="Edited by your family">Edited</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if product.kind === 'Family'}
|
||||||
|
<span
|
||||||
|
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600"
|
||||||
|
title="Family-only product (not in the global catalog)">Yours</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if product.brand}
|
||||||
|
<div class="text-xs text-gray-400">{product.brand}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button onclick={() => startEdit(product)} class="text-sm text-gray-400">Edit</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if formOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="product-form-title"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||||
|
<h3 id="product-form-title" class="mb-1 text-lg font-semibold">
|
||||||
|
{editingProduct === null
|
||||||
|
? 'Add product'
|
||||||
|
: editingProduct.kind === 'Family'
|
||||||
|
? 'Edit product'
|
||||||
|
: 'Edit catalog product'}
|
||||||
|
</h3>
|
||||||
|
{#if editingProduct?.kind === 'Global'}
|
||||||
|
<p class="mb-3 text-xs text-gray-500">
|
||||||
|
Changes save as a family-only override. Other families still see the catalog defaults.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveForm();
|
||||||
|
}}
|
||||||
|
class="space-y-3"
|
||||||
|
>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700">Name</span>
|
||||||
|
<input type="text" bind:value={formName} required class="field mt-1 w-full" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700"
|
||||||
|
>Brand <span class="text-gray-400">(optional)</span></span
|
||||||
|
>
|
||||||
|
<input type="text" bind:value={formBrand} class="field mt-1 w-full" />
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-xs font-medium text-gray-700"
|
||||||
|
>Notes <span class="text-gray-400">(optional)</span></span
|
||||||
|
>
|
||||||
|
<textarea bind:value={formNotes} rows="2" class="field mt-1 w-full"></textarea>
|
||||||
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend class="text-xs font-medium text-gray-700">Allowed units</legend>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-400">Leave all unchecked to allow any unit.</p>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-1.5">
|
||||||
|
{#each categoryOptions as opt}
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(formCategories & opt.flag) !== 0}
|
||||||
|
onchange={() => toggleCategory(opt.flag)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onclick={closeForm} class="btn-secondary">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !formName.trim()}
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if editingProduct?.kind === 'Global' && editingProduct.isOverridden}
|
||||||
|
<div class="mt-2 border-t border-gray-100 pt-2 text-right">
|
||||||
|
<button type="button" onclick={requestResetGlobal} class="btn-danger-link">
|
||||||
|
Reset to catalog default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if editingProduct?.kind === 'Family'}
|
||||||
|
<div class="mt-2 border-t border-gray-100 pt-2 text-right">
|
||||||
|
<button type="button" onclick={requestDeleteFamily} class="btn-danger-link">
|
||||||
|
Delete product
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingResetGlobal}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="reset-product-title"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||||
|
<h3 id="reset-product-title" class="mb-2 text-lg font-semibold">Reset to catalog default?</h3>
|
||||||
|
<p class="mb-5 text-sm text-gray-600">
|
||||||
|
<span class="font-medium">{pendingResetGlobal.name}</span> will go back to its catalog
|
||||||
|
values for your family. Your edits will be discarded.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (pendingResetGlobal = null)}
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick={confirmResetGlobal} class="btn-primary">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingDeleteFamily}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="delete-product-title"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
|
||||||
|
<h3 id="delete-product-title" class="mb-2 text-lg font-semibold">Delete product?</h3>
|
||||||
|
<p class="mb-5 text-sm text-gray-600">
|
||||||
|
Delete <span class="font-medium">{pendingDeleteFamily.name}</span>? Any list items or
|
||||||
|
ingredients linked to it will keep their text but lose the link.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (pendingDeleteFamily = null)}
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={confirmDeleteFamily}
|
||||||
|
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user