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:
parent
2691708304
commit
1b1566e6d7
3 changed files with 100 additions and 58 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue