MAJOR UPPDATE: "First Ai"

feat: add AI categorization for products and enhance user management

- Integrated AI service for category suggestions in receipt import and product management.
- Added premium subscription feature for users with corresponding API endpoints.
- Implemented admin interface for managing pending product suggestions.
- Enhanced user management to include premium status and corresponding UI updates.
- Updated database schema to support new fields for premium status and product status.
This commit is contained in:
Nils-Johan Gynther
2026-04-19 10:34:21 +02:00
parent 0286ab0991
commit 054a19ed7c
30 changed files with 917 additions and 77 deletions
@@ -3,10 +3,22 @@
import { useState, useMemo, useEffect, useTransition } from 'react';
import type { Product, Category } from '../../../features/inventory/types';
import EditProductForm from './EditProductForm';
import { bulkSetCategory } from './actions';
import { bulkSetCategory, suggestBulkCategories } from './actions';
type CategoryNode = Category & { children: CategoryNode[] };
type AiSuggestion = {
productId: number;
productName: string;
suggestion: {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
};
type Props = {
products: Product[];
};
@@ -36,6 +48,13 @@ export default function AdminProductList({ products }: Props) {
const [isPending, startTransition] = useTransition();
const [bulkError, setBulkError] = useState<string | null>(null);
// AI-kategorisering state
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestion[] | null>(null);
const [aiApproved, setAiApproved] = useState<Set<number>>(new Set());
const [aiApplying, setAiApplying] = useState(false);
useEffect(() => {
fetch('/api/categories')
.then((r) => r.json())
@@ -114,6 +133,44 @@ export default function AdminProductList({ products }: Props) {
});
};
const handleAiCategorize = async () => {
setAiLoading(true);
setAiError(null);
setAiSuggestions(null);
try {
const results = await suggestBulkCategories();
setAiSuggestions(results);
setAiApproved(new Set(results.map((r) => r.productId)));
} catch (err) {
setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades');
} finally {
setAiLoading(false);
}
};
const handleAiApply = async () => {
if (!aiSuggestions) return;
setAiApplying(true);
try {
const approved = aiSuggestions.filter((s) => aiApproved.has(s.productId));
const grouped = new Map<number, number[]>();
for (const s of approved) {
const cid = s.suggestion.categoryId;
if (!grouped.has(cid)) grouped.set(cid, []);
grouped.get(cid)!.push(s.productId);
}
for (const [categoryId, ids] of grouped.entries()) {
await bulkSetCategory(ids, categoryId);
}
setAiSuggestions(null);
setAiApproved(new Set());
} catch (err) {
setAiError(err instanceof Error ? err.message : 'Fel vid tillämpning');
} finally {
setAiApplying(false);
}
};
return (
<>
{/* Sök + sortering + filter */}
@@ -161,6 +218,23 @@ export default function AdminProductList({ products }: Props) {
>
Okategoriserade
</button>
<button
type="button"
onClick={handleAiCategorize}
disabled={aiLoading}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #a78bfa',
background: aiLoading ? '#f5f3ff' : '#ede9fe',
color: '#5b21b6',
fontWeight: 600,
cursor: aiLoading ? 'wait' : 'pointer',
fontSize: '0.9rem',
}}
>
{aiLoading ? '⏳ AI arbetar…' : '✨ AI-kategorisera okategoriserade'}
</button>
</div>
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
@@ -264,6 +338,68 @@ export default function AdminProductList({ products }: Props) {
</article>
))}
</div>
{/* AI-kategorisering modal */}
{(aiError || aiSuggestions) && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: 10, padding: '1.5rem', maxWidth: 700, width: '95%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', gap: '1rem', boxShadow: '0 8px 32px rgba(0,0,0,0.18)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}> AI-kategoriförslag</h3>
<button onClick={() => { setAiSuggestions(null); setAiError(null); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#64748b' }}></button>
</div>
{aiError && <div style={{ color: '#dc2626', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 6, padding: '0.6rem 1rem', fontSize: 14 }}>{aiError}</div>}
{aiSuggestions && (
<>
<p style={{ margin: 0, fontSize: 13, color: '#475569' }}>
AI har analyserat {aiSuggestions.length} okategoriserade produkter. Avmarkera rader du inte vill godkänna, klicka sedan "Godkänn valda".
</p>
<div style={{ overflowY: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
<th style={{ padding: '0.5rem 0.6rem', borderBottom: '2px solid #e2e8f0' }}>
<input type="checkbox" checked={aiApproved.size === aiSuggestions.length} onChange={() => setAiApproved(aiApproved.size === aiSuggestions.length ? new Set() : new Set(aiSuggestions.map((s) => s.productId)))} />
</th>
{['Produkt', 'AI-förslag', 'Säkerhet'].map((h) => <th key={h} style={{ padding: '0.5rem 0.6rem', borderBottom: '2px solid #e2e8f0' }}>{h}</th>)}
</tr>
</thead>
<tbody>
{aiSuggestions.map((s) => {
const approved = aiApproved.has(s.productId);
const isLow = s.suggestion.confidence === 'low' || s.suggestion.usedFallback;
return (
<tr key={s.productId} style={{ borderBottom: '1px solid #e2e8f0', background: isLow ? '#fffbeb' : approved ? '#f0fdf4' : '#fff', opacity: approved ? 1 : 0.5 }}>
<td style={{ padding: '0.5rem 0.6rem' }}>
<input type="checkbox" checked={approved} onChange={() => setAiApproved((prev) => { const next = new Set(prev); if (next.has(s.productId)) next.delete(s.productId); else next.add(s.productId); return next; })} />
</td>
<td style={{ padding: '0.5rem 0.6rem', fontWeight: 500 }}>{s.productName}</td>
<td style={{ padding: '0.5rem 0.6rem', color: isLow ? '#92400e' : '#15803d' }}>
{isLow ? '⚠ ' : '✓ '}{s.suggestion.path}
</td>
<td style={{ padding: '0.5rem 0.6rem' }}>
<span style={{ display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: 999, fontSize: 12, fontWeight: 600, background: isLow ? '#fef3c7' : s.suggestion.confidence === 'high' ? '#dcfce7' : '#dbeafe', color: isLow ? '#92400e' : s.suggestion.confidence === 'high' ? '#15803d' : '#1d4ed8' }}>
{isLow ? 'Låg' : s.suggestion.confidence === 'high' ? 'Hög' : 'Medium'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button onClick={() => { setAiSuggestions(null); setAiError(null); }} style={{ padding: '0.5rem 1rem', background: '#e2e8f0', border: 'none', borderRadius: 6, cursor: 'pointer', fontWeight: 500 }}>Avbryt</button>
<button onClick={handleAiApply} disabled={aiApplying || aiApproved.size === 0} style={{ padding: '0.5rem 1.2rem', background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontWeight: 600 }}>
{aiApplying ? 'Sparar…' : `Godkänn valda (${aiApproved.size})`}
</button>
</div>
</>
)}
</div>
</div>
)}
</>
);
}
+59 -11
View File
@@ -2,7 +2,7 @@
import { useState, useTransition, useEffect } from 'react';
import type { Product } from '../../../features/inventory/types';
import { updateProduct, deleteProduct, setProductTags } from './actions';
import { updateProduct, deleteProduct, setProductTags, suggestProductCategory } from './actions';
type CategoryNode = {
id: number;
@@ -11,6 +11,14 @@ type CategoryNode = {
children: CategoryNode[];
};
type AiSuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
type Props = {
product: Product;
};
@@ -39,6 +47,11 @@ export default function EditProductForm({ product }: Props) {
(product as any).categoryId ?? ''
);
// AI-suggestion state
const [aiSuggestion, setAiSuggestion] = useState<AiSuggestion | null>(null);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && categoryTree.length === 0) {
fetch('/api/categories')
@@ -64,6 +77,20 @@ export default function EditProductForm({ product }: Props) {
const flatCategories = flattenTree(categoryTree);
async function handleAiSuggest() {
setAiLoading(true);
setAiError(null);
setAiSuggestion(null);
try {
const result = await suggestProductCategory(product.id);
setAiSuggestion(result);
} catch (err) {
setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades');
} finally {
setAiLoading(false);
}
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
@@ -158,16 +185,37 @@ export default function EditProductForm({ product }: Props) {
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
<span style={{ fontWeight: 600 }}>Kategori (ny hierarki)</span>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value))}
style={inputStyle}
>
<option value=""> Ingen kategori </option>
{flatCategories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.label}</option>
))}
</select>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<select
value={selectedCategoryId}
onChange={(e) => { setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value)); setAiSuggestion(null); }}
style={{ ...inputStyle, flex: 1, minWidth: 180 }}
>
<option value=""> Ingen kategori </option>
{flatCategories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.label}</option>
))}
</select>
<button
type="button"
onClick={handleAiSuggest}
disabled={aiLoading}
title="Låt AI föreslå kategori"
style={{ padding: '0.45rem 0.75rem', background: '#ede9fe', border: '1px solid #a78bfa', borderRadius: 4, cursor: aiLoading ? 'wait' : 'pointer', fontSize: 13, color: '#5b21b6', fontWeight: 600, whiteSpace: 'nowrap' }}
>
{aiLoading ? '⏳' : '✨ Fråga AI'}
</button>
</div>
{aiError && <span style={{ color: '#dc2626', fontSize: 12 }}>{aiError}</span>}
{aiSuggestion && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: 4, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, padding: '0.2rem 0.6rem', borderRadius: 999, fontWeight: 600, background: aiSuggestion.usedFallback ? '#fef3c7' : aiSuggestion.confidence === 'high' ? '#dcfce7' : '#dbeafe', color: aiSuggestion.usedFallback ? '#92400e' : aiSuggestion.confidence === 'high' ? '#15803d' : '#1d4ed8', border: `1px solid ${aiSuggestion.usedFallback ? '#fcd34d' : aiSuggestion.confidence === 'high' ? '#86efac' : '#93c5fd'}` }}>
{aiSuggestion.usedFallback ? '⚠ AI osäker — ' : 'AI föreslår: '}{aiSuggestion.path}
</span>
<button type="button" onClick={() => { setSelectedCategoryId(aiSuggestion.categoryId); setAiSuggestion(null); }} style={{ padding: '0.2rem 0.5rem', background: '#16a34a', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}></button>
<button type="button" onClick={() => setAiSuggestion(null)} style={{ padding: '0.2rem 0.5rem', background: '#e2e8f0', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}></button>
</div>
)}
</label>
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
+66
View File
@@ -105,3 +105,69 @@ export async function bulkSetCategory(ids: number[], categoryId: number | null)
revalidatePath('/admin/products');
}
export async function suggestProductCategory(productId: number) {
const res = await fetch(`${API_BASE}/api/products/${productId}/suggest-category`, {
method: 'GET',
headers: { ...(await getAuthHeaders()) },
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`AI-kategorisering misslyckades: ${text}`);
}
return res.json() as Promise<{
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
}>;
}
export async function suggestBulkCategories(productIds?: number[]) {
const res = await fetch(`${API_BASE}/api/products/ai-categorize-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ productIds }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Bulk-AI-kategorisering misslyckades: ${text}`);
}
return res.json() as Promise<
{
productId: number;
productName: string;
suggestion: {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
}[]
>;
}
export async function setProductStatus(id: number, status: 'active' | 'rejected') {
const res = await fetch(`${API_BASE}/api/products/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ status }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera status: ${text}`);
}
revalidatePath('/admin/products');
revalidatePath('/admin/products/pending');
}
@@ -0,0 +1,109 @@
'use client';
import { useState, useTransition } from 'react';
import { setProductStatus } from '../actions';
type PendingProduct = {
id: number;
name: string;
canonicalName: string | null;
createdAt: string;
categoryRef?: { name: string; parent?: { name: string } } | null;
owner?: { id: number; username: string } | null;
};
export default function PendingProductsClient({ products: initial }: { products: PendingProduct[] }) {
const [products, setProducts] = useState<PendingProduct[]>(initial);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<number | null>(null);
function handleAction(id: number, status: 'active' | 'rejected') {
setError(null);
setProcessing(id);
startTransition(async () => {
try {
await setProductStatus(id, status);
setProducts((prev) => prev.filter((p) => p.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Fel vid uppdatering');
} finally {
setProcessing(null);
}
});
}
if (products.length === 0) {
return (
<div style={{ color: '#64748b', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8, padding: '2rem', textAlign: 'center' }}>
Inga väntande produktförslag 🎉
</div>
);
}
return (
<div>
{error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 6, padding: '0.6rem 1rem', color: '#dc2626', marginBottom: '1rem', fontSize: 14 }}>
{error}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
{['Produkt', 'Kategori (AI)', 'Föreslagen av', 'Datum', 'Åtgärd'].map((h) => (
<th key={h} style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{products.map((p) => {
const isProcessing = processing === p.id && isPending;
const categoryPath = [p.categoryRef?.parent?.name, p.categoryRef?.name].filter(Boolean).join(' ');
return (
<tr key={p.id} style={{ borderBottom: '1px solid #e2e8f0', opacity: isProcessing ? 0.5 : 1 }}>
<td style={{ padding: '0.6rem 0.8rem' }}>
<div style={{ fontWeight: 500 }}>{p.canonicalName ?? p.name}</div>
{p.canonicalName && p.canonicalName !== p.name && (
<div style={{ fontSize: 12, color: '#94a3b8' }}>{p.name}</div>
)}
</td>
<td style={{ padding: '0.6rem 0.8rem' }}>
{categoryPath ? (
<span style={{ fontSize: 12, background: '#e0f2fe', borderRadius: 999, padding: '0.15rem 0.5rem', color: '#0369a1' }}>{categoryPath}</span>
) : (
<span style={{ fontSize: 12, color: '#94a3b8' }}></span>
)}
</td>
<td style={{ padding: '0.6rem 0.8rem', color: '#475569' }}>{p.owner?.username ?? '—'}</td>
<td style={{ padding: '0.6rem 0.8rem', color: '#94a3b8', fontSize: 12 }}>
{new Date(p.createdAt).toLocaleDateString('sv-SE')}
</td>
<td style={{ padding: '0.6rem 0.8rem' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleAction(p.id, 'active')}
disabled={isProcessing}
style={{ padding: '0.3rem 0.7rem', background: '#dcfce7', border: '1px solid #86efac', borderRadius: 4, cursor: 'pointer', fontSize: 12, color: '#15803d', fontWeight: 600 }}
>
Godkänn
</button>
<button
onClick={() => handleAction(p.id, 'rejected')}
disabled={isProcessing}
style={{ padding: '0.3rem 0.7rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, cursor: 'pointer', fontSize: 12, color: '#dc2626', fontWeight: 600 }}
>
Avvisa
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { auth } from '../../../../auth';
import { redirect } from 'next/navigation';
import Navigation from '../../../Navigation';
import { getAuthHeaders } from '../../../../lib/auth-headers';
import PendingProductsClient from './PendingProductsClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
export default async function PendingProductsPage() {
const session = await auth();
if (!session || (session.user as any)?.role !== 'admin') redirect('/');
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products/pending`, { headers, cache: 'no-store' });
const products = res.ok ? await res.json() : [];
return (
<main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.5rem' }}>Väntande produktförslag</h1>
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
Produkter som användare har föreslagit. Godkänn för att göra dem tillgängliga i katalogen, eller avvisa för att ta bort dem.
</p>
<PendingProductsClient products={products} />
</main>
);
}