feat(inventory): add origin field to InventoryItem and update related DTOs and services
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `InventoryItem` ADD COLUMN `origin` VARCHAR(191) NULL;
|
||||||
@@ -89,6 +89,7 @@ model InventoryItem {
|
|||||||
quantity Decimal @db.Decimal(10, 2)
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
unit String
|
unit String
|
||||||
brand String?
|
brand String?
|
||||||
|
origin String?
|
||||||
receiptName String?
|
receiptName String?
|
||||||
location String?
|
location String?
|
||||||
purchaseDate DateTime?
|
purchaseDate DateTime?
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export class CreateInventoryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
brand?: string;
|
brand?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
origin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
receiptName?: string;
|
receiptName?: string;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export class InventoryService {
|
|||||||
quantity: new Prisma.Decimal(data.quantity),
|
quantity: new Prisma.Decimal(data.quantity),
|
||||||
location: data.location?.trim() || undefined,
|
location: data.location?.trim() || undefined,
|
||||||
brand: data.brand?.trim() || undefined,
|
brand: data.brand?.trim() || undefined,
|
||||||
|
origin: data.origin?.trim() || undefined,
|
||||||
receiptName: data.receiptName?.trim() || undefined,
|
receiptName: data.receiptName?.trim() || undefined,
|
||||||
suitableFor: data.suitableFor?.trim() || undefined,
|
suitableFor: data.suitableFor?.trim() || undefined,
|
||||||
comment: data.comment?.trim() || undefined,
|
comment: data.comment?.trim() || undefined,
|
||||||
|
|||||||
@@ -125,11 +125,20 @@ export class ProductsController {
|
|||||||
return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories);
|
return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() body: CreateProductDto) {
|
create(@Body() body: CreateProductDto) {
|
||||||
return this.productsService.create(body);
|
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')
|
@Roles('admin')
|
||||||
@Post('merge')
|
@Post('merge')
|
||||||
merge(@Body() body: MergeProductsDto) {
|
merge(@Body() body: MergeProductsDto) {
|
||||||
|
|||||||
@@ -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) {
|
setStatus(id: number, status: string) {
|
||||||
return this.prisma.product.update({ where: { id }, data: { status } });
|
return this.prisma.product.update({ where: { id }, data: { status } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface ParsedReceiptItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
|
brand?: string | null;
|
||||||
|
origin?: string | null;
|
||||||
// alias-match: säker, användaren slipper bekräfta
|
// alias-match: säker, användaren slipper bekräfta
|
||||||
matchedProductId?: number;
|
matchedProductId?: number;
|
||||||
matchedProductName?: string;
|
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)
|
- "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"
|
- "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
|
- "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.`;
|
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)
|
- "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"
|
- "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
|
- "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.
|
Returnera BARA JSON-arrayen utan markdown-formatering.
|
||||||
|
|
||||||
|
|||||||
@@ -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 }> }) {
|
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const authHeaders = await getAuthHeaders();
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
const authHeaders = await getAuthHeaders();
|
||||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
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 });
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const authHeaders = await getAuthHeaders();
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
const authHeaders = await getAuthHeaders();
|
||||||
const res = await fetch(`${API_BASE}/api/products`, {
|
const res = await fetch(`${API_BASE}/api/products`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
headers: { 'Content-Type': 'application/json', ...authHeaders },
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Tab = 'kvitto' | 'recept';
|
|||||||
|
|
||||||
type Product = { id: number; name: string; canonicalName: string | null };
|
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 (
|
return (
|
||||||
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
|
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
<h1 style={{ marginBottom: '1rem' }}>Importera</h1>
|
<h1 style={{ marginBottom: '1rem' }}>Importera</h1>
|
||||||
@@ -55,7 +55,7 @@ export default function ImportTabsClient({ activeTab }: { activeTab: Tab }) {
|
|||||||
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
|
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
|
||||||
Fotografera eller ladda upp ett kvitto — varorna läggs till i ditt inventarie.
|
Fotografera eller ladda upp ett kvitto — varorna läggs till i ditt inventarie.
|
||||||
</p>
|
</p>
|
||||||
<ReceiptImportClient />
|
<ReceiptImportClient isAdmin={isAdmin} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import Navigation from '../Navigation';
|
import Navigation from '../Navigation';
|
||||||
import ImportTabsClient from './ImportTabsClient';
|
import ImportTabsClient from './ImportTabsClient';
|
||||||
|
import { auth } from '../../auth';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
searchParams: Promise<{ tab?: string }>;
|
searchParams: Promise<{ tab?: string }>;
|
||||||
@@ -15,10 +16,12 @@ export async function generateMetadata({ searchParams }: Props): Promise<Metadat
|
|||||||
export default async function ImportPage({ searchParams }: Props) {
|
export default async function ImportPage({ searchParams }: Props) {
|
||||||
const { tab } = await searchParams;
|
const { tab } = await searchParams;
|
||||||
const activeTab = tab === 'recept' ? 'recept' : 'kvitto';
|
const activeTab = tab === 'recept' ? 'recept' : 'kvitto';
|
||||||
|
const session = await auth();
|
||||||
|
const isAdmin = (session?.user as any)?.role === 'admin';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<ImportTabsClient activeTab={activeTab} />
|
<ImportTabsClient activeTab={activeTab} isAdmin={isAdmin} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ type ParsedItem = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
|
brand?: string | null;
|
||||||
|
origin?: string | null;
|
||||||
matchedProductId?: number;
|
matchedProductId?: number;
|
||||||
matchedProductName?: string;
|
matchedProductName?: string;
|
||||||
suggestedProductId?: number;
|
suggestedProductId?: number;
|
||||||
@@ -38,13 +40,16 @@ type RowState = {
|
|||||||
saveAlias: boolean;
|
saveAlias: boolean;
|
||||||
editQty: string;
|
editQty: string;
|
||||||
editUnit: string;
|
editUnit: string;
|
||||||
|
editBrand: string;
|
||||||
|
editOrigin: string;
|
||||||
|
editComment: string;
|
||||||
matchSource: 'alias' | 'suggestion' | 'manual' | 'none';
|
matchSource: 'alias' | 'suggestion' | 'manual' | 'none';
|
||||||
categorySuggestion?: CategorySuggestion;
|
categorySuggestion?: CategorySuggestion;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska'];
|
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 fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
const [parsing, setParsing] = useState(false);
|
const [parsing, setParsing] = useState(false);
|
||||||
@@ -118,6 +123,9 @@ export default function ReceiptImportClient() {
|
|||||||
saveAlias: false,
|
saveAlias: false,
|
||||||
editQty: String(item.quantity),
|
editQty: String(item.quantity),
|
||||||
editUnit: item.unit,
|
editUnit: item.unit,
|
||||||
|
editBrand: item.brand ?? '',
|
||||||
|
editOrigin: item.origin ?? '',
|
||||||
|
editComment: '',
|
||||||
matchSource: 'alias',
|
matchSource: 'alias',
|
||||||
productSearch: item.matchedProductName ?? '',
|
productSearch: item.matchedProductName ?? '',
|
||||||
selectedCategoryId: '',
|
selectedCategoryId: '',
|
||||||
@@ -135,6 +143,9 @@ export default function ReceiptImportClient() {
|
|||||||
saveAlias: false,
|
saveAlias: false,
|
||||||
editQty: String(item.quantity),
|
editQty: String(item.quantity),
|
||||||
editUnit: item.unit,
|
editUnit: item.unit,
|
||||||
|
editBrand: item.brand ?? '',
|
||||||
|
editOrigin: item.origin ?? '',
|
||||||
|
editComment: '',
|
||||||
matchSource: 'suggestion',
|
matchSource: 'suggestion',
|
||||||
productSearch: item.suggestedProductName ?? '',
|
productSearch: item.suggestedProductName ?? '',
|
||||||
selectedCategoryId: '',
|
selectedCategoryId: '',
|
||||||
@@ -151,6 +162,9 @@ export default function ReceiptImportClient() {
|
|||||||
saveAlias: false,
|
saveAlias: false,
|
||||||
editQty: String(item.quantity),
|
editQty: String(item.quantity),
|
||||||
editUnit: item.unit,
|
editUnit: item.unit,
|
||||||
|
editBrand: item.brand ?? '',
|
||||||
|
editOrigin: item.origin ?? '',
|
||||||
|
editComment: '',
|
||||||
matchSource: 'none',
|
matchSource: 'none',
|
||||||
categorySuggestion: item.categorySuggestion,
|
categorySuggestion: item.categorySuggestion,
|
||||||
productSearch: '',
|
productSearch: '',
|
||||||
@@ -174,7 +188,7 @@ export default function ReceiptImportClient() {
|
|||||||
setCreatingProduct(i);
|
setCreatingProduct(i);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Skapa produkt
|
// Admin skapar aktiv produkt direkt
|
||||||
const createRes = await fetch('/api/products', {
|
const createRes = await fetch('/api/products', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 handleSave = async () => {
|
||||||
const toSave = rows.filter((r) => r.checked && r.selectedProductId !== '');
|
const toSave = rows.filter((r) => r.checked && r.selectedProductId !== '');
|
||||||
if (toSave.length === 0) return;
|
if (toSave.length === 0) return;
|
||||||
@@ -232,6 +293,9 @@ export default function ReceiptImportClient() {
|
|||||||
quantity: parseFloat(r.editQty) || r.quantity,
|
quantity: parseFloat(r.editQty) || r.quantity,
|
||||||
unit: r.editUnit,
|
unit: r.editUnit,
|
||||||
receiptName: r.rawName,
|
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 && (
|
{rows.length > 0 && (
|
||||||
<div style={{ marginTop: '1.25rem' }}>
|
<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' }}>
|
<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>
|
<h2 style={{ margin: 0, fontSize: '1.05rem' }}>Identifierade varor ({rows.length})</h2>
|
||||||
<span style={{ fontSize: '0.8rem', color: '#888' }}>
|
<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>
|
</span>
|
||||||
</div>
|
</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>
|
<span style={{ fontSize: '0.75rem', color: label.color, border: `1px solid ${label.color}`, borderRadius: '4px', padding: '1px 6px' }}>{label.text}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<input
|
<input
|
||||||
list={`products-${i}`}
|
list={`products-${i}`}
|
||||||
@@ -399,6 +468,31 @@ export default function ReceiptImportClient() {
|
|||||||
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
|
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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' && (
|
{row.categorySuggestion && row.matchSource === 'none' && (
|
||||||
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<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' }}>
|
<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>
|
<span>AI-förslag: <strong>{row.categorySuggestion.path}</strong></span>
|
||||||
{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}>(osäker)</span>}
|
{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}>(osäker)</span>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
{isAdmin ? (
|
||||||
onClick={() => handleCreateProduct(i)}
|
<button
|
||||||
disabled={creatingProduct === i}
|
onClick={() => handleCreateProduct(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 }}
|
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>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{row.matchSource === 'none' && !row.categorySuggestion && (
|
{row.matchSource === 'none' && !row.categorySuggestion && (
|
||||||
@@ -427,13 +531,23 @@ export default function ReceiptImportClient() {
|
|||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
{isAdmin ? (
|
||||||
onClick={() => handleCreateProduct(i)}
|
<button
|
||||||
disabled={creatingProduct === i}
|
onClick={() => handleCreateProduct(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' }}
|
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>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{row.selectedProductId !== '' && row.matchSource !== 'alias' && (
|
{row.selectedProductId !== '' && row.matchSource !== 'alias' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user