feat(inventory): add origin field to InventoryItem and update related DTOs and services
This commit is contained in:
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user