feat: add full/empty weight fields to Product and last_weighed_at to ProductInventory

Adds full_weight_g and empty_weight_g to ProductBase (inherited by Product
and response models) so per-product package weight specs are captured.
Adds last_weighed_at to ProductInventory to record when a package was last
weighed. Wires up all fields through API schemas, frontend types, forms, and
the product detail page (add/edit/display).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Piotr Oleszczyk 2026-02-27 16:35:08 +01:00
parent 2b73dc63ac
commit 43fcba4de6
8 changed files with 255 additions and 16 deletions

View file

@ -69,6 +69,9 @@ export const createInventory = (
productId: string,
body: Record<string, unknown>
): Promise<ProductInventory> => api.post(`/products/${productId}/inventory`, body);
export const updateInventory = (id: string, body: Record<string, unknown>): Promise<ProductInventory> =>
api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> => api.del(`/inventory/${id}`);
// ─── Routines ────────────────────────────────────────────────────────────────

View file

@ -668,6 +668,32 @@
/>
</div>
<div class="space-y-2">
<Label for="full_weight_g">Full weight (g)</Label>
<Input
id="full_weight_g"
name="full_weight_g"
type="number"
min="0"
step="0.1"
placeholder="e.g. 120"
value={product?.full_weight_g ?? ''}
/>
</div>
<div class="space-y-2">
<Label for="empty_weight_g">Empty weight (g)</Label>
<Input
id="empty_weight_g"
name="empty_weight_g"
type="number"
min="0"
step="0.1"
placeholder="e.g. 30"
value={product?.empty_weight_g ?? ''}
/>
</div>
<div class="space-y-2">
<Label for="pao_months">PAO (months)</Label>
<Input

View file

@ -107,6 +107,7 @@ export interface ProductInventory {
finished_at?: string;
expiry_date?: string;
current_weight_g?: number;
last_weighed_at?: string;
notes?: string;
created_at: string;
product?: Product;
@ -127,6 +128,8 @@ export interface Product {
leave_on: boolean;
price_tier?: PriceTier;
size_ml?: number;
full_weight_g?: number;
empty_weight_g?: number;
pao_months?: number;
inci: string[];
actives?: ActiveIngredient[];

View file

@ -1,4 +1,11 @@
import { createInventory, deleteProduct, getProduct, updateProduct } from '$lib/api';
import {
createInventory,
deleteProduct,
getProduct,
updateProduct,
updateInventory as apiUpdateInventory,
deleteInventory as apiDeleteInventory
} from '$lib/api';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@ -125,6 +132,8 @@ export const actions: Actions = {
// Optional numbers
body.size_ml = parseOptionalFloat(form.get('size_ml') as string | null) ?? null;
body.full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null) ?? null;
body.empty_weight_g = parseOptionalFloat(form.get('empty_weight_g') as string | null) ?? null;
body.pao_months = parseOptionalInt(form.get('pao_months') as string | null) ?? null;
body.ph_min = parseOptionalFloat(form.get('ph_min') as string | null) ?? null;
body.ph_max = parseOptionalFloat(form.get('ph_max') as string | null) ?? null;
@ -199,10 +208,16 @@ export const actions: Actions = {
const body: Record<string, unknown> = {
is_opened: form.get('is_opened') === 'true'
};
const openedAt = form.get('opened_at');
if (openedAt) body.opened_at = openedAt;
const finishedAt = form.get('finished_at');
if (finishedAt) body.finished_at = finishedAt;
const expiry = form.get('expiry_date');
if (expiry) body.expiry_date = expiry;
const weight = form.get('current_weight_g');
if (weight) body.current_weight_g = Number(weight);
const lastWeighed = form.get('last_weighed_at');
if (lastWeighed) body.last_weighed_at = lastWeighed;
const notes = form.get('notes');
if (notes) body.notes = notes;
try {
@ -211,5 +226,44 @@ export const actions: Actions = {
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
updateInventory: async ({ request }) => {
const form = await request.formData();
const inventoryId = form.get('inventory_id') as string;
if (!inventoryId) return fail(400, { error: 'Missing inventory_id' });
const body: Record<string, unknown> = {
is_opened: form.get('is_opened') === 'true'
};
const openedAt = form.get('opened_at');
body.opened_at = openedAt || null;
const finishedAt = form.get('finished_at');
body.finished_at = finishedAt || null;
const expiry = form.get('expiry_date');
body.expiry_date = expiry || null;
const weight = form.get('current_weight_g');
body.current_weight_g = weight ? Number(weight) : null;
const lastWeighed = form.get('last_weighed_at');
body.last_weighed_at = lastWeighed || null;
const notes = form.get('notes');
body.notes = notes || null;
try {
await apiUpdateInventory(inventoryId, body);
return { inventoryUpdated: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
deleteInventory: async ({ request }) => {
const form = await request.formData();
const inventoryId = form.get('inventory_id') as string;
if (!inventoryId) return fail(400, { error: 'Missing inventory_id' });
try {
await apiDeleteInventory(inventoryId);
return { inventoryDeleted: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
}
};

View file

@ -13,6 +13,7 @@
let { product } = $derived(data);
let showInventoryForm = $state(false);
let editingInventoryId = $state<string | null>(null);
</script>
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
@ -54,11 +55,29 @@
{#if form?.inventoryAdded}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Package added.</div>
{/if}
{#if form?.inventoryUpdated}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Package updated.</div>
{/if}
{#if form?.inventoryDeleted}
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">Package deleted.</div>
{/if}
{#if showInventoryForm}
<Card>
<CardContent class="pt-4">
<form method="POST" action="?/addInventory" use:enhance class="grid grid-cols-2 gap-4">
<div class="col-span-2 flex items-center gap-2">
<input type="checkbox" id="add_is_opened" name="is_opened" value="true" class="h-4 w-4" />
<Label for="add_is_opened">Already opened</Label>
</div>
<div class="space-y-1">
<Label for="add_opened_at">Opened date</Label>
<Input id="add_opened_at" name="opened_at" type="date" />
</div>
<div class="space-y-1">
<Label for="add_finished_at">Finished date</Label>
<Input id="add_finished_at" name="finished_at" type="date" />
</div>
<div class="space-y-1">
<Label for="expiry_date">Expiry date</Label>
<Input id="expiry_date" name="expiry_date" type="date" />
@ -67,6 +86,10 @@
<Label for="current_weight_g">Current weight (g)</Label>
<Input id="current_weight_g" name="current_weight_g" type="number" min="0" />
</div>
<div class="space-y-1">
<Label for="add_last_weighed_at">Last weighed</Label>
<Input id="add_last_weighed_at" name="last_weighed_at" type="date" />
</div>
<div class="space-y-1">
<Label for="notes">Notes</Label>
<Input id="notes" name="notes" />
@ -74,7 +97,6 @@
<div class="flex items-end">
<Button type="submit" size="sm">Add</Button>
</div>
<input type="hidden" name="is_opened" value="false" />
</form>
</CardContent>
</Card>
@ -82,21 +104,141 @@
{#if product.inventory.length}
<div class="space-y-2">
{#each product.inventory as pkg}
<div class="flex items-center justify-between rounded-md border border-border px-4 py-3 text-sm">
<div class="space-x-3">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? 'Open' : 'Sealed'}
</Badge>
{#if pkg.expiry_date}
<span class="text-muted-foreground">Exp: {pkg.expiry_date}</span>
{/if}
{#if pkg.current_weight_g}
<span class="text-muted-foreground">{pkg.current_weight_g}g remaining</span>
{/if}
{#each product.inventory as pkg (pkg.id)}
<div class="rounded-md border border-border text-sm">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<Badge variant={pkg.is_opened ? 'default' : 'secondary'}>
{pkg.is_opened ? 'Open' : 'Sealed'}
</Badge>
{#if pkg.finished_at}
<Badge variant="outline">Finished</Badge>
{/if}
{#if pkg.expiry_date}
<span class="text-muted-foreground">Exp: {pkg.expiry_date.slice(0, 10)}</span>
{/if}
{#if pkg.opened_at}
<span class="text-muted-foreground">Opened: {pkg.opened_at.slice(0, 10)}</span>
{/if}
{#if pkg.finished_at}
<span class="text-muted-foreground">Finished: {pkg.finished_at.slice(0, 10)}</span>
{/if}
{#if pkg.current_weight_g}
<span class="text-muted-foreground">{pkg.current_weight_g}g remaining</span>
{/if}
{#if pkg.last_weighed_at}
<span class="text-muted-foreground">Weighed: {pkg.last_weighed_at.slice(0, 10)}</span>
{/if}
{#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span>
{/if}
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = editingInventoryId === pkg.id ? null : pkg.id)}
>
{editingInventoryId === pkg.id ? 'Cancel' : 'Edit'}
</Button>
<form
method="POST"
action="?/deleteInventory"
use:enhance
onsubmit={(e) => { if (!confirm('Delete this package?')) e.preventDefault(); }}
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<Button type="submit" variant="ghost" size="sm" class="text-destructive hover:text-destructive">×</Button>
</form>
</div>
</div>
{#if pkg.notes}
<span class="text-muted-foreground">{pkg.notes}</span>
{#if editingInventoryId === pkg.id}
<div class="border-t px-4 py-3">
<form
method="POST"
action="?/updateInventory"
use:enhance={() => {
return async ({ result, update }) => {
await update();
if (result.type === 'success') editingInventoryId = null;
};
}}
class="grid grid-cols-2 gap-4"
>
<input type="hidden" name="inventory_id" value={pkg.id} />
<div class="col-span-2 flex items-center gap-2">
<input
type="checkbox"
id="edit_is_opened_{pkg.id}"
name="is_opened"
value="true"
checked={pkg.is_opened}
class="h-4 w-4"
/>
<Label for="edit_is_opened_{pkg.id}">Already opened</Label>
</div>
<div class="space-y-1">
<Label for="edit_opened_at_{pkg.id}">Opened date</Label>
<Input
id="edit_opened_at_{pkg.id}"
name="opened_at"
type="date"
value={pkg.opened_at?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_finished_at_{pkg.id}">Finished date</Label>
<Input
id="edit_finished_at_{pkg.id}"
name="finished_at"
type="date"
value={pkg.finished_at?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_expiry_{pkg.id}">Expiry date</Label>
<Input
id="edit_expiry_{pkg.id}"
name="expiry_date"
type="date"
value={pkg.expiry_date?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_weight_{pkg.id}">Current weight (g)</Label>
<Input
id="edit_weight_{pkg.id}"
name="current_weight_g"
type="number"
min="0"
value={pkg.current_weight_g ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_last_weighed_{pkg.id}">Last weighed</Label>
<Input
id="edit_last_weighed_{pkg.id}"
name="last_weighed_at"
type="date"
value={pkg.last_weighed_at?.slice(0, 10) ?? ''}
/>
</div>
<div class="space-y-1">
<Label for="edit_notes_{pkg.id}">Notes</Label>
<Input id="edit_notes_{pkg.id}" name="notes" value={pkg.notes ?? ''} />
</div>
<div class="flex items-end gap-2">
<Button type="submit" size="sm">Save</Button>
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => (editingInventoryId = null)}
>Cancel</Button>
</div>
</form>
</div>
{/if}
</div>
{/each}

View file

@ -122,6 +122,12 @@ export const actions: Actions = {
const size_ml = parseOptionalFloat(form.get('size_ml') as string | null);
if (size_ml !== undefined) payload.size_ml = size_ml;
const full_weight_g = parseOptionalFloat(form.get('full_weight_g') as string | null);
if (full_weight_g !== undefined) payload.full_weight_g = full_weight_g;
const empty_weight_g = parseOptionalFloat(form.get('empty_weight_g') as string | null);
if (empty_weight_g !== undefined) payload.empty_weight_g = empty_weight_g;
const pao_months = parseOptionalInt(form.get('pao_months') as string | null);
if (pao_months !== undefined) payload.pao_months = pao_months;