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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user