feat(products): implement category selection and product creation in receipt import
This commit is contained in:
@@ -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<RowState[]>([]);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [allCategories, setAllCategories] = useState<Category[]>([]);
|
||||
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);
|
||||
const [creatingProduct, setCreatingProduct] = useState<number | null>(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<HTMLInputElement>) => {
|
||||
@@ -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() {
|
||||
<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 · 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>
|
||||
</div>
|
||||
|
||||
@@ -324,10 +400,40 @@ export default function ReceiptImportClient() {
|
||||
</select>
|
||||
</div>
|
||||
{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' }}>
|
||||
<span>✨</span>
|
||||
<span>AI-förslag: <strong>{row.categorySuggestion.path}</strong></span>
|
||||
{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}>(osäker)</span>}
|
||||
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<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>✨</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>
|
||||
)}
|
||||
{row.selectedProductId !== '' && row.matchSource !== 'alias' && (
|
||||
|
||||
Reference in New Issue
Block a user