feat(receipt-import): enhance product search functionality and error handling
This commit is contained in:
@@ -76,6 +76,11 @@ export default function AiAdminClient({ keyHint, hasKey, aiFunctions }: Props) {
|
|||||||
<h2 style={{ fontSize: '1.05rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<h2 style={{ fontSize: '1.05rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
🔑 Mistral API-nyckel
|
🔑 Mistral API-nyckel
|
||||||
</h2>
|
</h2>
|
||||||
|
{!hasKey && (
|
||||||
|
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.9rem', color: '#991b1b' }}>
|
||||||
|
⚠️ <strong>MISTRAL_API_KEY är inte konfigurerad</strong> — alla AI-funktioner är inaktiva tills nyckeln sätts i miljövariablerna.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1.5rem', alignItems: 'center', marginBottom: '1.25rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1.5rem', alignItems: 'center', marginBottom: '1.25rem' }}>
|
||||||
<span style={{ color: '#555', fontSize: '0.9rem' }}>Status</span>
|
<span style={{ color: '#555', fontSize: '0.9rem' }}>Status</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -135,6 +140,7 @@ export default function AiAdminClient({ keyHint, hasKey, aiFunctions }: Props) {
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
|
||||||
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Status</th>
|
||||||
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Funktion</th>
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Funktion</th>
|
||||||
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Modell</th>
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Modell</th>
|
||||||
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Åtkomst</th>
|
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Åtkomst</th>
|
||||||
@@ -144,8 +150,14 @@ export default function AiAdminClient({ keyHint, hasKey, aiFunctions }: Props) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{aiFunctions.map((fn, i) => (
|
{aiFunctions.map((fn, i) => (
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #f3f4f6', verticalAlign: 'top' }}>
|
<tr key={i} style={{ borderBottom: '1px solid #f3f4f6', verticalAlign: 'top', opacity: hasKey ? 1 : 0.55 }}>
|
||||||
<td style={{ padding: '0.65rem 0.75rem' }}>
|
<td style={{ padding: '0.65rem 0.75rem' }}>
|
||||||
|
{hasKey ? (
|
||||||
|
<span title="Aktiv — API-nyckel konfigurerad" style={{ fontSize: '1.1rem' }}>✅</span>
|
||||||
|
) : (
|
||||||
|
<span title="Inaktiv — MISTRAL_API_KEY saknas" style={{ fontSize: '1.1rem' }}>🔴</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<div style={{ fontWeight: 500, marginBottom: '0.2rem' }}>{fn.name}</div>
|
<div style={{ fontWeight: 500, marginBottom: '0.2rem' }}>{fn.name}</div>
|
||||||
<div style={{ color: '#6b7280', fontSize: '0.8rem', lineHeight: 1.4 }}>{fn.description}</div>
|
<div style={{ color: '#6b7280', fontSize: '0.8rem', lineHeight: 1.4 }}>{fn.description}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type ParsedItem = {
|
|||||||
type Product = { id: number; name: string; canonicalName: string | null };
|
type Product = { id: number; name: string; canonicalName: string | null };
|
||||||
|
|
||||||
type RowState = {
|
type RowState = {
|
||||||
|
productSearch: string;
|
||||||
rawName: string;
|
rawName: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
@@ -48,15 +49,27 @@ 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 [productsLoading, setProductsLoading] = useState(true);
|
||||||
|
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/products')
|
fetch('/api/products')
|
||||||
.then((r) => r.json())
|
.then(async (r) => {
|
||||||
.then((data) => { if (Array.isArray(data)) setAllProducts(data); })
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
.catch(() => {});
|
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>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -97,6 +110,7 @@ export default function ReceiptImportClient() {
|
|||||||
editQty: String(item.quantity),
|
editQty: String(item.quantity),
|
||||||
editUnit: item.unit,
|
editUnit: item.unit,
|
||||||
matchSource: 'alias',
|
matchSource: 'alias',
|
||||||
|
productSearch: item.matchedProductName ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (item.suggestedProductId) {
|
if (item.suggestedProductId) {
|
||||||
@@ -112,6 +126,7 @@ export default function ReceiptImportClient() {
|
|||||||
editQty: String(item.quantity),
|
editQty: String(item.quantity),
|
||||||
editUnit: item.unit,
|
editUnit: item.unit,
|
||||||
matchSource: 'suggestion',
|
matchSource: 'suggestion',
|
||||||
|
productSearch: item.suggestedProductName ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -127,6 +142,7 @@ export default function ReceiptImportClient() {
|
|||||||
editUnit: item.unit,
|
editUnit: item.unit,
|
||||||
matchSource: 'none',
|
matchSource: 'none',
|
||||||
categorySuggestion: item.categorySuggestion,
|
categorySuggestion: item.categorySuggestion,
|
||||||
|
productSearch: '',
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -141,23 +157,6 @@ 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 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 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;
|
||||||
@@ -236,6 +235,12 @@ export default function ReceiptImportClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{preview && rows.length === 0 && (
|
||||||
<button onClick={handleParse} disabled={parsing} style={primaryBtn(parsing)}>
|
<button onClick={handleParse} disabled={parsing} style={primaryBtn(parsing)}>
|
||||||
{parsing ? '⏳ Läser kvitto...' : '🔍 Läs kvitto'}
|
{parsing ? '⏳ Läser kvitto...' : '🔍 Läs kvitto'}
|
||||||
@@ -256,7 +261,9 @@ export default function ReceiptImportClient() {
|
|||||||
<div style={{ marginTop: '1.25rem' }}>
|
<div style={{ marginTop: '1.25rem' }}>
|
||||||
<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' }}>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>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: '0.5rem', marginBottom: '1rem' }}>
|
<div style={{ display: 'grid', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||||
@@ -272,12 +279,45 @@ export default function ReceiptImportClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 90px', gap: '0.5rem', alignItems: 'center' }}>
|
<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' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<option value="">— Välj produkt —</option>
|
<input
|
||||||
{allProducts.map((p) => (
|
list={`products-${i}`}
|
||||||
<option key={p.id} value={p.id}>{p.canonicalName ?? p.name}</option>
|
value={row.productSearch}
|
||||||
))}
|
onChange={(e) => {
|
||||||
</select>
|
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' }} />
|
<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' }}>
|
<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>)}
|
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
|
||||||
|
|||||||
Reference in New Issue
Block a user