feat(products): implement category selection and product creation in receipt import

This commit is contained in:
Nils-Johan Gynther
2026-04-19 13:39:26 +02:00
parent 39b91d8c87
commit 632d084dbe
4 changed files with 151 additions and 15 deletions
+5 -3
View File
@@ -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'; const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function GET() { export async function GET(req: NextRequest) {
const res = await fetch(`${API_BASE}/api/categories/tree`, { cache: 'no-store' }); 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(); const text = await res.text();
return new NextResponse(text, { return new NextResponse(text, {
status: res.status, status: res.status,
+16
View File
@@ -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 });
}
+13 -1
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; 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'; 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}` : ''}`, { const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, {
cache: 'no-store', 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(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} }
+117 -11
View File
@@ -23,9 +23,11 @@ type ParsedItem = {
}; };
type Product = { id: number; name: string; canonicalName: string | null }; type Product = { id: number; name: string; canonicalName: string | null };
type Category = { id: number; name: string; parentId: number | null };
type RowState = { type RowState = {
productSearch: string; productSearch: string;
selectedCategoryId: number | ''; // för manuellt val vid none utan AI
rawName: string; rawName: string;
quantity: number; quantity: number;
unit: string; unit: string;
@@ -49,11 +51,13 @@ export default function ReceiptImportClient() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [rows, setRows] = useState<RowState[]>([]); const [rows, setRows] = useState<RowState[]>([]);
const [allProducts, setAllProducts] = useState<Product[]>([]); const [allProducts, setAllProducts] = useState<Product[]>([]);
const [allCategories, setAllCategories] = useState<Category[]>([]);
const [productsLoading, setProductsLoading] = useState(true); const [productsLoading, setProductsLoading] = useState(true);
const [productsError, setProductsError] = useState<string | null>(null); const [productsError, setProductsError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [savedCount, setSavedCount] = useState<number | null>(null); const [savedCount, setSavedCount] = useState<number | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [creatingProduct, setCreatingProduct] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
fetch('/api/products') fetch('/api/products')
@@ -70,6 +74,11 @@ export default function ReceiptImportClient() {
}) })
.catch((e) => setProductsError(`Kunde inte ladda produktlistan: ${e.message}`)) .catch((e) => setProductsError(`Kunde inte ladda produktlistan: ${e.message}`))
.finally(() => setProductsLoading(false)); .finally(() => setProductsLoading(false));
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setAllCategories(data); })
.catch(() => {});
}, []); }, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -111,6 +120,7 @@ export default function ReceiptImportClient() {
editUnit: item.unit, editUnit: item.unit,
matchSource: 'alias', matchSource: 'alias',
productSearch: item.matchedProductName ?? '', productSearch: item.matchedProductName ?? '',
selectedCategoryId: '',
}; };
} }
if (item.suggestedProductId) { if (item.suggestedProductId) {
@@ -127,6 +137,7 @@ export default function ReceiptImportClient() {
editUnit: item.unit, editUnit: item.unit,
matchSource: 'suggestion', matchSource: 'suggestion',
productSearch: item.suggestedProductName ?? '', productSearch: item.suggestedProductName ?? '',
selectedCategoryId: '',
}; };
} }
return { return {
@@ -143,6 +154,7 @@ export default function ReceiptImportClient() {
matchSource: 'none', matchSource: 'none',
categorySuggestion: item.categorySuggestion, categorySuggestion: item.categorySuggestion,
productSearch: '', productSearch: '',
selectedCategoryId: '',
}; };
}), }),
); );
@@ -157,14 +169,61 @@ export default function ReceiptImportClient() {
setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); 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 handleSave = async () => {
const toSave = rows.filter((r) => r.checked && r.selectedProductId !== ''); const toSave = rows.filter((r) => r.checked && r.selectedProductId !== '');
if (toSave.length === 0) return; if (toSave.length === 0) return;
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await Promise.all([ const inventoryResults = await Promise.all(
...toSave.map((r) => toSave.map((r) =>
fetch('/api/inventory', { fetch('/api/inventory', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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 !== '') .filter((r) => r.saveAlias && r.selectedProductId !== '')
.map((r) => .map((r) =>
fetch('/api/receipt-alias-proxy', { fetch('/api/receipt-alias-proxy', {
@@ -188,14 +255,15 @@ export default function ReceiptImportClient() {
}), }),
}), }),
), ),
]); );
setSavedCount(toSave.length); setSavedCount(toSave.length);
setRows([]); setRows([]);
setPreview(null); setPreview(null);
setSelectedFile(null); setSelectedFile(null);
if (fileRef.current) fileRef.current.value = ''; if (fileRef.current) fileRef.current.value = '';
} catch { } catch (err) {
setError('Något gick fel vid sparning. Försök igen.'); setError(`Sparning misslyckades: ${err instanceof Error ? err.message : 'Okänt fel'}. Försök igen.`);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -203,6 +271,14 @@ export default function ReceiptImportClient() {
const checkedCount = rows.filter((r) => r.checked && r.selectedProductId !== '').length; 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']) => { const sourceLabel = (src: RowState['matchSource']) => {
if (src === 'alias') return { text: 'Känd vara', color: '#27ae60' }; if (src === 'alias') return { text: 'Känd vara', color: '#27ae60' };
if (src === 'suggestion') return { text: 'Förslag', color: '#e67e22' }; if (src === 'suggestion') return { text: 'Förslag', color: '#e67e22' };
@@ -262,7 +338,7 @@ export default function ReceiptImportClient() {
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'baseline', flexWrap: 'wrap' }}> <div style={{ marginBottom: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'baseline', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0, fontSize: '1.05rem' }}>Identifierade varor ({rows.length})</h2> <h2 style={{ margin: 0, fontSize: '1.05rem' }}>Identifierade varor ({rows.length})</h2>
<span style={{ fontSize: '0.8rem', color: '#888' }}> <span style={{ fontSize: '0.8rem', color: '#888' }}>
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
</span> </span>
</div> </div>
@@ -324,10 +400,40 @@ export default function ReceiptImportClient() {
</select> </select>
</div> </div>
{row.categorySuggestion && row.matchSource === 'none' && ( {row.categorySuggestion && row.matchSource === 'none' && (
<div style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: '#7c3aed', background: '#f5f3ff', border: '1px solid #ddd6fe', borderRadius: '5px', padding: '4px 8px', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}> <div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<span></span> <div style={{ fontSize: '0.8rem', color: '#7c3aed', background: '#f5f3ff', border: '1px solid #ddd6fe', borderRadius: '5px', padding: '4px 8px', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<span>AI-förslag: <strong>{row.categorySuggestion.path}</strong></span> <span></span>
{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}>(osäker)</span>} <span>AI-förslag: <strong>{row.categorySuggestion.path}</strong></span>
{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}>(osäker)</span>}
</div>
<button
onClick={() => handleCreateProduct(i)}
disabled={creatingProduct === i}
style={{ fontSize: '0.8rem', padding: '3px 10px', background: creatingProduct === i ? '#e5e7eb' : '#f0fdf4', color: creatingProduct === i ? '#9ca3af' : '#166534', border: '1px solid #bbf7d0', borderRadius: '5px', cursor: creatingProduct === i ? 'not-allowed' : 'pointer', fontWeight: 500 }}
>
{creatingProduct === i ? '⏳ Skapar...' : '+ Skapa ny produkt'}
</button>
</div>
)}
{row.matchSource === 'none' && !row.categorySuggestion && (
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<select
value={row.selectedCategoryId}
onChange={(e) => updateRow(i, { selectedCategoryId: e.target.value === '' ? '' : Number(e.target.value) })}
style={{ fontSize: '0.8rem', padding: '3px 6px', border: '1px solid #d1d5db', borderRadius: '5px', color: '#374151', maxWidth: '220px' }}
>
<option value=""> Välj kategori (Övrigt) </option>
{ovrigtOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button
onClick={() => handleCreateProduct(i)}
disabled={creatingProduct === i}
style={{ fontSize: '0.8rem', padding: '3px 10px', background: creatingProduct === i ? '#e5e7eb' : '#f9fafb', color: creatingProduct === i ? '#9ca3af' : '#374151', border: '1px solid #d1d5db', borderRadius: '5px', cursor: creatingProduct === i ? 'not-allowed' : 'pointer' }}
>
{creatingProduct === i ? '⏳ Skapar...' : '+ Skapa ny produkt'}
</button>
</div> </div>
)} )}
{row.selectedProductId !== '' && row.matchSource !== 'alias' && ( {row.selectedProductId !== '' && row.matchSource !== 'alias' && (