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(
category: Optional[ProductCategory] = 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)

View file

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

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { PageData } from './$types';
import type { Product } from '$lib/types';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import {
@ -10,21 +11,47 @@
TableHeader,
TableRow
} 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();
const categories = [
type OwnershipFilter = 'all' | 'owned' | 'unowned';
let ownershipFilter = $state<OwnershipFilter>('all');
const CATEGORY_ORDER = [
'cleanser', 'toner', 'essence', 'serum', 'moisturizer',
'spf', 'mask', 'exfoliant', 'hair_treatment', 'tool', 'spot_treatment', 'oil'
];
let selectedCategory = $derived(data.category ?? '');
function filterByCategory(cat: string) {
goto(cat ? `/products?category=${cat}` : '/products');
function isOwned(p: Product): boolean {
return p.inventory?.some(inv => !inv.finished_at) ?? false;
}
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>
<svelte:head><title>Products — innercontext</title></svelte:head>
@ -33,28 +60,21 @@
<div class="flex items-center justify-between">
<div>
<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>
<Button href="/products/new">+ Add product</Button>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-muted-foreground">Filter by category:</span>
<Select
type="single"
value={selectedCategory}
onValueChange={filterByCategory}
>
<SelectTrigger class="w-48">
{selectedCategory ? selectedCategory.replace(/_/g, ' ') : 'All categories'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">All categories</SelectItem>
{#each categories as cat (cat)}
<SelectItem value={cat}>{cat.replace(/_/g, ' ')}</SelectItem>
{/each}
</SelectContent>
</Select>
<div class="flex gap-1">
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
<Button
variant={ownershipFilter === f ? 'default' : 'outline'}
size="sm"
onclick={() => ownershipFilter = f}
>
{f === 'all' ? 'All' : f === 'owned' ? 'Owned' : 'Not owned'}
</Button>
{/each}
</div>
<div class="rounded-md border border-border">
@ -63,42 +83,47 @@
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Category</TableHead>
<TableHead>Targets</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each data.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>
<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}
{#if totalCount === 0}
<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.
</TableCell>
</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>
</Table>
</div>