Files
recipe-app/frontend/app/kvitto/ReceiptImportClient.tsx
T

467 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useRef, useState, useEffect } from 'react';
type CategorySuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
type ParsedItem = {
rawName: string;
quantity: number;
unit: string;
price?: number | null;
matchedProductId?: number;
matchedProductName?: string;
suggestedProductId?: number;
suggestedProductName?: string;
categorySuggestion?: CategorySuggestion;
};
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;
price?: number | null;
selectedProductId: number | '';
selectedProductName: string;
checked: boolean;
saveAlias: boolean;
editQty: string;
editUnit: string;
matchSource: 'alias' | 'suggestion' | 'manual' | 'none';
categorySuggestion?: CategorySuggestion;
};
const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska'];
export default function ReceiptImportClient() {
const fileRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [parsing, setParsing] = useState(false);
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')
.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));
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setAllCategories(data); })
.catch(() => {});
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setPreview(file.type === 'application/pdf' ? 'pdf' : URL.createObjectURL(file));
setRows([]);
setError(null);
setSavedCount(null);
};
const handleParse = async () => {
if (!selectedFile) return;
setParsing(true);
setError(null);
try {
const fd = new FormData();
fd.append('file', selectedFile);
const res = await fetch('/api/receipt-import-proxy', { method: 'POST', body: fd });
if (!res.ok) {
const e = await res.json().catch(() => ({ message: 'Okänt fel' }));
throw new Error(e.message ?? 'Servern svarade med fel');
}
const items: ParsedItem[] = await res.json();
setRows(
items.map((item): RowState => {
if (item.matchedProductId) {
return {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
price: item.price,
selectedProductId: item.matchedProductId,
selectedProductName: item.matchedProductName ?? '',
checked: true,
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
matchSource: 'alias',
productSearch: item.matchedProductName ?? '',
selectedCategoryId: '',
};
}
if (item.suggestedProductId) {
return {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
price: item.price,
selectedProductId: item.suggestedProductId,
selectedProductName: item.suggestedProductName ?? '',
checked: false,
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
matchSource: 'suggestion',
productSearch: item.suggestedProductName ?? '',
selectedCategoryId: '',
};
}
return {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
price: item.price,
selectedProductId: '',
selectedProductName: '',
checked: false,
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
matchSource: 'none',
categorySuggestion: item.categorySuggestion,
productSearch: '',
selectedCategoryId: '',
};
}),
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte tolka kvittot');
} finally {
setParsing(false);
}
};
const updateRow = (i: number, patch: Partial<RowState>) => {
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 {
const inventoryResults = await Promise.all(
toSave.map((r) =>
fetch('/api/inventory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: r.selectedProductId,
quantity: parseFloat(r.editQty) || r.quantity,
unit: r.editUnit,
receiptName: r.rawName,
}),
}),
),
);
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
receiptName: r.rawName,
productId: r.selectedProductId,
}),
}),
),
);
setSavedCount(toSave.length);
setRows([]);
setPreview(null);
setSelectedFile(null);
if (fileRef.current) fileRef.current.value = '';
} catch (err) {
setError(`Sparning misslyckades: ${err instanceof Error ? err.message : 'Okänt fel'}. Försök igen.`);
} finally {
setSaving(false);
}
};
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' };
if (src === 'manual') return { text: 'Manuellt vald', color: '#0070f3' };
return null;
};
return (
<div>
<div
style={{ border: '2px dashed #ced4da', borderRadius: '10px', padding: '1.5rem', textAlign: 'center', background: '#fafafa', marginBottom: '1rem', cursor: 'pointer' }}
onClick={() => fileRef.current?.click()}
>
<input ref={fileRef} type="file" accept="image/*,application/pdf" capture="environment" onChange={handleFileChange} style={{ display: 'none' }} />
{preview === 'pdf' ? (
<div style={{ padding: '1rem' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📄</div>
<div style={{ fontWeight: 600 }}>{selectedFile?.name}</div>
<div style={{ fontSize: '0.85rem', color: '#888', marginTop: '0.25rem' }}>PDF-kvitto valt</div>
</div>
) : preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Kvittoförhandsgranskning" style={{ maxHeight: '300px', maxWidth: '100%', borderRadius: '6px' }} />
) : (
<div style={{ color: '#888' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📷</div>
<div style={{ fontWeight: 600 }}>Fotografera eller välj kvitto</div>
<div style={{ fontSize: '0.85rem', marginTop: '0.25rem' }}>Klicka för att välja bild (JPEG, PNG, WebP) eller PDF</div>
</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 && (
<button onClick={handleParse} disabled={parsing} style={primaryBtn(parsing)}>
{parsing ? '⏳ Läser kvitto...' : '🔍 Läs kvitto'}
</button>
)}
{error && (
<p style={{ color: '#c0392b', background: '#fdf0ef', padding: '0.75rem 1rem', borderRadius: '6px', marginTop: '0.75rem' }}>{error}</p>
)}
{savedCount !== null && (
<p style={{ color: '#27ae60', background: '#edfdf4', padding: '0.75rem 1rem', borderRadius: '6px', marginTop: '0.75rem', fontWeight: 600 }}>
{savedCount} {savedCount === 1 ? 'vara lades till' : 'varor lades till'} i inventariet.
</p>
)}
{rows.length > 0 && (
<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' }}>
🟢 Känd = automatiskt markerad · 🟠 Förslag = markera för att inkludera · Sök eller skapa ny produkt
</span>
</div>
<div style={{ display: 'grid', gap: '0.5rem', marginBottom: '1rem' }}>
{rows.map((row, i) => {
const label = sourceLabel(row.matchSource);
return (
<div key={i} style={{ padding: '0.75rem 1rem', border: `1px solid ${row.matchSource === 'alias' ? '#a8d5b5' : row.matchSource === 'suggestion' ? '#f5cba7' : '#dee2e6'}`, borderRadius: '8px', background: row.matchSource === 'alias' ? '#f0faf4' : row.matchSource === 'suggestion' ? '#fef9f5' : '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
<input type="checkbox" checked={row.checked} disabled={row.selectedProductId === ''} onChange={(e) => updateRow(i, { checked: e.target.checked })} style={{ width: '18px', height: '18px', cursor: row.selectedProductId !== '' ? 'pointer' : 'not-allowed' }} />
<span style={{ fontWeight: 500 }}>{row.rawName}</span>
{label && (
<span style={{ fontSize: '0.75rem', color: label.color, border: `1px solid ${label.color}`, borderRadius: '4px', padding: '1px 6px' }}>{label.text}</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 90px', gap: '0.5rem', alignItems: 'center' }}>
<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>)}
</select>
</div>
{row.categorySuggestion && row.matchSource === 'none' && (
<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' }}>
<input type="checkbox" checked={row.saveAlias} onChange={(e) => updateRow(i, { saveAlias: e.target.checked })} />
Kom ihåg kopplingen nästa gång matchas &quot;{row.rawName}&quot; automatiskt
</label>
)}
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<button onClick={handleSave} disabled={saving || checkedCount === 0} style={primaryBtn(saving || checkedCount === 0)}>
{saving ? 'Sparar...' : `Lägg till ${checkedCount} ${checkedCount === 1 ? 'vara' : 'varor'} i inventariet`}
</button>
<button onClick={() => { setRows([]); setPreview(null); setSelectedFile(null); if (fileRef.current) fileRef.current.value = ''; }} style={{ padding: '0.5rem 1rem', background: '#f0f0f0', border: '1px solid #ccc', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem' }}>
Börja om
</button>
</div>
</div>
)}
</div>
);
}
function primaryBtn(disabled: boolean): React.CSSProperties {
return { padding: '0.6rem 1.25rem', background: disabled ? '#aaa' : '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 600, fontSize: '0.95rem' };
}