feat(frontend): group products by category with ownership filter

Replace category filter dropdown with client-side grouping and a
3-way ownership toggle (All / Owned / Not owned). Products are grouped
by category with header rows as visual dividers, sorted brand → name
within each group. Category column removed (redundant with headings).

Backend: GET /products now returns ProductWithInventory so inventory
data is available for ownership filtering (bulk-loaded in one query).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-28 23:07:37 +01:00
parent 2691708304
commit 1b1566e6d7
3 changed files with 100 additions and 58 deletions

View file

@ -172,7 +172,7 @@ class InventoryUpdate(SQLModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("", response_model=list[ProductPublic]) @router.get("", response_model=list[ProductWithInventory])
def list_products( def list_products(
category: Optional[ProductCategory] = None, category: Optional[ProductCategory] = None,
brand: Optional[str] = None, brand: Optional[str] = None,
@ -205,7 +205,25 @@ def list_products(
) )
] ]
return products # Bulk-load inventory for all products in one query
product_ids = [p.id for p in products]
inventory_rows = (
session.exec(
select(ProductInventory).where(ProductInventory.product_id.in_(product_ids))
).all()
if product_ids
else []
)
inv_by_product: dict = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
results = []
for p in products:
r = ProductWithInventory.model_validate(p, from_attributes=True)
r.inventory = inv_by_product.get(p.id, [])
results.append(r)
return results
@router.post("", response_model=ProductPublic, status_code=201) @router.post("", response_model=ProductPublic, status_code=201)

View file

@ -1,8 +1,7 @@
import { getProducts } from '$lib/api'; import { getProducts } from '$lib/api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async () => {
const category = url.searchParams.get('category') ?? undefined; const products = await getProducts();
const products = await getProducts({ category }); return { products };
return { products, category };
}; };

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import type { Product } from '$lib/types';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import {
@ -10,21 +11,47 @@
TableHeader, TableHeader,
TableRow TableRow
} from '$lib/components/ui/table'; } from '$lib/components/ui/table';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
import { goto } from '$app/navigation';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const categories = [ type OwnershipFilter = 'all' | 'owned' | 'unowned';
let ownershipFilter = $state<OwnershipFilter>('all');
const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer', 'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil' 'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
]; ];
let selectedCategory = $derived(data.category ?? ''); function isOwned(p: Product): boolean {
return p.inventory?.some(inv => !inv.finished_at) ?? false;
function filterByCategory(cat: string) {
goto(cat ? `/products?category=${cat}` : '/products');
} }
const groupedProducts = $derived((() => {
let items = data.products;
if (ownershipFilter === 'owned') items = items.filter(isOwned);
if (ownershipFilter === 'unowned') items = items.filter(p => !isOwned(p));
items = [...items].sort((a, b) => {
const bc = a.brand.localeCompare(b.brand);
return bc !== 0 ? bc : a.name.localeCompare(b.name);
});
const map = new Map<string, Product[]>();
for (const p of items) {
if (!map.has(p.category)) map.set(p.category, []);
map.get(p.category)!.push(p);
}
return [...map.entries()].sort(([a], [b]) => {
const ai = CATEGORY_ORDER.indexOf(a), bi = CATEGORY_ORDER.indexOf(b);
if (ai === -1 && bi === -1) return a.localeCompare(b);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
})());
const totalCount = $derived(groupedProducts.reduce((s, [, arr]) => s + arr.length, 0));
</script> </script>
<svelte:head><title>Products — innercontext</title></svelte:head> <svelte:head><title>Products — innercontext</title></svelte:head>
@ -33,28 +60,21 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h2 class="text-2xl font-bold tracking-tight">Products</h2> <h2 class="text-2xl font-bold tracking-tight">Products</h2>
<p class="text-muted-foreground">{data.products.length} products</p> <p class="text-muted-foreground">{totalCount} products</p>
</div> </div>
<Button href="/products/new">+ Add product</Button> <Button href="/products/new">+ Add product</Button>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex gap-1">
<span class="text-sm text-muted-foreground">Filter by category:</span> {#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Select <Button
type="single" variant={ownershipFilter === f ? 'default' : 'outline'}
value={selectedCategory} size="sm"
onValueChange={filterByCategory} onclick={() => ownershipFilter = f}
> >
<SelectTrigger class="w-48"> {f === 'all' ? 'All' : f === 'owned' ? 'Owned' : 'Not owned'}
{selectedCategory ? selectedCategory.replace(/_/g, ' ') : 'All categories'} </Button>
</SelectTrigger> {/each}
<SelectContent>
<SelectItem value="">All categories</SelectItem>
{#each categories as cat (cat)}
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
</div> </div>
<div class="rounded-md border border-border"> <div class="rounded-md border border-border">
@ -63,42 +83,47 @@
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Brand</TableHead> <TableHead>Brand</TableHead>
<TableHead>Category</TableHead>
<TableHead>Targets</TableHead> <TableHead>Targets</TableHead>
<TableHead>Time</TableHead> <TableHead>Time</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each data.products as product (product.id)} {#if totalCount === 0}
<TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell>
<a href="/products/{product.id}" class="font-medium hover:underline">
{product.name}
</a>
</TableCell>
<TableCell class="text-muted-foreground">{product.brand}</TableCell>
<TableCell>
<Badge variant="outline">{product.category.replace(/_/g, ' ')}</Badge>
</TableCell>
<TableCell>
<div class="flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t (t)}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each}
{#if product.targets.length > 3}
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
{/if}
</div>
</TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
</TableRow>
{:else}
<TableRow> <TableRow>
<TableCell colspan={5} class="text-center text-muted-foreground py-8"> <TableCell colspan={4} class="text-center text-muted-foreground py-8">
No products found. No products found.
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {:else}
{#each groupedProducts as [category, products] (category)}
<TableRow class="bg-muted/30 hover:bg-muted/30">
<TableCell colspan={4} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
{category.replace(/_/g, ' ')}
</TableCell>
</TableRow>
{#each products as product (product.id)}
<TableRow class="cursor-pointer hover:bg-muted/50">
<TableCell>
<a href="/products/{product.id}" class="font-medium hover:underline">
{product.name}
</a>
</TableCell>
<TableCell class="text-muted-foreground">{product.brand}</TableCell>
<TableCell>
<div class="flex flex-wrap gap-1">
{#each product.targets.slice(0, 3) as t (t)}
<Badge variant="secondary" class="text-xs">{t.replace(/_/g, ' ')}</Badge>
{/each}
{#if product.targets.length > 3}
<span class="text-xs text-muted-foreground">+{product.targets.length - 3}</span>
{/if}
</div>
</TableCell>
<TableCell class="uppercase text-sm">{product.recommended_time}</TableCell>
</TableRow>
{/each}
{/each}
{/if}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>