feat(receipt-import): enhance product search functionality and error handling

This commit is contained in:
Nils-Johan Gynther
2026-04-19 11:23:20 +02:00
parent cd14e59ff8
commit f2f9f46502
2 changed files with 80 additions and 28 deletions
+67 -27
View File
@@ -25,6 +25,7 @@ type ParsedItem = {
type Product = { id: number; name: string; canonicalName: string | null };
type RowState = {
productSearch: string;
rawName: string;
quantity: number;
unit: string;
@@ -48,15 +49,27 @@ export default function ReceiptImportClient() {
const [saving, setSaving] = useState(false);
const [rows, setRows] = useState<RowState[]>([]);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(true);
const [productsError, setProductsError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedCount, setSavedCount] = useState<number | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
useEffect(() => {
fetch('/api/products')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setAllProducts(data); })
.catch(() => {});
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data) => {
if (Array.isArray(data)) {
setAllProducts(data);
} else {
setProductsError('Oväntat format från produktlistan');
}
})
.catch((e) => setProductsError(`Kunde inte ladda produktlistan: ${e.message}`))
.finally(() => setProductsLoading(false));
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -97,6 +110,7 @@ export default function ReceiptImportClient() {
editQty: String(item.quantity),
editUnit: item.unit,
matchSource: 'alias',
productSearch: item.matchedProductName ?? '',
};
}
if (item.suggestedProductId) {
@@ -112,6 +126,7 @@ export default function ReceiptImportClient() {
editQty: String(item.quantity),
editUnit: item.unit,
matchSource: 'suggestion',
productSearch: item.suggestedProductName ?? '',
};
}
return {
@@ -127,6 +142,7 @@ export default function ReceiptImportClient() {
editUnit: item.unit,
matchSource: 'none',
categorySuggestion: item.categorySuggestion,
productSearch: '',
};
}),
);
@@ -141,23 +157,6 @@ export default function ReceiptImportClient() {
setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
};
const handleProductSelect = (i: number, productId: string) => {
if (!productId) {
updateRow(i, { selectedProductId: '', selectedProductName: '', checked: false, matchSource: 'none' });
return;
}
const prod = allProducts.find((p) => p.id === Number(productId));
if (prod) {
updateRow(i, {
selectedProductId: prod.id,
selectedProductName: prod.canonicalName ?? prod.name,
checked: true,
matchSource: 'manual',
saveAlias: true,
});
}
};
const handleSave = async () => {
const toSave = rows.filter((r) => r.checked && r.selectedProductId !== '');
if (toSave.length === 0) return;
@@ -236,6 +235,12 @@ export default function ReceiptImportClient() {
)}
</div>
{productsError && (
<p style={{ color: '#c0392b', background: '#fdf0ef', padding: '0.75rem 1rem', borderRadius: '6px', marginTop: '0.75rem', fontSize: '0.9rem' }}>
{productsError}
</p>
)}
{preview && rows.length === 0 && (
<button onClick={handleParse} disabled={parsing} style={primaryBtn(parsing)}>
{parsing ? '⏳ Läser kvitto...' : '🔍 Läs kvitto'}
@@ -256,7 +261,9 @@ export default function ReceiptImportClient() {
<div style={{ marginTop: '1.25rem' }}>
<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>
<span style={{ fontSize: '0.8rem', color: '#888' }}>Grön = känd koppling · Orange = förslag · Välj manuellt om inget förslag</span>
<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
</span>
</div>
<div style={{ display: 'grid', gap: '0.5rem', marginBottom: '1rem' }}>
@@ -272,12 +279,45 @@ export default function ReceiptImportClient() {
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 90px', gap: '0.5rem', alignItems: 'center' }}>
<select value={row.selectedProductId} onChange={(e) => handleProductSelect(i, e.target.value)} style={{ padding: '0.35rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.9rem' }}>
<option value=""> Välj produkt </option>
{allProducts.map((p) => (
<option key={p.id} value={p.id}>{p.canonicalName ?? p.name}</option>
))}
</select>
<div style={{ position: 'relative' }}>
<input
list={`products-${i}`}
value={row.productSearch}
onChange={(e) => {
const val = e.target.value;
updateRow(i, { productSearch: val });
// Hitta exakt match
const match = allProducts.find(
(p) => (p.canonicalName ?? p.name) === val
);
if (match) {
updateRow(i, {
productSearch: val,
selectedProductId: match.id,
selectedProductName: match.canonicalName ?? match.name,
checked: true,
matchSource: row.matchSource === 'alias' ? 'alias' : 'manual',
saveAlias: row.matchSource !== 'alias',
});
} else {
updateRow(i, {
productSearch: val,
selectedProductId: '',
selectedProductName: '',
checked: false,
});
}
}}
placeholder={productsLoading ? 'Laddar produkter...' : 'Sök produkt...'}
disabled={productsLoading}
style={{ width: '100%', padding: '0.35rem 0.5rem', border: `1px solid ${row.selectedProductId !== '' ? '#22c55e' : '#ced4da'}`, borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' }}
/>
<datalist id={`products-${i}`}>
{allProducts.map((p) => (
<option key={p.id} value={p.canonicalName ?? p.name} />
))}
</datalist>
</div>
<input type="number" min="0" step="0.01" value={row.editQty} onChange={(e) => updateRow(i, { editQty: e.target.value })} style={{ padding: '0.35rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.9rem' }} />
<select value={row.editUnit} onChange={(e) => updateRow(i, { editUnit: e.target.value })} style={{ padding: '0.35rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.9rem' }}>
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}