|
|
|
@@ -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,11 +400,41 @@ 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' }}>
|
|
|
|
|
<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' && (
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.5rem', fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}>
|
|
|
|
|