From 976a72612e049103b51ba42e2920f7abf2e91a3c Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 19 Apr 2026 15:11:35 +0200 Subject: [PATCH] feat(inventory): add origin field to InventoryItem and update related DTOs and services --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + .../src/inventory/dto/create-inventory.dto.ts | 4 + backend/src/inventory/inventory.service.ts | 1 + backend/src/products/products.controller.ts | 9 ++ backend/src/products/products.service.ts | 31 ++++ .../dto/parsed-receipt-item.dto.ts | 2 + .../receipt-import/receipt-import.service.ts | 4 + frontend/app/api/products/[id]/route.ts | 2 +- frontend/app/api/products/pending/route.ts | 16 ++ frontend/app/api/products/route.ts | 2 +- frontend/app/import/ImportTabsClient.tsx | 4 +- frontend/app/import/page.tsx | 5 +- frontend/app/kvitto/ReceiptImportClient.tsx | 150 +++++++++++++++--- 14 files changed, 210 insertions(+), 23 deletions(-) create mode 100644 backend/prisma/migrations/20260419100000_add_inventory_origin/migration.sql create mode 100644 frontend/app/api/products/pending/route.ts diff --git a/backend/prisma/migrations/20260419100000_add_inventory_origin/migration.sql b/backend/prisma/migrations/20260419100000_add_inventory_origin/migration.sql new file mode 100644 index 00000000..f5fec57e --- /dev/null +++ b/backend/prisma/migrations/20260419100000_add_inventory_origin/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `InventoryItem` ADD COLUMN `origin` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ed5b2ad2..b13406bd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -89,6 +89,7 @@ model InventoryItem { quantity Decimal @db.Decimal(10, 2) unit String brand String? + origin String? receiptName String? location String? purchaseDate DateTime? diff --git a/backend/src/inventory/dto/create-inventory.dto.ts b/backend/src/inventory/dto/create-inventory.dto.ts index 39251789..cf029b4a 100644 --- a/backend/src/inventory/dto/create-inventory.dto.ts +++ b/backend/src/inventory/dto/create-inventory.dto.ts @@ -32,6 +32,10 @@ export class CreateInventoryDto { @IsString() brand?: string; + @IsOptional() + @IsString() + origin?: string; + @IsOptional() @IsString() receiptName?: string; diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 2a95ec3d..682792c5 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -143,6 +143,7 @@ export class InventoryService { quantity: new Prisma.Decimal(data.quantity), location: data.location?.trim() || undefined, brand: data.brand?.trim() || undefined, + origin: data.origin?.trim() || undefined, receiptName: data.receiptName?.trim() || undefined, suitableFor: data.suitableFor?.trim() || undefined, comment: data.comment?.trim() || undefined, diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 74ca9c1c..c102066c 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -125,11 +125,20 @@ export class ProductsController { return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories); } + @Roles('admin') @Post() create(@Body() body: CreateProductDto) { return this.productsService.create(body); } + @Post('pending') + createPending( + @Body() body: CreateProductDto, + @Request() req: { user: { id: number } }, + ) { + return this.productsService.createPending(body, req.user.id); + } + @Roles('admin') @Post('merge') merge(@Body() body: MergeProductsDto) { diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 0aff1740..467e9035 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -427,6 +427,37 @@ export class ProductsService { }); } + async createPending(data: CreateProductDto, userId: number) { + const name = data.name.trim(); + const normalizedName = normalizeName(name); + + const existing = await this.prisma.product.findUnique({ + where: { normalizedName }, + }); + + if (existing) { + // Om produkten redan finns (aktiv), returnera den direkt + if (existing.isActive && existing.status === 'active') { + return existing; + } + // Om det redan finns ett pending-förslag, returnera det + if (existing.status === 'pending') { + return existing; + } + } + + return this.prisma.product.create({ + data: { + name, + normalizedName, + canonicalName: name, + isActive: false, + status: 'pending', + ownerId: userId, + }, + }); + } + setStatus(id: number, status: string) { return this.prisma.product.update({ where: { id }, data: { status } }); } diff --git a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts index 528079f8..222c841c 100644 --- a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts +++ b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts @@ -5,6 +5,8 @@ export interface ParsedReceiptItem { quantity: number; unit: string; price?: number | null; + brand?: string | null; + origin?: string | null; // alias-match: säker, användaren slipper bekräfta matchedProductId?: number; matchedProductName?: string; diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 715d68b2..4bc16ff5 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -19,6 +19,8 @@ Varje vara ska ha följande fält: - "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) - "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" - "price": pris i SEK som ett tal, eller null +- "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null +- "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null Returnera BARA JSON-arrayen utan markdown-formatering.`; @@ -29,6 +31,8 @@ Varje vara ska ha följande fält: - "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) - "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" - "price": pris i SEK som ett tal, eller null +- "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null +- "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null Returnera BARA JSON-arrayen utan markdown-formatering. diff --git a/frontend/app/api/products/[id]/route.ts b/frontend/app/api/products/[id]/route.ts index 08ab4440..8c20ae84 100644 --- a/frontend/app/api/products/[id]/route.ts +++ b/frontend/app/api/products/[id]/route.ts @@ -5,8 +5,8 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api: export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const authHeaders = await getAuthHeaders(); const body = await req.json(); + const authHeaders = await getAuthHeaders(); const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...authHeaders }, diff --git a/frontend/app/api/products/pending/route.ts b/frontend/app/api/products/pending/route.ts new file mode 100644 index 00000000..3e2de77c --- /dev/null +++ b/frontend/app/api/products/pending/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthHeaders } from '../../../../lib/auth-headers'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function POST(req: NextRequest) { + const body = await req.json(); + const authHeaders = await getAuthHeaders(); + const res = await fetch(`${API_BASE}/api/products/pending`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify(body), + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} diff --git a/frontend/app/api/products/route.ts b/frontend/app/api/products/route.ts index 68d97f89..b37b01b0 100644 --- a/frontend/app/api/products/route.ts +++ b/frontend/app/api/products/route.ts @@ -16,8 +16,8 @@ export async function GET(req: NextRequest) { } export async function POST(req: NextRequest) { - const authHeaders = await getAuthHeaders(); const body = await req.json(); + const authHeaders = await getAuthHeaders(); const res = await fetch(`${API_BASE}/api/products`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeaders }, diff --git a/frontend/app/import/ImportTabsClient.tsx b/frontend/app/import/ImportTabsClient.tsx index 86646c27..b2b09165 100644 --- a/frontend/app/import/ImportTabsClient.tsx +++ b/frontend/app/import/ImportTabsClient.tsx @@ -10,7 +10,7 @@ type Tab = 'kvitto' | 'recept'; type Product = { id: number; name: string; canonicalName: string | null }; -export default function ImportTabsClient({ activeTab }: { activeTab: Tab }) { +export default function ImportTabsClient({ activeTab, isAdmin }: { activeTab: Tab; isAdmin: boolean }) { return (

Importera

@@ -55,7 +55,7 @@ export default function ImportTabsClient({ activeTab }: { activeTab: Tab }) {

Fotografera eller ladda upp ett kvitto — varorna läggs till i ditt inventarie.

- + )} diff --git a/frontend/app/import/page.tsx b/frontend/app/import/page.tsx index 96777904..aa724ac0 100644 --- a/frontend/app/import/page.tsx +++ b/frontend/app/import/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from 'next'; import Navigation from '../Navigation'; import ImportTabsClient from './ImportTabsClient'; +import { auth } from '../../auth'; type Props = { searchParams: Promise<{ tab?: string }>; @@ -15,10 +16,12 @@ export async function generateMetadata({ searchParams }: Props): Promise - + ); } diff --git a/frontend/app/kvitto/ReceiptImportClient.tsx b/frontend/app/kvitto/ReceiptImportClient.tsx index a4bb0521..ce7a33f1 100644 --- a/frontend/app/kvitto/ReceiptImportClient.tsx +++ b/frontend/app/kvitto/ReceiptImportClient.tsx @@ -15,6 +15,8 @@ type ParsedItem = { quantity: number; unit: string; price?: number | null; + brand?: string | null; + origin?: string | null; matchedProductId?: number; matchedProductName?: string; suggestedProductId?: number; @@ -38,13 +40,16 @@ type RowState = { saveAlias: boolean; editQty: string; editUnit: string; + editBrand: string; + editOrigin: string; + editComment: string; matchSource: 'alias' | 'suggestion' | 'manual' | 'none'; categorySuggestion?: CategorySuggestion; }; const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska']; -export default function ReceiptImportClient() { +export default function ReceiptImportClient({ isAdmin }: { isAdmin: boolean }) { const fileRef = useRef(null); const [preview, setPreview] = useState(null); const [parsing, setParsing] = useState(false); @@ -118,6 +123,9 @@ export default function ReceiptImportClient() { saveAlias: false, editQty: String(item.quantity), editUnit: item.unit, + editBrand: item.brand ?? '', + editOrigin: item.origin ?? '', + editComment: '', matchSource: 'alias', productSearch: item.matchedProductName ?? '', selectedCategoryId: '', @@ -135,6 +143,9 @@ export default function ReceiptImportClient() { saveAlias: false, editQty: String(item.quantity), editUnit: item.unit, + editBrand: item.brand ?? '', + editOrigin: item.origin ?? '', + editComment: '', matchSource: 'suggestion', productSearch: item.suggestedProductName ?? '', selectedCategoryId: '', @@ -151,6 +162,9 @@ export default function ReceiptImportClient() { saveAlias: false, editQty: String(item.quantity), editUnit: item.unit, + editBrand: item.brand ?? '', + editOrigin: item.origin ?? '', + editComment: '', matchSource: 'none', categorySuggestion: item.categorySuggestion, productSearch: '', @@ -174,7 +188,7 @@ export default function ReceiptImportClient() { setCreatingProduct(i); setError(null); try { - // Skapa produkt + // Admin skapar aktiv produkt direkt const createRes = await fetch('/api/products', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -216,6 +230,53 @@ export default function ReceiptImportClient() { } }; + const handleSuggestProduct = async (i: number) => { + const row = rows[i]; + setCreatingProduct(i); + setError(null); + try { + // Användare skapar ett pending-förslag + const createRes = await fetch('/api/products/pending', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: row.rawName }), + }); + if (!createRes.ok) { + const e = await createRes.json().catch(() => ({})); + throw new Error(e.message ?? `HTTP ${createRes.status}`); + } + const product = await createRes.json() as { id: number; name: string; canonicalName: string | null }; + + // Sätt kategori om vald/föreslagen + const categoryId = row.categorySuggestion?.categoryId ?? (row.selectedCategoryId !== '' ? row.selectedCategoryId : null); + if (categoryId) { + await fetch(`/api/products/${product.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ categoryId }), + }); + } + + // Lägg till i lokal lista (men markera som pending) + const newProduct = { id: product.id, name: product.name, canonicalName: product.canonicalName }; + setAllProducts((prev) => [...prev, newProduct].sort((a, b) => (a.canonicalName ?? a.name).localeCompare(b.canonicalName ?? b.name, 'sv'))); + + // Markera raden — pending = kan läggas till i inventariet men väntar på admin-godkännande + updateRow(i, { + selectedProductId: product.id, + selectedProductName: product.canonicalName ?? product.name, + productSearch: product.canonicalName ?? product.name, + checked: true, + matchSource: 'manual', + saveAlias: false, + }); + } catch (err) { + setError(`Kunde inte föreslå produkt: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setCreatingProduct(null); + } + }; + const handleSave = async () => { const toSave = rows.filter((r) => r.checked && r.selectedProductId !== ''); if (toSave.length === 0) return; @@ -232,6 +293,9 @@ export default function ReceiptImportClient() { quantity: parseFloat(r.editQty) || r.quantity, unit: r.editUnit, receiptName: r.rawName, + brand: r.editBrand.trim() || undefined, + origin: r.editOrigin.trim() || undefined, + comment: r.editComment.trim() || undefined, }), }), ), @@ -335,10 +399,15 @@ export default function ReceiptImportClient() { {rows.length > 0 && (
+ {!isAdmin && ( +
+ Tips: Om en vara saknas kan du klicka Föreslå ny vara — varan läggs till i inventariet och skickas för granskning av en administratör. +
+ )}

Identifierade varor ({rows.length})

- 🟢 Känd = automatiskt markerad · 🟠 Förslag = markera för att inkludera · Sök eller skapa ny produkt + 🟢 Känd = automatiskt markerad · 🟠 Förslag = markera för att inkludera · {isAdmin ? 'Sök eller skapa ny produkt' : 'Sök eller föreslå ny vara'}
@@ -354,7 +423,7 @@ export default function ReceiptImportClient() { {label.text} )}
-
+
)}
+
+ updateRow(i, { editBrand: e.target.value })} + placeholder="Märke / leverantör (valfritt)" + style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555' }} + /> + updateRow(i, { editOrigin: e.target.value })} + placeholder="Ursprungsland (valfritt)" + style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555' }} + /> +
+
+ updateRow(i, { editComment: e.target.value })} + placeholder="Kommentar, t.ex. styckning, kvalitet... (valfritt)" + style={{ width: '100%', padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555', boxSizing: 'border-box' }} + /> +
{row.categorySuggestion && row.matchSource === 'none' && (
@@ -406,13 +500,23 @@ export default function ReceiptImportClient() { AI-förslag: {row.categorySuggestion.path} {row.categorySuggestion.usedFallback && (osäker)}
- + {isAdmin ? ( + + ) : ( + + )}
)} {row.matchSource === 'none' && !row.categorySuggestion && ( @@ -427,13 +531,23 @@ export default function ReceiptImportClient() { ))} - + {isAdmin ? ( + + ) : ( + + )}
)} {row.selectedProductId !== '' && row.matchSource !== 'alias' && (