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(
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue