feat(inventory): add origin field to InventoryItem and update related DTOs and services

This commit is contained in:
Nils-Johan Gynther
2026-04-19 15:11:35 +02:00
parent 3b0208b5b4
commit 976a72612e
14 changed files with 210 additions and 23 deletions
+132 -18
View File
@@ -15,6 +15,8 @@ type ParsedItem = {
quantity: number;
unit: string;
price?: number | null;
brand?: string | null;
origin?: string | null;
matchedProductId?: number;
matchedProductName?: string;
suggestedProductId?: number;
@@ -38,13 +40,16 @@ type RowState = {
saveAlias: boolean;
editQty: string;
editUnit: string;
editBrand: string;
editOrigin: string;
editComment: 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() {
export default function ReceiptImportClient({ isAdmin }: { isAdmin: boolean }) {
const fileRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<string | null>(null);
const [parsing, setParsing] = useState(false);
@@ -118,6 +123,9 @@ export default function ReceiptImportClient() {
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
editBrand: item.brand ?? '',
editOrigin: item.origin ?? '',
editComment: '',
matchSource: 'alias',
productSearch: item.matchedProductName ?? '',
selectedCategoryId: '',
@@ -135,6 +143,9 @@ export default function ReceiptImportClient() {
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
editBrand: item.brand ?? '',
editOrigin: item.origin ?? '',
editComment: '',
matchSource: 'suggestion',
productSearch: item.suggestedProductName ?? '',
selectedCategoryId: '',
@@ -151,6 +162,9 @@ export default function ReceiptImportClient() {
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
editBrand: item.brand ?? '',
editOrigin: item.origin ?? '',
editComment: '',
matchSource: 'none',
categorySuggestion: item.categorySuggestion,
productSearch: '',
@@ -174,7 +188,7 @@ export default function ReceiptImportClient() {
setCreatingProduct(i);
setError(null);
try {
// Skapa produkt
// Admin skapar aktiv produkt direkt
const createRes = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -216,6 +230,53 @@ export default function ReceiptImportClient() {
}
};
const handleSuggestProduct = async (i: number) => {
const row = rows[i];
setCreatingProduct(i);
setError(null);
try {
// Användare skapar ett pending-förslag
const createRes = await fetch('/api/products/pending', {
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 om vald/föreslagen
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 }),
});
}
// Lägg till i lokal lista (men markera som pending)
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 — pending = kan läggas till i inventariet men väntar på admin-godkännande
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 föreslå 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;
@@ -232,6 +293,9 @@ export default function ReceiptImportClient() {
quantity: parseFloat(r.editQty) || r.quantity,
unit: r.editUnit,
receiptName: r.rawName,
brand: r.editBrand.trim() || undefined,
origin: r.editOrigin.trim() || undefined,
comment: r.editComment.trim() || undefined,
}),
}),
),
@@ -335,10 +399,15 @@ export default function ReceiptImportClient() {
{rows.length > 0 && (
<div style={{ marginTop: '1.25rem' }}>
{!isAdmin && (
<div style={{ fontSize: '0.82rem', color: '#92400e', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '6px', padding: '0.6rem 0.9rem', marginBottom: '0.75rem' }}>
<strong>Tips:</strong> Om en vara saknas kan du klicka <em>Föreslå ny vara</em> varan läggs till i inventariet och skickas för granskning av en administratör.
</div>
)}
<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
🟢 Känd = automatiskt markerad · 🟠 Förslag = markera för att inkludera · {isAdmin ? 'Sök eller skapa ny produkt' : 'Sök eller föreslå ny vara'}
</span>
</div>
@@ -354,7 +423,7 @@ export default function ReceiptImportClient() {
<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={{ display: 'grid', gridTemplateColumns: '1fr 120px 80px 90px', gap: '0.5rem', alignItems: 'center' }}>
<div style={{ position: 'relative' }}>
<input
list={`products-${i}`}
@@ -399,6 +468,31 @@ export default function ReceiptImportClient() {
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
</div>
<div style={{ marginTop: '0.4rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.4rem' }}>
<input
type="text"
value={row.editBrand}
onChange={(e) => updateRow(i, { editBrand: e.target.value })}
placeholder="Märke / leverantör (valfritt)"
style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555' }}
/>
<input
type="text"
value={row.editOrigin}
onChange={(e) => updateRow(i, { editOrigin: e.target.value })}
placeholder="Ursprungsland (valfritt)"
style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555' }}
/>
</div>
<div style={{ marginTop: '0.4rem' }}>
<input
type="text"
value={row.editComment}
onChange={(e) => updateRow(i, { editComment: e.target.value })}
placeholder="Kommentar, t.ex. styckning, kvalitet... (valfritt)"
style={{ width: '100%', padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555', boxSizing: 'border-box' }}
/>
</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' }}>
@@ -406,13 +500,23 @@ export default function ReceiptImportClient() {
<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>
{isAdmin ? (
<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>
) : (
<button
onClick={() => handleSuggestProduct(i)}
disabled={creatingProduct === i}
style={{ fontSize: '0.8rem', padding: '3px 10px', background: creatingProduct === i ? '#e5e7eb' : '#fefce8', color: creatingProduct === i ? '#9ca3af' : '#854d0e', border: '1px solid #fde68a', borderRadius: '5px', cursor: creatingProduct === i ? 'not-allowed' : 'pointer', fontWeight: 500 }}
>
{creatingProduct === i ? '⏳ Skickar...' : '+ Föreslå ny vara'}
</button>
)}
</div>
)}
{row.matchSource === 'none' && !row.categorySuggestion && (
@@ -427,13 +531,23 @@ export default function ReceiptImportClient() {
<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>
{isAdmin ? (
<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>
) : (
<button
onClick={() => handleSuggestProduct(i)}
disabled={creatingProduct === i}
style={{ fontSize: '0.8rem', padding: '3px 10px', background: creatingProduct === i ? '#e5e7eb' : '#fefce8', color: creatingProduct === i ? '#9ca3af' : '#854d0e', border: '1px solid #fde68a', borderRadius: '5px', cursor: creatingProduct === i ? 'not-allowed' : 'pointer' }}
>
{creatingProduct === i ? '⏳ Skickar...' : '+ Föreslå ny vara'}
</button>
)}
</div>
)}
{row.selectedProductId !== '' && row.matchSource !== 'alias' && (