From 632d084dbe5b924ebbddce23166544ac77d46f87 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 19 Apr 2026 13:39:26 +0200 Subject: [PATCH] feat(products): implement category selection and product creation in receipt import --- frontend/app/api/categories/route.ts | 8 +- frontend/app/api/products/[id]/route.ts | 16 +++ frontend/app/api/products/route.ts | 14 ++- frontend/app/kvitto/ReceiptImportClient.tsx | 128 ++++++++++++++++++-- 4 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 frontend/app/api/products/[id]/route.ts diff --git a/frontend/app/api/categories/route.ts b/frontend/app/api/categories/route.ts index fc5f95fe..696a819b 100644 --- a/frontend/app/api/categories/route.ts +++ b/frontend/app/api/categories/route.ts @@ -1,9 +1,11 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; -export async function GET() { - const res = await fetch(`${API_BASE}/api/categories/tree`, { cache: 'no-store' }); +export async function GET(req: NextRequest) { + const isTree = req.nextUrl.searchParams.has('tree'); + const endpoint = isTree ? '/api/categories/tree' : '/api/categories'; + const res = await fetch(`${API_BASE}${endpoint}`, { cache: 'no-store' }); const text = await res.text(); return new NextResponse(text, { status: res.status, diff --git a/frontend/app/api/products/[id]/route.ts b/frontend/app/api/products/[id]/route.ts new file mode 100644 index 00000000..47bd1a9b --- /dev/null +++ b/frontend/app/api/products/[id]/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 PATCH(req: NextRequest, { params }: { params: { id: string } }) { + const authHeaders = await getAuthHeaders(); + const body = await req.json(); + const res = await fetch(`${API_BASE}/api/products/${params.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return NextResponse.json(data, { status: res.status }); +} diff --git a/frontend/app/api/products/route.ts b/frontend/app/api/products/route.ts index 37d61ccb..68d97f89 100644 --- a/frontend/app/api/products/route.ts +++ b/frontend/app/api/products/route.ts @@ -1,4 +1,5 @@ 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'; @@ -10,7 +11,18 @@ export async function GET(req: NextRequest) { const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, { cache: 'no-store', }); - + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); +} + +export async function POST(req: NextRequest) { + const authHeaders = await getAuthHeaders(); + const body = await req.json(); + const res = await fetch(`${API_BASE}/api/products`, { + 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/kvitto/ReceiptImportClient.tsx b/frontend/app/kvitto/ReceiptImportClient.tsx index 512d48df..a4bb0521 100644 --- a/frontend/app/kvitto/ReceiptImportClient.tsx +++ b/frontend/app/kvitto/ReceiptImportClient.tsx @@ -23,9 +23,11 @@ type ParsedItem = { }; type Product = { id: number; name: string; canonicalName: string | null }; +type Category = { id: number; name: string; parentId: number | null }; type RowState = { productSearch: string; + selectedCategoryId: number | ''; // för manuellt val vid none utan AI rawName: string; quantity: number; unit: string; @@ -49,11 +51,13 @@ export default function ReceiptImportClient() { const [saving, setSaving] = useState(false); const [rows, setRows] = useState([]); const [allProducts, setAllProducts] = useState([]); + const [allCategories, setAllCategories] = useState([]); const [productsLoading, setProductsLoading] = useState(true); const [productsError, setProductsError] = useState(null); const [error, setError] = useState(null); const [savedCount, setSavedCount] = useState(null); const [selectedFile, setSelectedFile] = useState(null); + const [creatingProduct, setCreatingProduct] = useState(null); useEffect(() => { fetch('/api/products') @@ -70,6 +74,11 @@ export default function ReceiptImportClient() { }) .catch((e) => setProductsError(`Kunde inte ladda produktlistan: ${e.message}`)) .finally(() => setProductsLoading(false)); + + fetch('/api/categories') + .then((r) => r.json()) + .then((data) => { if (Array.isArray(data)) setAllCategories(data); }) + .catch(() => {}); }, []); const handleFileChange = (e: React.ChangeEvent) => { @@ -111,6 +120,7 @@ export default function ReceiptImportClient() { editUnit: item.unit, matchSource: 'alias', productSearch: item.matchedProductName ?? '', + selectedCategoryId: '', }; } if (item.suggestedProductId) { @@ -127,6 +137,7 @@ export default function ReceiptImportClient() { editUnit: item.unit, matchSource: 'suggestion', productSearch: item.suggestedProductName ?? '', + selectedCategoryId: '', }; } return { @@ -143,6 +154,7 @@ export default function ReceiptImportClient() { matchSource: 'none', categorySuggestion: item.categorySuggestion, productSearch: '', + selectedCategoryId: '', }; }), ); @@ -157,14 +169,61 @@ export default function ReceiptImportClient() { setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); }; + const handleCreateProduct = async (i: number) => { + const row = rows[i]; + setCreatingProduct(i); + setError(null); + try { + // Skapa produkt + const createRes = await fetch('/api/products', { + 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: AI-förslag har prioritet, annars manuellt val + 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 }), + }); + } + + // Uppdatera produktlistan lokalt + 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 som matchad + 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 skapa 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; setSaving(true); setError(null); try { - await Promise.all([ - ...toSave.map((r) => + const inventoryResults = await Promise.all( + toSave.map((r) => fetch('/api/inventory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -176,7 +235,15 @@ export default function ReceiptImportClient() { }), }), ), - ...toSave + ); + const failedInventory = inventoryResults.find((r) => !r.ok); + if (failedInventory) { + const e = await failedInventory.json().catch(() => ({})); + throw new Error(e.message ?? `Inventory HTTP ${failedInventory.status}`); + } + + await Promise.all( + toSave .filter((r) => r.saveAlias && r.selectedProductId !== '') .map((r) => fetch('/api/receipt-alias-proxy', { @@ -188,14 +255,15 @@ export default function ReceiptImportClient() { }), }), ), - ]); + ); + setSavedCount(toSave.length); setRows([]); setPreview(null); setSelectedFile(null); if (fileRef.current) fileRef.current.value = ''; - } catch { - setError('Något gick fel vid sparning. Försök igen.'); + } catch (err) { + setError(`Sparning misslyckades: ${err instanceof Error ? err.message : 'Okänt fel'}. Försök igen.`); } finally { setSaving(false); } @@ -203,6 +271,14 @@ export default function ReceiptImportClient() { const checkedCount = rows.filter((r) => r.checked && r.selectedProductId !== '').length; + // Bygg en lista av kategorier under "Övrigt" + "Övrigt" självt som fallback + const ovrigtOptions = (() => { + const ovrigt = allCategories.find((c) => c.name === 'Övrigt' && c.parentId === null); + if (!ovrigt) return allCategories.slice(0, 20); // fallback: visa alla + const children = allCategories.filter((c) => c.parentId === ovrigt.id).sort((a, b) => a.name.localeCompare(b.name, 'sv')); + return [ovrigt, ...children]; + })(); + const sourceLabel = (src: RowState['matchSource']) => { if (src === 'alias') return { text: 'Känd vara', color: '#27ae60' }; if (src === 'suggestion') return { text: 'Förslag', color: '#e67e22' }; @@ -262,7 +338,7 @@ export default function ReceiptImportClient() {

Identifierade varor ({rows.length})

- Grön = känd koppling · Orange = förslag · Skriv för att söka bland {allProducts.length} produkter + 🟢 Känd = automatiskt markerad · 🟠 Förslag = markera för att inkludera · Sök eller skapa ny produkt
@@ -324,10 +400,40 @@ export default function ReceiptImportClient() { {row.categorySuggestion && row.matchSource === 'none' && ( -
- - AI-förslag: {row.categorySuggestion.path} - {row.categorySuggestion.usedFallback && (osäker)} +
+
+ + AI-förslag: {row.categorySuggestion.path} + {row.categorySuggestion.usedFallback && (osäker)} +
+ +
+ )} + {row.matchSource === 'none' && !row.categorySuggestion && ( +
+ +
)} {row.selectedProductId !== '' && row.matchSource !== 'alias' && (