feat: add TypeScript definitions for next-auth session with accessToken and user details
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 på 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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user