feat: add TypeScript definitions for next-auth session with accessToken and user details
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-04 20:09:21 +02:00
parent afd2607000
commit ffe50e5151
135 changed files with 5 additions and 38 deletions
@@ -0,0 +1,219 @@
'use client';
import { useEffect, useState } from 'react';
export interface AiModelInfo {
id: string;
name: string;
description: string;
model: string;
path: string;
trigger: string;
access: string;
}
const STORAGE_KEY = 'mistral_api_key_meta';
interface KeyMeta {
createdAt: string;
validityMonths: string;
}
interface Props {
keyHint: string;
hasKey: boolean;
aiFunctions: AiModelInfo[];
}
export default function AiAdminClient({ keyHint, hasKey, aiFunctions }: Props) {
const [meta, setMeta] = useState<KeyMeta>({ createdAt: '', validityMonths: '' });
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) setMeta(JSON.parse(stored));
} catch {
// ignore
}
}, []);
const saveMeta = (patch: Partial<KeyMeta>) => {
const updated = { ...meta, ...patch };
setMeta(updated);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
} catch {
// ignore
}
};
const { daysLeft, expiryDate } = computeExpiry(meta.createdAt, meta.validityMonths);
const modelChip = (model: string) => {
const color = model.includes('tiny') ? '#6366f1' : '#0ea5e9';
return (
<span style={{ fontSize: '0.78rem', background: color, color: '#fff', borderRadius: '4px', padding: '2px 7px', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
{model}
</span>
);
};
const accessChip = (access: string) => {
const isAdmin = access === 'Admin';
const isPremium = access.includes('Premium');
const bg = isAdmin ? '#7c3aed' : isPremium ? '#f59e0b' : '#10b981';
return (
<span style={{ fontSize: '0.75rem', background: bg, color: '#fff', borderRadius: '4px', padding: '2px 7px', whiteSpace: 'nowrap' }}>
{access}
</span>
);
};
return (
<div>
{/* API-nyckel */}
<section style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: '10px', padding: '1.5rem', marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
🔑 Mistral API-nyckel
</h2>
{!hasKey && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.9rem', color: '#991b1b' }}>
<strong>MISTRAL_API_KEY är inte konfigurerad</strong> alla AI-funktioner är inaktiva tills nyckeln sätts i miljövariablerna.
</div>
)}
{hasKey && (
<div style={{ background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '8px', padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.85rem', color: '#92400e' }}>
Status <strong>Konfigurerad</strong> innebär att API-nyckeln är satt. Om Mistral svarar med 503 är det ett tillfälligt serverfel hos Mistral inte ett konfigurationsproblem.
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1.5rem', alignItems: 'center', marginBottom: '1.25rem' }}>
<span style={{ color: '#555', fontSize: '0.9rem' }}>Status</span>
<span>
{hasKey
? <span style={{ color: '#10b981', fontWeight: 600 }}> Konfigurerad</span>
: <span style={{ color: '#ef4444', fontWeight: 600 }}> Saknas (MISTRAL_API_KEY ej satt)</span>}
</span>
<span style={{ color: '#555', fontSize: '0.9rem' }}>Nyckel (sista 4)</span>
<code style={{ fontFamily: 'monospace', fontSize: '1rem', letterSpacing: '0.15em', background: '#f3f4f6', padding: '2px 8px', borderRadius: '4px' }}>
****{keyHint}
</code>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', alignItems: 'end' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', fontSize: '0.85rem', color: '#374151' }}>
Skapad datum
<input
type="date"
value={meta.createdAt}
onChange={(e) => saveMeta({ createdAt: e.target.value })}
style={{ padding: '0.4rem 0.6rem', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '0.9rem' }}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem', fontSize: '0.85rem', color: '#374151' }}>
Giltighet (månader)
<input
type="number"
min="1"
max="120"
value={meta.validityMonths}
onChange={(e) => saveMeta({ validityMonths: e.target.value })}
placeholder="t.ex. 12"
style={{ padding: '0.4rem 0.6rem', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '0.9rem' }}
/>
</label>
<div style={{ paddingBottom: '2px' }}>
{expiryDate && daysLeft !== null ? (
<div style={{ background: daysLeft <= 14 ? '#fef2f2' : daysLeft <= 30 ? '#fffbeb' : '#f0fdf4', border: `1px solid ${daysLeft <= 14 ? '#fecaca' : daysLeft <= 30 ? '#fde68a' : '#bbf7d0'}`, borderRadius: '8px', padding: '0.6rem 0.9rem' }}>
<div style={{ fontSize: '0.75rem', color: '#6b7280', marginBottom: '2px' }}>Förfaller {expiryDate}</div>
<div style={{ fontWeight: 700, fontSize: '1.1rem', color: daysLeft <= 14 ? '#dc2626' : daysLeft <= 30 ? '#d97706' : '#16a34a' }}>
{daysLeft <= 0 ? '⚠️ Nyckel har förfallit!' : `${daysLeft} dagar kvar`}
</div>
</div>
) : (
<div style={{ color: '#9ca3af', fontSize: '0.85rem', padding: '0.6rem 0' }}>
Fyll i datum och giltighet för att se återstående tid
</div>
)}
</div>
</div>
</section>
{/* AI-funktioner */}
<section style={{ background: '#fff', border: '1px solid #e5e7eb', borderRadius: '10px', padding: '1.5rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '1rem' }}> Implementerade AI-funktioner</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.88rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid #e5e7eb', textAlign: 'left' }}>
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Status</th>
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Funktion</th>
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Modell</th>
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Åtkomst</th>
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Utlösare</th>
<th style={{ padding: '0.5rem 0.75rem', color: '#374151', fontWeight: 600 }}>Sida</th>
</tr>
</thead>
<tbody>
{aiFunctions.map((fn, i) => (
<tr key={i} style={{ borderBottom: '1px solid #f3f4f6', verticalAlign: 'top', opacity: hasKey ? 1 : 0.55 }}>
<td style={{ padding: '0.65rem 0.75rem', whiteSpace: 'nowrap' }}>
{hasKey ? (
<span title="API-nyckel konfigurerad" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.78rem', background: '#dcfce7', color: '#166534', border: '1px solid #bbf7d0', borderRadius: '4px', padding: '2px 7px' }}>
Konfigurerad
</span>
) : (
<span title="MISTRAL_API_KEY saknas" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.3rem', fontSize: '0.78rem', background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: '4px', padding: '2px 7px' }}>
Inaktiv
</span>
)}
</td>
<td style={{ padding: '0.65rem 0.75rem' }}>
<div style={{ fontWeight: 500, marginBottom: '0.2rem' }}>{fn.name}</div>
<div style={{ color: '#6b7280', fontSize: '0.8rem', lineHeight: 1.4 }}>{fn.description}</div>
</td>
<td style={{ padding: '0.65rem 0.75rem' }}>{modelChip(fn.model)}</td>
<td style={{ padding: '0.65rem 0.75rem' }}>{accessChip(fn.access)}</td>
<td style={{ padding: '0.65rem 0.75rem', color: '#4b5563', fontSize: '0.82rem' }}>{fn.trigger}</td>
<td style={{ padding: '0.65rem 0.75rem' }}>
<a href={fn.path} style={{ color: '#0070f3', textDecoration: 'none', fontFamily: 'monospace', fontSize: '0.82rem' }}>{fn.path}</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: '#6b7280' }}>
<span style={{ background: '#6366f1', color: '#fff', borderRadius: '4px', padding: '1px 6px', fontFamily: 'monospace', fontSize: '0.75rem' }}>tiny</span>
Snabb och kostnadseffektiv text- och bildtolkning
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.8rem', color: '#6b7280' }}>
<span style={{ background: '#0ea5e9', color: '#fff', borderRadius: '4px', padding: '1px 6px', fontFamily: 'monospace', fontSize: '0.75rem' }}>small</span>
Bättre resoneringsförmåga kategorisering och matchning
</div>
</div>
</section>
</div>
);
}
function computeExpiry(createdAt: string, validityMonths: string): { daysLeft: number | null; expiryDate: string | null } {
if (!createdAt || !validityMonths) return { daysLeft: null, expiryDate: null };
const months = parseInt(validityMonths, 10);
if (isNaN(months) || months <= 0) return { daysLeft: null, expiryDate: null };
const created = new Date(createdAt);
if (isNaN(created.getTime())) return { daysLeft: null, expiryDate: null };
const expiry = new Date(created);
expiry.setMonth(expiry.getMonth() + months);
const today = new Date();
today.setHours(0, 0, 0, 0);
expiry.setHours(0, 0, 0, 0);
const daysLeft = Math.round((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const expiryDate = expiry.toLocaleDateString('sv-SE');
return { daysLeft, expiryDate };
}
+39
View File
@@ -0,0 +1,39 @@
import { redirect } from 'next/navigation';
import { auth } from '../../../auth';
import Navigation from '../../Navigation';
import AiAdminClient from './AiAdminClient';
import type { AiModelInfo } from './AiAdminClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export default async function AiAdminPage() {
const session = await auth();
if ((session?.user as any)?.role !== 'admin') {
redirect('/');
}
const key = process.env.MISTRAL_API_KEY ?? '';
const hasKey = key.length > 0;
const keyHint = key.length >= 4 ? key.slice(-4) : '????';
let aiFunctions: AiModelInfo[] = [];
try {
const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' });
if (res.ok) aiFunctions = await res.json();
} catch {
// backend ej nåbart — visa tom lista
}
return (
<>
<Navigation />
<main style={{ maxWidth: '900px', margin: '0 auto', padding: '0 1rem 2rem' }}>
<h1 style={{ fontSize: '1.4rem', marginBottom: '0.25rem' }}>🤖 AI-konfiguration</h1>
<p style={{ color: '#666', marginBottom: '2rem', fontSize: '0.9rem' }}>
Översikt över implementerade AI-funktioner och API-nyckelstatus.
</p>
<AiAdminClient keyHint={keyHint} hasKey={hasKey} aiFunctions={aiFunctions} />
</main>
</>
);
}
@@ -0,0 +1,458 @@
'use client';
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { Product, Category } from '../../../features/inventory/types';
import EditProductForm from './EditProductForm';
type CategoryNode = Category & { children: CategoryNode[] };
type AiSuggestion = {
productId: number;
productName: string;
suggestion: {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
};
const sortOptions = [
{ value: 'createdDesc', label: 'Senast tillagda' },
{ value: 'nameAsc', label: 'Namn A–Ö' },
];
function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; label: string }[] {
const result: { id: number; label: string }[] = [];
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name, 'sv'));
for (const node of sorted) {
result.push({ id: node.id, label: '\u00a0\u00a0'.repeat(depth) + (depth > 0 ? '↳ ' : '') + node.name });
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1));
}
return result;
}
export default function AdminProductList() {
const [products, setProducts] = useState<Product[]>([]);
const [productsLoading, setProductsLoading] = useState(true);
const [search, setSearch] = useState('');
const [sort, setSort] = useState('createdDesc');
const [showUncategorizedOnly, setShowUncategorizedOnly] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [bulkCategoryId, setBulkCategoryId] = useState<string>('');
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [isBulkPending, setIsBulkPending] = useState(false);
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);
const refetchProducts = useCallback(() => {
fetch('/api/products')
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data) => {
if (Array.isArray(data)) setProducts(data);
})
.catch((e) => console.error('[AdminProductList] refetchProducts error:', e))
.finally(() => setProductsLoading(false));
}, []);
useEffect(() => {
refetchProducts();
}, [refetchProducts]);
useEffect(() => {
const handler = () => refetchProducts();
window.addEventListener('product-created', handler);
window.addEventListener('product-list-changed', handler);
return () => {
window.removeEventListener('product-created', handler);
window.removeEventListener('product-list-changed', handler);
};
}, [refetchProducts]);
useEffect(() => {
fetch('/api/categories?tree')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setCategoryTree(data); })
.catch(() => {});
}, []);
const categoryOptions = useMemo(() => flattenTree(categoryTree), [categoryTree]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
let result = products.filter((p) => {
if (showUncategorizedOnly && p.categoryId != null) return false;
if (q) {
return (
p.name.toLowerCase().includes(q) ||
(p.canonicalName ?? '').toLowerCase().includes(q) ||
(p.normalizedName ?? '').toLowerCase().includes(q)
);
}
return true;
});
if (sort === 'nameAsc') {
result.sort((a, b) =>
(a.canonicalName || a.name).localeCompare(b.canonicalName || b.name, 'sv'),
);
} else {
result.sort((a, b) => b.id - a.id);
}
return result;
}, [products, search, sort, showUncategorizedOnly]);
const allVisibleSelected = filtered.length > 0 && filtered.every((p) => selectedIds.has(p.id));
const toggleSelectAll = () => {
if (allVisibleSelected) {
setSelectedIds((prev) => {
const next = new Set(prev);
filtered.forEach((p) => next.delete(p.id));
return next;
});
} else {
setSelectedIds((prev) => {
const next = new Set(prev);
filtered.forEach((p) => next.add(p.id));
return next;
});
}
};
const toggleSelect = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleBulkApply = async () => {
setBulkError(null);
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
const categoryId = bulkCategoryId === '' ? null : bulkCategoryId === '__remove__' ? null : Number(bulkCategoryId);
setIsBulkPending(true);
try {
const res = await fetch('/api/admin/bulk-set-category', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, categoryId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Fel vid uppdatering');
}
setSelectedIds(new Set());
setBulkCategoryId('');
refetchProducts();
} catch (err) {
setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering');
} finally {
setIsBulkPending(false);
}
};
const handleAiCategorize = async () => {
setAiLoading(true);
setAiError(null);
setAiSuggestions(null);
try {
const res = await fetch('/api/admin/bulk-categorize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'AI-kategorisering misslyckades');
setAiSuggestions(data);
setAiApproved(new Set(data.map((r: AiSuggestion) => 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()) {
const res = await fetch('/api/admin/bulk-set-category', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, categoryId }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Fel vid tillämpning');
}
}
setAiSuggestions(null);
setAiApproved(new Set());
refetchProducts();
} catch (err) {
setAiError(err instanceof Error ? err.message : 'Fel vid tillämpning');
} finally {
setAiApplying(false);
}
};
if (productsLoading) {
return <p style={{ color: '#888', marginTop: '1rem' }}>Laddar produkter</p>;
}
return (
<>
{/* Sök + sortering + filter */}
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
<input
type="search"
placeholder="Sök produkt…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: '1 1 200px', padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '1rem' }}
/>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
{sortOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setSort(opt.value)}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #ddd',
background: sort === opt.value ? '#efefef' : '#fff',
fontWeight: sort === opt.value ? 600 : 400,
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{opt.label}
</button>
))}
<button
type="button"
onClick={() => { setShowUncategorizedOnly((v) => !v); setSelectedIds(new Set()); }}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid ' + (showUncategorizedOnly ? '#f59e0b' : '#ddd'),
background: showUncategorizedOnly ? '#fffbeb' : '#fff',
fontWeight: showUncategorizedOnly ? 600 : 400,
cursor: 'pointer',
fontSize: '0.9rem',
color: showUncategorizedOnly ? '#92400e' : 'inherit',
}}
>
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' }}>
{filtered.length} av {products.length} produkter
</span>
</div>
{/* Bulk-åtgärd */}
{selectedIds.size > 0 && (
<div style={{
display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap',
padding: '0.75rem 1rem', marginBottom: '1rem',
background: '#f0f7ff', border: '1px solid #bfdbfe', borderRadius: '8px',
}}>
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{selectedIds.size} valda</span>
<select
value={bulkCategoryId}
onChange={(e) => setBulkCategoryId(e.target.value)}
style={{ padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', minWidth: '200px' }}
>
<option value="">Välj kategori</option>
<option value="__remove__"> Ta bort kategori </option>
{categoryOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
<button
type="button"
onClick={handleBulkApply}
disabled={isBulkPending}
style={{ padding: '0.4rem 0.9rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }}
>
{isBulkPending ? 'Sparar…' : 'Sätt kategori'}
</button>
<button
type="button"
onClick={() => setSelectedIds(new Set())}
style={{ padding: '0.4rem 0.6rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem' }}
>
Avmarkera
</button>
{bulkError && <span style={{ color: '#dc2626', fontSize: '0.85rem' }}>{bulkError}</span>}
</div>
)}
{/* Välj alla synliga */}
{filtered.length > 0 && (
<div style={{ marginBottom: '0.5rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.875rem', color: '#555' }}>
<input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAll} />
Välj alla synliga ({filtered.length})
</label>
</div>
)}
<div style={{ display: 'grid', gap: '1rem' }}>
{filtered.map((product) => (
<article
key={product.id}
style={{
border: selectedIds.has(product.id) ? '1px solid #93c5fd' : '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
background: selectedIds.has(product.id) ? '#f0f7ff' : undefined,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.6rem' }}>
<input
type="checkbox"
checked={selectedIds.has(product.id)}
onChange={() => toggleSelect(product.id)}
style={{ marginTop: '3px', flexShrink: 0 }}
/>
<div>
<strong>{product.canonicalName || product.name}</strong>
{product.canonicalName && product.canonicalName !== product.name && (
<span style={{ color: '#666', fontSize: '0.85rem', marginLeft: '0.5rem' }}>({product.name})</span>
)}
{product.categoryRef ? (
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#e0f2fe', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#0369a1' }}>
{[product.categoryRef.parent?.parent?.name, product.categoryRef.parent?.name, product.categoryRef.name].filter(Boolean).join(' ')}
</span>
) : product.category ? (
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#eee', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#555' }}>
{product.category}
</span>
) : (
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: '#f59e0b', fontStyle: 'italic' }}>Okategoriserad</span>
)}
</div>
</div>
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>ID: {product.id}</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#888' }}>
Normalized: {product.normalizedName}
</div>
<EditProductForm
product={product}
onSaved={(updated) => setProducts((prev) => prev.map((p) => p.id === updated.id ? updated : p))}
onDeleted={(id) => setProducts((prev) => prev.filter((p) => p.id !== id))}
/>
</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>
)}
</>
);
}
@@ -0,0 +1,180 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
type DeletedProduct = {
id: number;
name: string;
normalizedName: string;
canonicalName?: string | null;
category?: string | null;
brand?: string | null;
deletedAt?: string | null;
};
export default function DeletedProductsView() {
const [products, setProducts] = useState<DeletedProduct[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pendingId, setPendingId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const fetchDeleted = useCallback(() => {
setLoading(true);
setError(null);
fetch('/api/admin/deleted-products')
.then((r) => r.json())
.then((data) => {
if (Array.isArray(data)) setProducts(data);
else setError('Kunde inte ladda raderade produkter.');
})
.catch(() => setError('Nätverksfel. Försök igen.'))
.finally(() => setLoading(false));
}, []);
useEffect(() => { fetchDeleted(); }, [fetchDeleted]);
const handleRestore = async (id: number) => {
if (!confirm('Återställ produkten?')) return;
setPendingId(id);
try {
const res = await fetch(`/api/admin/deleted-products/${id}`, { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d?.error ?? 'Kunde inte återställa produkten.');
} else {
setProducts((prev) => prev.filter((p) => p.id !== id));
}
} catch {
setError('Nätverksfel. Försök igen.');
} finally {
setPendingId(null);
}
};
const handlePermanentDelete = async (id: number, name: string) => {
if (!confirm(`Radera "${name}" permanent? Detta kan inte ångras.`)) return;
setPendingId(id);
try {
const res = await fetch(`/api/admin/deleted-products/${id}`, { method: 'DELETE' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d?.error ?? 'Kunde inte radera produkten permanent.');
} else {
setProducts((prev) => prev.filter((p) => p.id !== id));
}
} catch {
setError('Nätverksfel. Försök igen.');
} finally {
setPendingId(null);
}
};
const filtered = products.filter((p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.canonicalName ?? '').toLowerCase().includes(search.toLowerCase())
);
if (loading) return <p style={{ color: '#888' }}>Laddar raderade produkter...</p>;
return (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Här visas produkter som mjukraderats. Du kan återställa dem eller radera dem permanent.
</p>
{error && (
<div style={{ color: '#c00', background: '#fff0f0', border: '1px solid #fcc', borderRadius: 6, padding: '0.5rem 1rem', marginBottom: '1rem' }}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
<input
type="text"
placeholder="Sök på namn..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ padding: '0.4rem 0.75rem', borderRadius: 6, border: '1px solid #ccc', fontSize: '0.9rem', minWidth: 220 }}
/>
<span style={{ color: '#888', fontSize: '0.85rem' }}>
{filtered.length} av {products.length} produkter
</span>
<button
onClick={fetchDeleted}
style={{ marginLeft: 'auto', padding: '0.35rem 0.75rem', borderRadius: 6, border: '1px solid #ccc', background: '#f8f8f8', cursor: 'pointer', fontSize: '0.85rem' }}
>
Uppdatera
</button>
</div>
{filtered.length === 0 ? (
<p style={{ color: '#888', fontStyle: 'italic' }}>Inga raderade produkter hittades.</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<thead>
<tr style={{ background: '#f2f2f2', textAlign: 'left' }}>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>ID</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Namn</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Canonical name</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Kategori</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Raderades</th>
<th style={{ padding: '0.5rem 0.75rem', borderBottom: '2px solid #ddd' }}>Åtgärder</th>
</tr>
</thead>
<tbody>
{filtered.map((p) => (
<tr key={p.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.5rem 0.75rem', color: '#888' }}>{p.id}</td>
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 500 }}>{p.name}</td>
<td style={{ padding: '0.5rem 0.75rem', color: '#666' }}>{p.canonicalName || ''}</td>
<td style={{ padding: '0.5rem 0.75rem', color: '#666' }}>{p.category || ''}</td>
<td style={{ padding: '0.5rem 0.75rem', color: '#999', fontSize: '0.8rem' }}>
{p.deletedAt ? new Date(p.deletedAt).toLocaleDateString('sv-SE') : ''}
</td>
<td style={{ padding: '0.5rem 0.75rem' }}>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
disabled={pendingId === p.id}
onClick={() => handleRestore(p.id)}
style={{
padding: '0.25rem 0.6rem',
borderRadius: 5,
border: '1px solid #3a7d44',
background: '#eafaf1',
color: '#276032',
cursor: pendingId === p.id ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Återställ
</button>
<button
disabled={pendingId === p.id}
onClick={() => handlePermanentDelete(p.id, p.name)}
style={{
padding: '0.25rem 0.6rem',
borderRadius: 5,
border: '1px solid #c00',
background: '#fff0f0',
color: '#c00',
cursor: pendingId === p.id ? 'not-allowed' : 'pointer',
fontSize: '0.8rem',
fontWeight: 500,
}}
>
Radera permanent
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,315 @@
'use client';
import { useState, useEffect } from 'react';
import type { Product } from '../../../features/inventory/types';
type CategoryNode = {
id: number;
name: string;
parentId: number | null;
children: CategoryNode[];
};
type AiSuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
type Props = {
product: Product;
onSaved: (updated: Product) => void;
onDeleted: (id: number) => void;
};
const inputStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
width: '100%',
boxSizing: 'border-box',
};
export default function EditProductForm({ product, onSaved, onDeleted }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [tagInput, setTagInput] = useState(
product.tags?.map((pt) => pt.tag.name).join(', ') ?? ''
);
// Kategoriträd från API
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>(
(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?tree')
.then((r) => r.json())
.then((data: unknown) => {
if (Array.isArray(data)) setCategoryTree(data as CategoryNode[]);
})
.catch(() => {});
}
}, [isOpen]);
// Bygg flat lista för select med indragna nivåer
function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; name: string; label: string }[] {
const result: { id: number; name: string; label: string }[] = [];
const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name, 'sv'));
for (const node of sorted) {
const prefix = depth === 0 ? '' : depth === 1 ? '\u00a0\u00a0\u00a0↳ ' : '\u00a0\u00a0\u00a0\u00a0\u00a0\u00a0↳ ';
result.push({ id: node.id, name: node.name, label: prefix + node.name });
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1));
}
return result;
}
const flatCategories = flattenTree(categoryTree);
async function handleAiSuggest() {
setAiLoading(true);
setAiError(null);
setAiSuggestion(null);
try {
const res = await fetch(`/api/admin/suggest-category/${product.id}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'AI-kategorisering misslyckades');
setAiSuggestion(data);
} catch (err) {
setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades');
} finally {
setAiLoading(false);
}
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
setIsPending(true);
setError(null);
setSuccess(false);
try {
const res = await fetch(`/api/admin/product/${product.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.get('name'),
canonicalName: formData.get('canonicalName') ?? '',
category: formData.get('category') ?? '',
subcategory: formData.get('subcategory') ?? '',
brand: formData.get('brand') ?? '',
categoryId: selectedCategoryId !== '' ? selectedCategoryId : null,
tags: rawTags,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error ?? 'Okänt fel');
return;
}
setSuccess(true);
setIsOpen(false);
onSaved(data as Product);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}
async function handleDelete() {
if (!confirm(`Ta bort "${product.name}"? Detta är en mjukradering och kan återställas.`)) return;
setError(null);
setSuccess(false);
setIsPending(true);
try {
const res = await fetch(`/api/admin/product/${product.id}`, { method: 'DELETE' });
console.log('[EditProductForm] handleDelete: HTTP', res.status);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data?.error ?? 'Kunde inte ta bort produkt');
return;
}
onDeleted(product.id);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}
return (
<div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => { setIsOpen(!isOpen); setError(null); setSuccess(false); }}
style={{
padding: '0.4rem 1rem',
border: '1px solid #0070f3',
borderRadius: '4px',
background: isOpen ? '#0070f3' : '#fff',
color: isOpen ? '#fff' : '#0070f3',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: 600,
}}
>
{isOpen ? 'Stäng' : 'Redigera'}
</button>
{success && <span style={{ color: 'green', fontSize: '0.9rem' }}> Sparat!</span>}
</div>
{error && <div style={{ color: 'crimson', marginTop: '0.5rem', fontSize: '0.9rem' }}>{error}</div>}
{isOpen && (
<form
onSubmit={handleSubmit}
style={{ marginTop: '0.75rem', display: 'grid', gap: '0.75rem', maxWidth: '480px' }}
>
<input type="hidden" name="id" value={product.id} />
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
<span style={{ fontWeight: 600 }}>Namn</span>
<input
name="name"
type="text"
defaultValue={product.name}
required
style={inputStyle}
/>
</label>
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
<span style={{ fontWeight: 600 }}>Canonical name</span>
<input
name="canonicalName"
type="text"
defaultValue={product.canonicalName ?? ''}
style={inputStyle}
placeholder="Lämna tomt för att använda namn"
/>
<span style={{ color: '#666', fontSize: '0.8rem' }}>
Används för att gruppera liknande produkter (t.ex. "Kyckling" för alla kycklingvarianter)
</span>
</label>
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
<span style={{ fontWeight: 600 }}>Kategori (ny hierarki)</span>
<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' }}>
<span style={{ fontWeight: 600 }}>Varumärke</span>
<input
name="brand"
type="text"
defaultValue={product.brand ?? ''}
style={inputStyle}
placeholder="T.ex. Arla, ICA, Överlopps"
/>
</label>
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
<span style={{ fontWeight: 600 }}>Taggar</span>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
style={inputStyle}
placeholder="t.ex. svensk, ekologisk, glutenfri"
/>
<span style={{ color: '#666', fontSize: '0.8rem' }}>Kommaseparerade taggar (gemener)</span>
</label>
<div style={{ display: 'grid', gap: '0.25rem', fontSize: '0.85rem', color: '#888' }}>
<span><strong style={{ color: '#555' }}>Normaliserat namn:</strong> {product.normalizedName}</span>
<span><strong style={{ color: '#555' }}>Aktiv:</strong> {product.isActive ? 'Ja' : 'Nej'}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="submit"
disabled={isPending}
style={{
padding: '0.6rem 1.25rem',
background: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: isPending ? 'not-allowed' : 'pointer',
fontWeight: 600,
fontSize: '0.9rem',
opacity: isPending ? 0.7 : 1,
}}
>
{isPending ? 'Sparar...' : 'Spara'}
</button>
<button
type="button"
onClick={handleDelete}
disabled={isPending}
style={{
padding: '0.6rem 1.25rem',
background: '#fff',
color: '#c00',
border: '1px solid #c00',
borderRadius: '4px',
cursor: isPending ? 'not-allowed' : 'pointer',
fontWeight: 600,
fontSize: '0.9rem',
opacity: isPending ? 0.7 : 1,
}}
>
Ta bort (mjukradering)
</button>
</div>
</form>
)}
</div>
);
}
@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import ProductForm from '../../inventory/ProductForm';
export default function ExpandableCreateProductSection() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<section
style={{
border: '2px solid #0070f3',
borderRadius: '8px',
marginBottom: '1.5rem',
overflow: 'hidden',
}}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
style={{
width: '100%',
padding: '1rem',
background: '#0070f3',
color: 'white',
border: 'none',
fontSize: '1.1rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.background = '#0059cc';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = '#0070f3';
}}
>
<span> Skapa produkt</span>
<span style={{ fontSize: '1.5rem', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
</span>
</button>
{isExpanded && (
<div style={{ padding: '1rem', background: '#f9f9f9' }}>
<ProductForm />
</div>
)}
</section>
);
}
@@ -0,0 +1,361 @@
'use client';
import { useState, useTransition, useEffect } from 'react';
import type { MergePreview, Product } from '../../../features/inventory/types';
export default function MergePreviewForm() {
const [products, setProducts] = useState<Product[]>([]);
const [sourceProductId, setSourceProductId] = useState('');
const [targetProductId, setTargetProductId] = useState('');
const [preview, setPreview] = useState<MergePreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [isConfirming, setIsConfirming] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (isExpanded && products.length === 0) {
fetch('/api/products')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setProducts(data); })
.catch(() => {});
}
}, [isExpanded]);
const fetchPreview = () => {
setError(null);
setSuccessMessage(null);
setPreview(null);
setIsConfirming(false);
if (!sourceProductId || !targetProductId) {
setError('Välj både source och target.');
return;
}
if (sourceProductId === targetProductId) {
setError('Source och target kan inte vara samma produkt.');
return;
}
startTransition(async () => {
try {
const res = await fetch(
`/api/admin/merge-preview-proxy?sourceProductId=${sourceProductId}&targetProductId=${targetProductId}`,
{
method: 'GET',
cache: 'no-store',
},
);
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Kunde inte hämta preview.');
}
const data: MergePreview = await res.json();
setPreview(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
};
const confirmMerge = () => {
setError(null);
setSuccessMessage(null);
if (!preview) {
setError('Ingen preview finns att bekräfta.');
return;
}
startTransition(async () => {
try {
const res = await fetch('/api/admin/merge-products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceProductId: preview.source.id,
targetProductId: preview.target.id,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Sammanslagning misslyckades');
}
setSuccessMessage(
`Produkten "${preview.source.canonicalName || preview.source.name}" slogs ihop med "${preview.target.canonicalName || preview.target.name}".`,
);
setPreview(null);
setIsConfirming(false);
setSourceProductId('');
setTargetProductId('');
window.dispatchEvent(new CustomEvent('product-list-changed'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
};
return (
<section
style={{
border: '2px solid #10b981',
borderRadius: '8px',
marginBottom: '1.5rem',
overflow: 'hidden',
}}
>
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
style={{
width: '100%',
padding: '1rem',
background: '#10b981',
color: 'white',
border: 'none',
fontSize: '1.1rem',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.background = '#059669';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = '#10b981';
}}
>
<span>🔄 Förhandsgranska merge</span>
<span style={{ fontSize: '1.5rem', transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}>
</span>
</button>
{isExpanded && (
<div style={{ padding: '1rem', background: '#f9fafb', display: 'grid', gap: '1rem' }}>
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Source product (ska bort)</span>
<select
value={sourceProductId}
onChange={(e) => setSourceProductId(e.target.value)}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
>
<option value="">Välj source</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.canonicalName || product.name} (ID {product.id})
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Target product (ska behållas)</span>
<select
value={targetProductId}
onChange={(e) => setTargetProductId(e.target.value)}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
>
<option value="">Välj target</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.canonicalName || product.name} (ID {product.id})
</option>
))}
</select>
</label>
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={fetchPreview}
disabled={isPending}
style={{
padding: '0.75rem 1.5rem',
background: '#0070f3',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
minHeight: '44px',
fontWeight: 600,
}}
>
{isPending ? 'Hämtar preview...' : 'Förhandsgranska merge'}
</button>
{preview ? (
<button
type="button"
onClick={() => setIsConfirming((prev) => !prev)}
disabled={isPending}
style={{
padding: '0.75rem 1.5rem',
background: '#f0f0f0',
color: '#333',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
minHeight: '44px',
fontWeight: 600,
}}
>
{isConfirming ? 'Avbryt bekräftelse' : 'Gå vidare till bekräftelse'}
</button>
) : null}
</div>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
{successMessage ? <p style={{ color: 'green', margin: 0 }}>{successMessage}</p> : null}
{preview ? (
<div style={{ display: 'grid', gap: '1rem' }}>
<div
style={{
display: 'grid',
gap: '1rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
}}
>
<article style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1rem' }}>
<h3 style={{ marginTop: 0 }}>Source</h3>
<div><strong>ID:</strong> {preview.source.id}</div>
<div><strong>Namn:</strong> {preview.source.name}</div>
<div><strong>Canonical:</strong> {preview.source.canonicalName || 'Saknas'}</div>
<div><strong>Normalized:</strong> {preview.source.normalizedName}</div>
<div><strong>Aktiv:</strong> {preview.source.isActive ? 'Ja' : 'Nej'}</div>
<div><strong>Inventory count:</strong> {preview.source.inventoryCount}</div>
</article>
<article style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '1rem' }}>
<h3 style={{ marginTop: 0 }}>Target</h3>
<div><strong>ID:</strong> {preview.target.id}</div>
<div><strong>Namn:</strong> {preview.target.name}</div>
<div><strong>Canonical:</strong> {preview.target.canonicalName || 'Saknas'}</div>
<div><strong>Normalized:</strong> {preview.target.normalizedName}</div>
<div><strong>Aktiv:</strong> {preview.target.isActive ? 'Ja' : 'Nej'}</div>
<div><strong>Inventory count:</strong> {preview.target.inventoryCount}</div>
</article>
</div>
<article
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
background: '#fafafa',
}}
>
<h3 style={{ marginTop: 0 }}>Det här kommer att hända</h3>
<div>
<strong>Inventory som flyttas:</strong> {preview.outcome.inventoryItemsToMove}
</div>
<div>
<strong>Source soft-deletas:</strong>{' '}
{preview.outcome.sourceWillBeSoftDeleted ? 'Ja' : 'Nej'}
</div>
<div>
<strong>Target förblir aktiv:</strong>{' '}
{preview.outcome.targetWillRemainActive ? 'Ja' : 'Nej'}
</div>
</article>
{isConfirming ? (
<article
style={{
border: '1px solid #e0b4b4',
borderRadius: '8px',
padding: '1rem',
background: '#fff6f6',
display: 'grid',
gap: '0.75rem',
}}
>
<h3 style={{ marginTop: 0 }}>Bekräfta merge</h3>
<p style={{ margin: 0 }}>
Du är väg att slå ihop{' '}
<strong>{preview.source.canonicalName || preview.source.name}</strong> in i{' '}
<strong>{preview.target.canonicalName || preview.target.name}</strong>.
</p>
<p style={{ margin: 0 }}>
Source-produkten kommer att soft-deletas och kan återställas senare, men
inventory flyttas till target.
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={confirmMerge}
disabled={isPending}
style={{
padding: '0.75rem 1.5rem',
background: '#c0392b',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
minHeight: '44px',
fontWeight: 600,
}}
>
{isPending ? 'Slår ihop...' : 'Bekräfta merge'}
</button>
<button
type="button"
onClick={() => setIsConfirming(false)}
disabled={isPending}
style={{
padding: '0.75rem 1.5rem',
background: '#f0f0f0',
color: '#333',
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '1rem',
minHeight: '44px',
fontWeight: 600,
}}
>
Avbryt
</button>
</div>
</article>
) : null}
</div>
) : null}
</div>
)}
</section>
);
}
@@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
export default function ResetProductsButton() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleClick() {
if (
!confirm(
'⚠️ Detta raderar ALLA produkter, inventory, taggar, kvitto-alias och pantry.\n\nKategorier och användare behålls.\n\nÄr du säker?',
)
)
return;
setError(null);
setIsPending(true);
try {
const res = await fetch('/api/admin/reset-products', { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Återställning misslyckades');
}
window.dispatchEvent(new CustomEvent('product-list-changed'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}
return (
<div style={{ marginBottom: '1.5rem' }}>
<button
type="button"
onClick={handleClick}
disabled={isPending}
style={{
padding: '0.6rem 1.25rem',
background: isPending ? '#ccc' : '#fff',
color: '#c00',
border: '1px solid #c00',
borderRadius: '4px',
cursor: isPending ? 'not-allowed' : 'pointer',
fontWeight: 600,
fontSize: '0.9rem',
}}
>
{isPending ? 'Återställer...' : '🗑 Återställ alla produkter'}
</button>
{error && (
<p style={{ color: 'crimson', marginTop: '0.5rem', fontSize: '0.9rem' }}>{error}</p>
)}
</div>
);
}
@@ -0,0 +1,230 @@
'use server';
import { revalidatePath } from 'next/cache';
import { API_BASE } from '../../../lib/api';
import { getAuthHeaders } from '../../../lib/auth-headers';
export async function updateProduct(formData: FormData) {
const id = Number(formData.get('id'));
const name = String(formData.get('name') || '').trim();
const canonicalName = String(formData.get('canonicalName') || '').trim();
const category = String(formData.get('category') || '').trim();
const subcategory = String(formData.get('subcategory') || '').trim();
const brand = String(formData.get('brand') || '').trim();
const categoryIdRaw = formData.get('categoryId');
const categoryId = categoryIdRaw !== '' && categoryIdRaw != null ? Number(categoryIdRaw) : null;
if (!name) throw new Error('Namn får inte vara tomt.');
if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.');
if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.');
if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.');
if (subcategory.length > 100) throw new Error('Underkategori får inte vara längre än 100 tecken.');
if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.');
const res = await fetch(`${API_BASE}/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({
name: name || undefined,
canonicalName: canonicalName || undefined,
category: category || null,
subcategory: subcategory || null,
brand: brand || null,
categoryId,
}),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera produkt: ${text}`);
}
revalidatePath('/admin/products');
}
export async function setProductTags(productId: number, tags: string[]) {
const res = await fetch(`${API_BASE}/api/products/${productId}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ tags }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera taggar: ${text}`);
}
revalidatePath('/admin/products');
}
export async function updateProductWithTags(formData: FormData, tags: string[]) {
const id = Number(formData.get('id'));
const name = String(formData.get('name') || '').trim();
const canonicalName = String(formData.get('canonicalName') || '').trim();
const category = String(formData.get('category') || '').trim();
const subcategory = String(formData.get('subcategory') || '').trim();
const brand = String(formData.get('brand') || '').trim();
const categoryIdRaw = formData.get('categoryId');
const categoryId = categoryIdRaw !== '' && categoryIdRaw != null ? Number(categoryIdRaw) : null;
if (!name) throw new Error('Namn får inte vara tomt.');
if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.');
if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.');
if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.');
if (subcategory.length > 100) throw new Error('Underkategori får inte vara längre än 100 tecken.');
if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.');
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({
name: name || undefined,
canonicalName: canonicalName || undefined,
category: category || null,
subcategory: subcategory || null,
brand: brand || null,
categoryId,
}),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera produkt: ${text}`);
}
const updatedProduct = await res.json();
const tagsRes = await fetch(`${API_BASE}/api/products/${id}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({ tags }),
cache: 'no-store',
});
if (!tagsRes.ok) {
const text = await tagsRes.text();
throw new Error(`Kunde inte uppdatera taggar: ${text}`);
}
// Fetch the complete product
const fullRes = await fetch(`${API_BASE}/api/products/${id}`, {
headers: authHeaders,
cache: 'no-store',
});
if (!fullRes.ok) return updatedProduct;
const result = await fullRes.json();
return result;
}
export async function deleteProduct(id: number) {
const res = await fetch(`${API_BASE}/api/products/${id}`, {
method: 'DELETE',
headers: { ...(await getAuthHeaders()) },
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte ta bort produkt: ${text}`);
}
}
export async function resetAllProducts() {
const res = await fetch(`${API_BASE}/api/products/reset-all`, {
method: 'POST',
headers: { ...(await getAuthHeaders()) },
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte återställa produkter: ${text}`);
}
revalidatePath('/admin/products');
}
export async function bulkSetCategory(ids: number[], categoryId: number | null) {
if (ids.length === 0) return;
const res = await fetch(`${API_BASE}/api/products/bulk-update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ ids, categoryId }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera produkter: ${text}`);
}
}
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,23 @@
import MergePreviewForm from './MergePreviewForm';
import AdminProductList from './AdminProductList';
import Navigation from '../../Navigation';
import ExpandableCreateProductSection from './ExpandableCreateProductSection';
import ResetProductsButton from './ResetProductsButton';
export default async function AdminProductsPage() {
return (
<main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '1.5rem' }}>Admin: Produkter</h1>
<p>Här kan du granska och standardisera produktnamn.</p>
<ExpandableCreateProductSection />
<ResetProductsButton />
<MergePreviewForm />
<AdminProductList />
</main>
);
}
@@ -0,0 +1,116 @@
'use client';
import { useState, useTransition } from 'react';
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 {
const res = await fetch(`/api/admin/product-status/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Fel vid uppdatering');
}
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>
);
}
@@ -0,0 +1,110 @@
'use client';
import { useState } from 'react';
type User = {
id: number;
username: string;
email: string;
firstName: string | null;
lastName: string | null;
role: string;
createdAt: string;
};
type Props = {
users: User[];
currentUserId: string;
};
export default function UserAdminClient({ users: initial, currentUserId }: Props) {
const [users, setUsers] = useState(initial);
const [loading, setLoading] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
async function toggleRole(user: User) {
const newRole = user.role === 'admin' ? 'user' : 'admin';
setLoading(user.id);
setError(null);
try {
const res = await fetch(`/api/admin-users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? 'Okänt fel');
}
const updated: User = await res.json();
setUsers((prev) => prev.map((u) => (u.id === updated.id ? { ...u, role: updated.role } : u)));
} catch (e: any) {
setError(e.message);
} finally {
setLoading(null);
}
}
return (
<>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-800 rounded">{error}</div>
)}
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100 text-left">
<th className="p-2 border">Användarnamn</th>
<th className="p-2 border">E-post</th>
<th className="p-2 border">Namn</th>
<th className="p-2 border">Roll</th>
<th className="p-2 border">Skapad</th>
<th className="p-2 border">Åtgärd</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const isSelf = String(user.id) === currentUserId;
return (
<tr key={user.id} className="hover:bg-gray-50">
<td className="p-2 border font-medium">{user.username}</td>
<td className="p-2 border">{user.email}</td>
<td className="p-2 border">
{[user.firstName, user.lastName].filter(Boolean).join(' ') || '—'}
</td>
<td className="p-2 border">
<span
className={`px-2 py-0.5 rounded text-xs font-semibold ${
user.role === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-700'
}`}
>
{user.role}
</span>
</td>
<td className="p-2 border text-gray-500">
{new Date(user.createdAt).toLocaleDateString('sv-SE')}
</td>
<td className="p-2 border">
{isSelf ? (
<span className="text-gray-400 text-xs">Du själv</span>
) : (
<button
onClick={() => toggleRole(user)}
disabled={loading === user.id}
className="px-3 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading === user.id
? 'Sparar…'
: user.role === 'admin'
? 'Sätt som user'
: 'Sätt som admin'}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</>
);
}
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
export default function AdminUsersPage() {
redirect('/profil?tab=anvandare');
}