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
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `InventoryItem` ADD COLUMN `origin` VARCHAR(191) NULL;
+1
View File
@@ -89,6 +89,7 @@ model InventoryItem {
quantity Decimal @db.Decimal(10, 2)
unit String
brand String?
origin String?
receiptName String?
location String?
purchaseDate DateTime?
@@ -32,6 +32,10 @@ export class CreateInventoryDto {
@IsString()
brand?: string;
@IsOptional()
@IsString()
origin?: string;
@IsOptional()
@IsString()
receiptName?: string;
@@ -143,6 +143,7 @@ export class InventoryService {
quantity: new Prisma.Decimal(data.quantity),
location: data.location?.trim() || undefined,
brand: data.brand?.trim() || undefined,
origin: data.origin?.trim() || undefined,
receiptName: data.receiptName?.trim() || undefined,
suitableFor: data.suitableFor?.trim() || undefined,
comment: data.comment?.trim() || undefined,
@@ -125,11 +125,20 @@ export class ProductsController {
return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories);
}
@Roles('admin')
@Post()
create(@Body() body: CreateProductDto) {
return this.productsService.create(body);
}
@Post('pending')
createPending(
@Body() body: CreateProductDto,
@Request() req: { user: { id: number } },
) {
return this.productsService.createPending(body, req.user.id);
}
@Roles('admin')
@Post('merge')
merge(@Body() body: MergeProductsDto) {
+31
View File
@@ -427,6 +427,37 @@ export class ProductsService {
});
}
async createPending(data: CreateProductDto, userId: number) {
const name = data.name.trim();
const normalizedName = normalizeName(name);
const existing = await this.prisma.product.findUnique({
where: { normalizedName },
});
if (existing) {
// Om produkten redan finns (aktiv), returnera den direkt
if (existing.isActive && existing.status === 'active') {
return existing;
}
// Om det redan finns ett pending-förslag, returnera det
if (existing.status === 'pending') {
return existing;
}
}
return this.prisma.product.create({
data: {
name,
normalizedName,
canonicalName: name,
isActive: false,
status: 'pending',
ownerId: userId,
},
});
}
setStatus(id: number, status: string) {
return this.prisma.product.update({ where: { id }, data: { status } });
}
@@ -5,6 +5,8 @@ export interface ParsedReceiptItem {
quantity: number;
unit: string;
price?: number | null;
brand?: string | null;
origin?: string | null;
// alias-match: säker, användaren slipper bekräfta
matchedProductId?: number;
matchedProductName?: string;
@@ -19,6 +19,8 @@ Varje vara ska ha följande fält:
- "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5)
- "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska"
- "price": pris i SEK som ett tal, eller null
- "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null
- "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null
Returnera BARA JSON-arrayen utan markdown-formatering.`;
@@ -29,6 +31,8 @@ Varje vara ska ha följande fält:
- "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5)
- "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska"
- "price": pris i SEK som ett tal, eller null
- "brand": märke eller leverantör om det tydligt framgår av varunamnet (t.ex. "Arla", "ICA", "Oatly"), annars null
- "origin": ursprungsland om det framgår av varunamnet (t.ex. "Brasilien", "Sverige", "Italien"), annars null
Returnera BARA JSON-arrayen utan markdown-formatering.
+1 -1
View File
@@ -5,8 +5,8 @@ const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const authHeaders = await getAuthHeaders();
const body = await req.json();
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders },
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthHeaders } from '../../../../lib/auth-headers';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(req: NextRequest) {
const body = await req.json();
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products/pending`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
+1 -1
View File
@@ -16,8 +16,8 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
const authHeaders = await getAuthHeaders();
const body = await req.json();
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
+2 -2
View File
@@ -10,7 +10,7 @@ type Tab = 'kvitto' | 'recept';
type Product = { id: number; name: string; canonicalName: string | null };
export default function ImportTabsClient({ activeTab }: { activeTab: Tab }) {
export default function ImportTabsClient({ activeTab, isAdmin }: { activeTab: Tab; isAdmin: boolean }) {
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1rem' }}>Importera</h1>
@@ -55,7 +55,7 @@ export default function ImportTabsClient({ activeTab }: { activeTab: Tab }) {
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Fotografera eller ladda upp ett kvitto varorna läggs till i ditt inventarie.
</p>
<ReceiptImportClient />
<ReceiptImportClient isAdmin={isAdmin} />
</div>
)}
+4 -1
View File
@@ -1,6 +1,7 @@
import { Metadata } from 'next';
import Navigation from '../Navigation';
import ImportTabsClient from './ImportTabsClient';
import { auth } from '../../auth';
type Props = {
searchParams: Promise<{ tab?: string }>;
@@ -15,10 +16,12 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
export default async function ImportPage({ searchParams }: Props) {
const { tab } = await searchParams;
const activeTab = tab === 'recept' ? 'recept' : 'kvitto';
const session = await auth();
const isAdmin = (session?.user as any)?.role === 'admin';
return (
<>
<Navigation />
<ImportTabsClient activeTab={activeTab} />
<ImportTabsClient activeTab={activeTab} isAdmin={isAdmin} />
</>
);
}
+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' && (