From 2acf66e4c428bc7ee3d0a14a1aaea30545270f76 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Tue, 21 Apr 2026 16:09:33 +0200 Subject: [PATCH] feat: enhance pantry management with new features and UI improvements --- README.md | 41 +++++++- TEKNISK_BESKRIVNING.md | 6 +- backend/src/meal-plan/meal-plan.service.ts | 23 ++++- frontend/app/baslager/PantryList.tsx | 94 +++++++++++++++++-- frontend/app/baslager/page.tsx | 13 +-- frontend/app/matsedel/MealPlanClient.tsx | 31 ++++-- frontend/app/profil/tabs/views/PantryView.tsx | 56 +++++++++-- 7 files changed, 221 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 89ad1878..3a85996e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,41 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på - **Konsumtionshistorik** — spåra vad som använts när och i vilken mängd - **Utförlig information** — stöd för varumärke, lagringsnot, tillkomsttid och mer +### Baslager +- **Ständigt lager** — markera produkter du alltid räknar med att ha hemma +- **Grupperat per kategori** — produkterna i baslagret visas grupperade under kategorirubrik +- **Lägg till och ta bort** — välj från produktlistan via sökbar dropdown, ta bort med ett klick + +--- + +### 📌 Så använder du Inventarie och Baslager + +Dessa två funktioner fyller olika syften och kompletterar varandra: + +#### Inventarie — ”vad du faktiskt har hemma” +Inventariet är en **aktiv förrådsbok** över varor du just nu har hemma. Här registrerar du: +- Exakt mängd (t.ex. 500 g pasta, 1,5 liter mjolk) +- Var varan förvaras (kyl, frys, skafferi) +- Bäst före-datum +- Om förpackningen är öppnad + +När du lagar mat kan du registrera hur mycket du förbrukat, och inventariet uppdateras. **Inventariet är punkten i tid — det speglar verkligheten.** + +#### Baslager — ”vad du alltid har hemma” +Baslagret är en **permanent lista** över varor du alltid räknar med att ha hemma, oavsett vad inventariet säger. Tänk salt, olja, socker, svartpeppar, mjolk, ägg — varor som nästan aldrig tar slut helt, eller som du alltid köper på direkt när de tar slut. + +Baslagret **påverkar inköpslistan**: varor i baslagret markeras automatiskt som tillgängliga i matplanens inköpslista — du behöver inte föra in dem i inventariet för att det ska fungera. + +#### Praktiskt flöde +| Situation | Använd | +|---|---| +| Du köpte 2 kg pasta idag | **Inventariet** — lägg till med mängd och bäst före | +| Salt ingår alltid i dina recept | **Baslager** — lägg till en gång, släpp sen | +| Du vill se vad du behöver köpa | **Matplanen** — inköpslistan jämför mot båda | +| Du tog slut på mjolk | Ta bort från inventariet (baslagret påverkas inte) | + +--- + ### Recept - **Skapa och redigera recept** — med namn, beskrivning, portionsantal, ingredienser (kvantitet och enhet) och instruktioner i Markdown-format - **Portionsjustering** — ange antal portioner vid skapandet; matplanen räknar automatiskt om ingrediensmängder om du lagar fler eller färre portioner @@ -39,11 +74,6 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på - **Granska och lägg till** — se tolkningsresultatet, justera kvantitet och enhet, och lägg till direkt i inventariet - **AI-kategorisuggestion (premium)** — för varor som inte matchas mot befintliga produkter visas ett AI-förslag på kategori (t.ex. "✨ Mejeri och ägg > Kvarg och fil") som hjälp när användaren väljer produkt manuellt -### Baslager -- **Ständigt lager** — markera produkter du alltid räknar med att ha hemma -- **Grupperat per kategori** — produkterna i baslagret visas grupperade under kategorirubrik -- **Lägg till och ta bort** — välj från produktlistan via sökbar dropdown, ta bort med ett klick - ### Admin: Produkter > Obs: Destruktiva åtgärder (merge, ta bort, återställ, bulk-uppdatera, återställ all data) kräver admin-roll. **Admin: Produkter (fliken Databas i /profil)** @@ -76,6 +106,7 @@ Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar bero **Alla inloggade användare:** - **Min profil** — redigera förnamn, efternamn och e-postadress +- **Databas** — hantera inventarie och baslager (se nedan) **Enbart admin:** - **Användare** — fullständig användarhantering: diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index f110fb70..6d6862cf 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -644,7 +644,11 @@ backend/src/ - Om `entry.servings` och `recipe.servings` är satta beräknas en skala: `scale = entry.servings / recipe.servings` - Ingrediensmängder multipliceras med skalan innan aggregering - Returnerar lista av `{ productName, quantity, unit }` -- **`inventoryCompare(from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot aktuellt inventarielager. Returnerar status per ingrediens: `räcker | saknas | enhetskonflikt`. +- **`inventoryCompare(from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot: + 1. **Pantry (baslager):** Om produkten finns i `PantryItem`-tabellen returneras `status: 'pantry'`, `missing: 0` — varan räknas alltid som tillgänglig oavsett inventariet. + 2. **Inventariet:** Övriga ingredienser jämförs mot `InventoryItem`. Returnerar `status: 'enough' | 'missing'`. + - Sorteringsordning: `missing` → `enough` → `pantry` + - Frontend visar 📦-ikon och ”(baslager)” för pantry-varor; de visas aldrig som ”saknas” i inköpslistan. **Kvittoimport-API:** - **`parseReceipt(file, isPremium)`** — Tar emot en bild eller PDF (max 15 MB), skickar den till Mistral AI för tolkning och returnerar en lista av kandidatprodukter med namn, kvantitet och enhet. diff --git a/backend/src/meal-plan/meal-plan.service.ts b/backend/src/meal-plan/meal-plan.service.ts index bf3e9d0b..d4250243 100644 --- a/backend/src/meal-plan/meal-plan.service.ts +++ b/backend/src/meal-plan/meal-plan.service.ts @@ -86,6 +86,10 @@ export class MealPlanService { async inventoryCompare(from: string, to: string) { const entries = await this.findByRange(from, to); + // Hämta pantry-produkter — dessa anses alltid tillgängliga + const pantryItems = await this.prisma.pantryItem.findMany({ select: { productId: true } }); + const pantryProductIds = new Set(pantryItems.map((p) => p.productId)); + // Aggregera ingredienser per produkt+enhet (skalat per portionsantal) const map = new Map(); for (const entry of entries) { @@ -112,6 +116,19 @@ export class MealPlanService { // Kontrollera inventariet för varje ingrediens const result = await Promise.all( Array.from(map.values()).map(async (item) => { + // Pantry-varor anses alltid tillgängliga — visa inte i inköpslistan + if (pantryProductIds.has(item.productId)) { + return { + productId: item.productId, + name: item.name, + required: item.required, + unit: item.unit, + available: item.required, + missing: 0, + status: 'pantry' as const, + }; + } + const inventoryItems = await this.prisma.inventoryItem.findMany({ where: { productId: item.productId }, }); @@ -125,13 +142,15 @@ export class MealPlanService { unit: item.unit, available, missing: Math.max(0, item.required - available), - status: (available >= item.required ? 'enough' : 'missing') as 'enough' | 'missing', + status: (available >= item.required ? 'enough' : 'missing') as 'enough' | 'missing' | 'pantry', }; }), ); + const statusOrder = { missing: 0, enough: 1, pantry: 2 }; return result.sort((a, b) => { - if (a.status !== b.status) return a.status === 'missing' ? -1 : 1; + const diff = statusOrder[a.status] - statusOrder[b.status]; + if (diff !== 0) return diff; return a.name.localeCompare(b.name, 'sv'); }); } diff --git a/frontend/app/baslager/PantryList.tsx b/frontend/app/baslager/PantryList.tsx index 43d5998d..503dc2a3 100644 --- a/frontend/app/baslager/PantryList.tsx +++ b/frontend/app/baslager/PantryList.tsx @@ -7,21 +7,99 @@ type PantryItem = { product: { id: number; name: string; canonicalName: string | null; category: string | null }; }; -type InventoryItem = { - productId: number; - quantity: string; - unit: string; -}; - type Props = { items: PantryItem[]; - inventoryByProductId: Record; onDeleted?: () => void; }; -export default function PantryList({ items, inventoryByProductId, onDeleted }: Props) { +export default function PantryList({ items, onDeleted }: Props) { const router = useRouter(); + async function handleRemove(id: number, name: string) { + if (!confirm(`Ta bort "${name}" från baslagret?`)) return; + const res = await fetch(`/api/admin/pantry-item/${id}`, { method: 'DELETE' }); + if (res.ok) { + if (onDeleted) onDeleted(); + else router.refresh(); + } + } + + if (items.length === 0) { + return ( +

+ Baslagret är tomt. Lägg till produkter ovan. +

+ ); + } + + // Gruppera per kategori + const grouped = items.reduce>((acc, item) => { + const cat = item.product.category || 'Övrigt'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(item); + return acc; + }, {}); + + const sortedCategories = Object.keys(grouped).sort((a, b) => { + if (a === 'Övrigt') return 1; + if (b === 'Övrigt') return -1; + return a.localeCompare(b, 'sv'); + }); + + return ( +
+ {sortedCategories.map((category) => ( +
+

+ {category} +

+
+ {grouped[category].map((item) => { + const displayName = item.product.canonicalName || item.product.name; + return ( +
+ + {displayName} + + +
+ ); + })} +
+
+ ))} +
+ ); +} + + async function handleRemove(id: number, name: string) { if (!confirm(`Ta bort "${name}" från baslagret?`)) return; const res = await fetch(`/api/admin/pantry-item/${id}`, { method: 'DELETE' }); diff --git a/frontend/app/baslager/page.tsx b/frontend/app/baslager/page.tsx index ce3a8ab6..3f920dd2 100644 --- a/frontend/app/baslager/page.tsx +++ b/frontend/app/baslager/page.tsx @@ -1,5 +1,5 @@ import { fetchJson } from '../../lib/api'; -import type { Product, InventoryItem } from '../../features/inventory/types'; +import type { Product } from '../../features/inventory/types'; import Navigation from '../Navigation'; import AddToPantryForm from './AddToPantryForm'; import PantryList from './PantryList'; @@ -13,20 +13,13 @@ type PantryItem = { }; export default async function BaslagerPage() { - const [pantryItems, products, inventoryItems] = await Promise.all([ + const [pantryItems, products] = await Promise.all([ fetchJson('/api/pantry'), fetchJson('/api/products'), - fetchJson('/api/inventory').catch(() => [] as InventoryItem[]), ]); const pantryProductIds = new Set(pantryItems.map((i) => i.productId)); - // Bygg upp en map productId → inventarieposter - const inventoryByProductId = inventoryItems.reduce>((acc, item) => { - if (!acc[item.productId]) acc[item.productId] = []; - acc[item.productId].push(item); - return acc; - }, {}); return (
@@ -45,7 +38,7 @@ export default async function BaslagerPage() {

{pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret

- +
); diff --git a/frontend/app/matsedel/MealPlanClient.tsx b/frontend/app/matsedel/MealPlanClient.tsx index ddbf407c..433deba8 100644 --- a/frontend/app/matsedel/MealPlanClient.tsx +++ b/frontend/app/matsedel/MealPlanClient.tsx @@ -25,7 +25,7 @@ type InventoryCompareItem = { unit: string; available: number; missing: number; - status: 'enough' | 'missing'; + status: 'enough' | 'missing' | 'pantry'; }; function getWeekDates(offset = 0): string[] { @@ -228,7 +228,7 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {

Laddar ingredienser...

) : (() => { // Berika varje rad med inventariestatus - type DisplayStatus = 'enough' | 'partial' | 'missing'; + type DisplayStatus = 'enough' | 'partial' | 'missing' | 'pantry'; const enriched = shopping.map((item) => { const cmp = inventoryCompare.find( (c) => c.productId === item.productId && c.unit === item.unit, @@ -236,7 +236,10 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { let displayStatus: DisplayStatus = 'missing'; let buyQty = item.quantity; if (cmp) { - if (cmp.available >= cmp.required) { + if (cmp.status === 'pantry') { + displayStatus = 'pantry'; + buyQty = 0; + } else if (cmp.available >= cmp.required) { displayStatus = 'enough'; buyQty = 0; } else if (cmp.available > 0) { @@ -247,12 +250,13 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { return { ...item, cmp, displayStatus, buyQty }; }); - const order: Record = { missing: 0, partial: 1, enough: 2 }; + const order: Record = { missing: 0, partial: 1, enough: 2, pantry: 3 }; enriched.sort((a, b) => order[a.displayStatus] - order[b.displayStatus] || a.name.localeCompare(b.name, 'sv')); const missingCount = enriched.filter((e) => e.displayStatus === 'missing').length; const partialCount = enriched.filter((e) => e.displayStatus === 'partial').length; const enoughCount = enriched.filter((e) => e.displayStatus === 'enough').length; + const pantryCount = enriched.filter((e) => e.displayStatus === 'pantry').length; const hasCompare = inventoryCompare.length > 0; const fmtQty = (n: number) => (n % 1 === 0 ? String(n) : n.toFixed(1)); @@ -265,6 +269,7 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { {missingCount > 0 && ❌ {missingCount} saknas} {partialCount > 0 && ⚠️ {partialCount} delvis hemma} {enoughCount > 0 && ✅ {enoughCount} hemma} + {pantryCount > 0 && 📦 {pantryCount} baslager} {missingCount === 0 && partialCount === 0 && ( ✅ Du har allt hemma! )} @@ -276,8 +281,9 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { const isMissing = item.displayStatus === 'missing'; const isPartial = item.displayStatus === 'partial'; const isEnough = item.displayStatus === 'enough'; - const bg = isMissing ? '#ffeaea' : isPartial ? '#fff8e6' : '#ecf8ee'; - const icon = isMissing ? '❌' : isPartial ? '⚠️' : '✅'; + const isPantry = item.displayStatus === 'pantry'; + const bg = isMissing ? '#ffeaea' : isPartial ? '#fff8e6' : isPantry ? '#f5f5f5' : '#ecf8ee'; + const icon = isMissing ? '❌' : isPartial ? '⚠️' : isPantry ? '📦' : '✅'; return (
  • - {hasCompare && {icon}} - + {hasCompare && {icon}} + {item.name} {isPartial && item.cmp && ( @@ -306,9 +312,14 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { (finns hemma) )} + {isPantry && ( + + (baslager) + + )} - - {isEnough + + {(isEnough || isPantry) ? '—' : `${fmtQty(item.buyQty)} ${item.unit}`} diff --git a/frontend/app/profil/tabs/views/PantryView.tsx b/frontend/app/profil/tabs/views/PantryView.tsx index 8f9ae437..d00a0e37 100644 --- a/frontend/app/profil/tabs/views/PantryView.tsx +++ b/frontend/app/profil/tabs/views/PantryView.tsx @@ -14,20 +14,62 @@ type PantryItem = { product: Product; }; -type InventoryItem = { - productId: number; - quantity: string; - unit: string; -}; - export default function PantryView() { const [pantryItems, setPantryItems] = useState([]); const [products, setProducts] = useState([]); - const [inventoryByProductId, setInventoryByProductId] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const authFetch = useAuthFetch(); + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [pantryRes, prodRes] = await Promise.all([ + authFetch('/api/pantry'), + fetch('/api/products'), + ]); + if (!pantryRes.ok) throw new Error('Kunde inte hämta baslager'); + if (!prodRes.ok) throw new Error('Kunde inte hämta produkter'); + const [pantry, prods] = await Promise.all([pantryRes.json(), prodRes.json()]); + setPantryItems(pantry); + setProducts(prods); + } catch (e) { + setError(e instanceof Error ? e.message : 'Okänt fel'); + } finally { + setLoading(false); + } + }, [authFetch]); + + useEffect(() => { load(); }, [load]); + + if (loading) return

    Laddar baslager…

    ; + if (error) return

    {error}

    ; + + const pantryProductIds = new Set(pantryItems.map((i) => i.productId)); + + return ( +
    +

    + Produkter du alltid räknar med att ha hemma. Lägg till och ta bort varor i ditt baslager. +

    + +
    +

    Lägg till produkt

    + +
    + +
    +

    + {pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret +

    + +
    +
    + ); +} + + const load = useCallback(async () => { setLoading(true); setError(null);