diff --git a/backend/prisma/migrations/20260415100000_add_pantry_item/migration.sql b/backend/prisma/migrations/20260415100000_add_pantry_item/migration.sql new file mode 100644 index 00000000..f3e5226b --- /dev/null +++ b/backend/prisma/migrations/20260415100000_add_pantry_item/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `PantryItem` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `productId` INTEGER NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `PantryItem_productId_key`(`productId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `PantryItem` ADD CONSTRAINT `PantryItem_productId_fkey` FOREIGN KEY (`productId`) REFERENCES `Product`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 75f3351a..69888665 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -18,8 +18,9 @@ model Product { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - inventoryItems InventoryItem[] + inventoryItems InventoryItem[] recipeIngredients RecipeIngredient[] + pantryItems PantryItem[] } model InventoryItem { @@ -81,6 +82,14 @@ model RecipeIngredient { unit String note String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model PantryItem { + id Int @id @default(autoincrement()) + productId Int @unique + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index f9c43fff..c40e1f53 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { ProductsModule } from './products/products.module'; import { InventoryModule } from './inventory/inventory.module'; import { RecipesModule } from './recipes/recipes.module'; import { QuickImportModule } from './quick-import/quick-import.module'; +import { PantryModule } from './pantry/pantry.module'; @Module({ @@ -15,6 +16,7 @@ import { QuickImportModule } from './quick-import/quick-import.module'; InventoryModule, RecipesModule, QuickImportModule, + PantryModule, ], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/pantry/dto/create-pantry-item.dto.ts b/backend/src/pantry/dto/create-pantry-item.dto.ts new file mode 100644 index 00000000..9f1361b0 --- /dev/null +++ b/backend/src/pantry/dto/create-pantry-item.dto.ts @@ -0,0 +1,7 @@ +import { IsInt, IsPositive } from 'class-validator'; + +export class CreatePantryItemDto { + @IsInt() + @IsPositive() + productId: number; +} diff --git a/backend/src/pantry/pantry.controller.ts b/backend/src/pantry/pantry.controller.ts new file mode 100644 index 00000000..944ae2e7 --- /dev/null +++ b/backend/src/pantry/pantry.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { PantryService } from './pantry.service'; +import { CreatePantryItemDto } from './dto/create-pantry-item.dto'; + +@Controller('pantry') +export class PantryController { + constructor(private readonly pantryService: PantryService) {} + + @Get() + findAll() { + return this.pantryService.findAll(); + } + + @Post() + create(@Body() body: CreatePantryItemDto) { + return this.pantryService.create(body); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.pantryService.remove(id); + } +} diff --git a/backend/src/pantry/pantry.module.ts b/backend/src/pantry/pantry.module.ts new file mode 100644 index 00000000..84c5bdfd --- /dev/null +++ b/backend/src/pantry/pantry.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PantryController } from './pantry.controller'; +import { PantryService } from './pantry.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + controllers: [PantryController], + providers: [PantryService], + imports: [PrismaModule], +}) +export class PantryModule {} diff --git a/backend/src/pantry/pantry.service.ts b/backend/src/pantry/pantry.service.ts new file mode 100644 index 00000000..9942f201 --- /dev/null +++ b/backend/src/pantry/pantry.service.ts @@ -0,0 +1,44 @@ +import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePantryItemDto } from './dto/create-pantry-item.dto'; + +@Injectable() +export class PantryService { + constructor(private readonly prisma: PrismaService) {} + + findAll() { + return this.prisma.pantryItem.findMany({ + include: { + product: true, + }, + orderBy: { + product: { name: 'asc' }, + }, + }); + } + + async create(data: CreatePantryItemDto) { + const existing = await this.prisma.pantryItem.findUnique({ + where: { productId: data.productId }, + }); + + if (existing) { + throw new ConflictException('Produkten finns redan i baslagret'); + } + + return this.prisma.pantryItem.create({ + data: { productId: data.productId }, + include: { product: true }, + }); + } + + async remove(id: number) { + const item = await this.prisma.pantryItem.findUnique({ where: { id } }); + + if (!item) { + throw new NotFoundException(`PantryItem med id ${id} hittades inte`); + } + + return this.prisma.pantryItem.delete({ where: { id } }); + } +} diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index ccbeac90..f92e005d 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -59,6 +59,21 @@ export default function Navigation() { > 📖 Recept + + 🏪 Baslager + ; +}; + +export default function AddToPantryForm({ products, pantryProductIds }: Props) { + const [selectedId, setSelectedId] = useState(''); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const available = products.filter((p) => !pantryProductIds.has(p.id)); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!selectedId) return; + setError(null); + startTransition(async () => { + try { + await addPantryItem(Number(selectedId)); + setSelectedId(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Okänt fel'); + } + }); + } + + return ( + + setSelectedId(e.target.value)} + required + style={{ + flex: '1 1 220px', + padding: '0.6rem 0.75rem', + border: '1px solid #ddd', + borderRadius: '6px', + fontSize: '1rem', + }} + > + Välj produkt… + {available.map((p) => ( + + {p.canonicalName || p.name} + + ))} + + + {isPending ? 'Lägger till…' : 'Lägg till'} + + {error && {error}} + + ); +} diff --git a/frontend/app/baslager/PantryList.tsx b/frontend/app/baslager/PantryList.tsx new file mode 100644 index 00000000..f04b8a12 --- /dev/null +++ b/frontend/app/baslager/PantryList.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { useTransition } from 'react'; +import { removePantryItem } from './actions'; + +type PantryItem = { + id: number; + product: { id: number; name: string; canonicalName: string | null; category: string | null }; +}; + +type Props = { + items: PantryItem[]; +}; + +export default function PantryList({ items }: Props) { + const [isPending, startTransition] = useTransition(); + + function handleRemove(id: number, name: string) { + if (!confirm(`Ta bort "${name}" från baslagret?`)) return; + startTransition(async () => { + await removePantryItem(id); + }); + } + + 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} + handleRemove(item.id, displayName)} + disabled={isPending} + style={{ + background: 'none', + border: 'none', + color: '#c00', + cursor: isPending ? 'not-allowed' : 'pointer', + fontSize: '1.1rem', + padding: '0.2rem 0.5rem', + lineHeight: 1, + }} + title="Ta bort från baslagret" + > + × + + + ); + })} + + + ))} + + ); +} diff --git a/frontend/app/baslager/actions.ts b/frontend/app/baslager/actions.ts new file mode 100644 index 00000000..aa4c9ca4 --- /dev/null +++ b/frontend/app/baslager/actions.ts @@ -0,0 +1,34 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { API_BASE } from '../../lib/api'; + +export async function addPantryItem(productId: number) { + const res = await fetch(`${API_BASE}/api/pantry`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ productId }), + cache: 'no-store', + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Kunde inte lägga till i baslagret: ${text}`); + } + + revalidatePath('/baslager'); +} + +export async function removePantryItem(id: number) { + const res = await fetch(`${API_BASE}/api/pantry/${id}`, { + method: 'DELETE', + cache: 'no-store', + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Kunde inte ta bort från baslagret: ${text}`); + } + + revalidatePath('/baslager'); +} diff --git a/frontend/app/baslager/page.tsx b/frontend/app/baslager/page.tsx new file mode 100644 index 00000000..1c04af36 --- /dev/null +++ b/frontend/app/baslager/page.tsx @@ -0,0 +1,44 @@ +import { fetchJson } from '../../lib/api'; +import type { Product } from '../../features/inventory/types'; +import Navigation from '../Navigation'; +import AddToPantryForm from './AddToPantryForm'; +import PantryList from './PantryList'; + +type PantryItem = { + id: number; + productId: number; + createdAt: string; + updatedAt: string; + product: Product; +}; + +export default async function BaslagerPage() { + const [pantryItems, products] = await Promise.all([ + fetchJson('/api/pantry'), + fetchJson('/api/products'), + ]); + + const pantryProductIds = new Set(pantryItems.map((i) => i.productId)); + + return ( + + + Baslager + + Produkter du alltid räknar med att ha hemma. + + + + Lägg till produkt + + + + + + {pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret + + + + + ); +}
+ Baslagret är tomt. Lägg till produkter ovan. +
+ Produkter du alltid räknar med att ha hemma. +