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
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
npm-debug.log
.git
.gitignore
*.log
*.tsbuildinfo
.env
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json ./
RUN npm install
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
+91
View File
@@ -0,0 +1,91 @@
import Link from 'next/link';
import { auth } from '../auth';
import { signOutAction } from './actions/auth-actions';
const linkStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
background: '#fff',
border: '1px solid #ddd',
borderRadius: '4px',
textDecoration: 'none',
color: '#0070f3',
fontSize: '0.9rem',
fontWeight: 500,
};
export default async function Navigation() {
const session = await auth();
return (
<nav
style={{
background: '#f9f9f9',
borderBottom: '1px solid #ddd',
padding: '0.75rem 1rem',
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
marginBottom: '1.5rem',
alignItems: 'center',
}}
>
<Link href="/" style={linkStyle}>🏠 Hem</Link>
<Link href="/inventory" style={linkStyle}>🛒 Varor</Link>
<Link href="/recipes" style={linkStyle}>📖 Recept</Link>
<Link href="/matsedel" style={linkStyle}>📅 Matsedel</Link>
<Link href="/import" style={linkStyle}>📥 Importera</Link>
<Link href="/baslager" style={linkStyle}>🏪 Baslager</Link>
<span style={{ flex: 1 }} />
{session?.user && (
<>
<Link
href="/profil"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
fontSize: '0.9rem',
color: '#555',
textDecoration: 'none',
padding: '0.3rem 0.5rem',
borderRadius: 4,
}}
>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
borderRadius: '50%',
background: '#2563eb',
color: 'white',
fontWeight: 700,
fontSize: '0.85rem',
}}
>
{session.user.name?.charAt(0).toUpperCase()}
</span>
{session.user.name}
</Link>
<form action={signOutAction}>
<button
type="submit"
style={{
...linkStyle,
cursor: 'pointer',
color: '#dc2626',
borderColor: '#dc2626',
}}
>
Logga ut
</button>
</form>
</>
)}
</nav>
);
}
+7
View File
@@ -0,0 +1,7 @@
'use client';
import { SessionProvider } from 'next-auth/react';
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
@@ -0,0 +1,7 @@
'use server';
import { signOut } from '../../auth';
export async function signOutAction() {
await signOut({ redirectTo: '/login' });
}
@@ -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');
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '../../../../../auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = await auth();
if (!session || (session.user as any)?.role !== 'admin') {
return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
}
const res = await fetch(`${API_BASE}/api/users/${id}/reset-password`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '../../../../auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
async function getAdminSession() {
const session = await auth();
if (!session || (session.user as any)?.role !== 'admin') return null;
return session;
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = await getAdminSession();
if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
const body = await request.json();
// Om body innehåller isPremium → anropa /premium-endpoint
if ('isPremium' in body) {
const res = await fetch(`${API_BASE}/api/users/${id}/premium`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ isPremium: body.isPremium }),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
// Annars → roll-byte
const res = await fetch(`${API_BASE}/api/users/${id}/role`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const session = await getAdminSession();
if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
const res = await fetch(`${API_BASE}/api/users/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await res.json().catch(() => ({ deleted: true }));
return NextResponse.json(data, { status: res.status });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
// PUT används för e-postbyte (PATCH /api/users/:id/email)
const { id } = await params;
const session = await getAdminSession();
if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
const body = await request.json();
const res = await fetch(`${API_BASE}/api/users/${id}/email`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '../../../auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
export async function GET() {
const session = await auth();
if (!session || (session.user as any)?.role !== 'admin') {
return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
}
const res = await fetch(`${API_BASE}/api/users`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session || (session.user as any)?.role !== 'admin') {
return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
}
const body = await request.json();
const res = await fetch(`${API_BASE}/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
@@ -0,0 +1,30 @@
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (req, session) => {
try {
const body = await req.json().catch(() => ({}));
const { productIds } = body;
const res = await fetch(`${API_BASE}/api/products/ai-categorize-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ productIds }),
});
if (!res.ok) {
const text = await res.text();
console.error('[api/admin/bulk-categorize] failed:', res.status, text);
return Response.json({ error: `Bulk-AI-kategorisering misslyckades: ${text}` }, { status: res.status });
}
return Response.json(await res.json());
} catch (err) {
console.error('[api/admin/bulk-categorize] error:', err);
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(req: Request) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { ids, categoryId } = body as { ids: number[]; categoryId: number | null };
const res = await fetch(`${API_BASE}/api/products/bulk-update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ ids, categoryId }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Bulk-uppdatering misslyckades' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,37 @@
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (req, session) => {
try {
const body = await req.json();
const { name } = body;
if (!name || typeof name !== 'string') {
return Response.json({ error: 'Name is required' }, { status: 400 });
}
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
return Response.json({ error: e.message ?? `HTTP ${res.status}` }, { status: res.status });
}
const product = await res.json();
return Response.json({
id: product.id,
name: product.name,
canonicalName: product.canonicalName ?? null,
});
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,29 @@
import { withAuth } from '../../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (_req, session, context) => {
const { id } = await context.params;
const productId = Number(id);
if (!productId) return Response.json({ error: 'Ogiltigt id' }, { status: 400 });
const res = await fetch(`${API_BASE}/api/products/${productId}/restore`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await res.json().catch(() => ({}));
return Response.json(data, { status: res.status });
});
export const DELETE = withAuth(async (_req, session, context) => {
const { id } = await context.params;
const productId = Number(id);
if (!productId) return Response.json({ error: 'Ogiltigt id' }, { status: 400 });
const res = await fetch(`${API_BASE}/api/products/${productId}/permanent`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await res.json().catch(() => ({}));
return Response.json(data, { status: res.status });
});
@@ -0,0 +1,11 @@
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_req, session) => {
const res = await fetch(`${API_BASE}/api/products/deleted`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const data = await res.json().catch(() => ([]));
return Response.json(data, { status: res.status });
});
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await req.json();
const res = await fetch(`${API_BASE}/api/inventory/${id}/consume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte förbruka inventory-rad' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await req.json();
const res = await fetch(`${API_BASE}/api/inventory/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte uppdatera inventory-rad' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const res = await fetch(`${API_BASE}/api/inventory/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte ta bort inventory-rad' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(req: Request) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const res = await fetch(`${API_BASE}/api/inventory`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte skapa inventory-rad' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const sourceProductId = searchParams.get('sourceProductId');
const targetProductId = searchParams.get('targetProductId');
const res = await fetch(
`${API_BASE}/api/products/merge-preview?sourceProductId=${sourceProductId}&targetProductId=${targetProductId}`,
{
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
},
);
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(req: Request) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { sourceProductId, targetProductId } = body as {
sourceProductId: number;
targetProductId: number;
};
const res = await fetch(`${API_BASE}/api/products/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ sourceProductId, targetProductId }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Sammanslagning misslyckades' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const res = await fetch(`${API_BASE}/api/pantry/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${session.accessToken}`,
},
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte ta bort baslager-vara' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(req: Request) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await req.json();
const { productId } = body as { productId: number };
const res = await fetch(`${API_BASE}/api/pantry`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ productId }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte lägga till baslager-vara' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,32 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await req.json();
const { status } = body as { status: 'active' | 'rejected' };
const res = await fetch(`${API_BASE}/api/products/${id}/status`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ status }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Kunde inte uppdatera status' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,95 @@
import { withAuth } from '../../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const PATCH = withAuth(async (req, session, context) => {
try {
const { id } = await context.params;
const productId = Number(id);
if (!productId) return Response.json({ error: 'Invalid id' }, { status: 400 });
const body = await req.json();
const { name, canonicalName, category, subcategory, brand, categoryId, tags } = body;
if (!name || typeof name !== 'string' || !name.trim()) {
return Response.json({ error: 'Namn får inte vara tomt.' }, { status: 400 });
}
const authHeader = `Bearer ${session.accessToken}`;
const patchRes = await fetch(`${API_BASE}/api/products/${productId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({
name: name.trim(),
canonicalName: canonicalName?.trim() || undefined,
category: category?.trim() || null,
subcategory: subcategory?.trim() || null,
brand: brand?.trim() || null,
categoryId: categoryId ?? null,
}),
});
if (!patchRes.ok) {
const text = await patchRes.text();
console.error('[api/admin/product] PATCH failed:', patchRes.status, text);
return Response.json({ error: `Kunde inte uppdatera produkt: ${text}` }, { status: patchRes.status });
}
const tagsRes = await fetch(`${API_BASE}/api/products/${productId}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: authHeader },
body: JSON.stringify({ tags: tags ?? [] }),
});
if (!tagsRes.ok) {
const text = await tagsRes.text();
console.error('[api/admin/product] tags PUT failed:', tagsRes.status, text);
return Response.json({ error: `Kunde inte uppdatera taggar: ${text}` }, { status: tagsRes.status });
}
const fullRes = await fetch(`${API_BASE}/api/products/${productId}`, {
headers: { Authorization: authHeader },
});
if (!fullRes.ok) {
return Response.json({ error: 'Produkt uppdaterad men kunde inte hämtas' }, { status: 500 });
}
return Response.json(await fullRes.json());
} catch (err) {
console.error('[api/admin/product] PATCH error:', err);
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
export const DELETE = withAuth(async (_req, session, context) => {
try {
const { id } = await context.params;
const productId = Number(id);
if (!productId) return Response.json({ error: 'Invalid id' }, { status: 400 });
const res = await fetch(`${API_BASE}/api/products/${productId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
const text = await res.text();
console.error('[api/admin/product] DELETE failed:', res.status, text);
return Response.json({ error: `Kunde inte ta bort produkt: ${text}` }, { status: res.status });
}
return new Response(null, { status: 204 });
} catch (err) {
console.error('[api/admin/product] DELETE error:', err);
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../../auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST() {
const session = await auth();
if (!session?.accessToken) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const res = await fetch(`${API_BASE}/api/products/reset-all`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
return NextResponse.json({ error: text || 'Återställning misslyckades' }, { status: res.status });
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,29 @@
import { withAuth } from '../../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_req, session, context) => {
try {
const { id } = await context.params;
const productId = Number(id);
if (!productId) return Response.json({ error: 'Invalid id' }, { status: 400 });
const res = await fetch(`${API_BASE}/api/products/${productId}/suggest-category`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
const text = await res.text();
console.error('[api/admin/suggest-category] failed:', res.status, text);
return Response.json({ error: `AI-kategorisering misslyckades: ${text}` }, { status: res.status });
}
return Response.json(await res.json());
} catch (err) {
console.error('[api/admin/suggest-category] error:', err);
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,36 @@
import { withAuth } from '../../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const PATCH = withAuth(async (req, session, context) => {
try {
const { id } = await context.params;
const productId = parseInt(id, 10);
const body = await req.json();
const { categoryId } = body;
const res = await fetch(`${API_BASE}/api/products/${productId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ categoryId }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
return Response.json({ error: e.message ?? `HTTP ${res.status}` }, { status: res.status });
}
const product = await res.json();
return Response.json({
id: product.id,
name: product.name,
canonicalName: product.canonicalName ?? null,
categoryId: product.categoryId ?? null,
});
} catch (err) {
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { auth } from '../../../auth';
export async function GET() {
const session = await auth();
if ((session?.user as any)?.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const key = process.env.MISTRAL_API_KEY ?? '';
const keyHint = key.length >= 4 ? key.slice(-4) : '????';
const hasKey = key.length > 0;
return NextResponse.json({ keyHint, hasKey });
}
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function GET() {
const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' });
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function POST(request: NextRequest) {
const body = await request.json();
const res = await fetch(`${API_BASE}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -0,0 +1,3 @@
import { handlers } from '../../../../auth';
export const { GET, POST } = handlers;
@@ -0,0 +1,14 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function GET(req: NextRequest) {
const isTree = req.nextUrl.searchParams.has('tree');
const endpoint = isTree ? '/api/categories/tree' : '/api/categories';
const res = await fetch(`${API_BASE}${endpoint}`, { cache: 'no-store' });
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import * as fs from 'fs';
import * as path from 'path';
// turbopackIgnore: true — IMAGE_DIR är en runtime env-variabel, inte statisk sökväg
const IMAGE_DIR: string = /* turbopackIgnore: true */ (process.env.IMAGE_DIR || '/app/public/images') as string;
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ filename: string }> },
) {
const { filename } = await params;
// Förhindra path traversal
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return new NextResponse('Not found', { status: 404 });
}
const filePath = path.join(IMAGE_DIR, filename);
if (!fs.existsSync(filePath)) {
return new NextResponse('Not found', { status: 404 });
}
const file = fs.readFileSync(filePath);
return new NextResponse(file, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const res = await fetch(`${API_BASE}/api/inventory/${id}/consumption-history`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const { search } = new URL(request.url);
const res = await fetch(`${API_BASE}/api/inventory${search}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const POST = withAuth(async (request, session) => {
const body = await request.json();
const res = await fetch(`${API_BASE}/api/inventory`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const from = searchParams.get('from');
const to = searchParams.get('to');
const res = await fetch(`${API_BASE}/api/meal-plan/inventory-compare?from=${from}&to=${to}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const query = searchParams.toString();
const res = await fetch(`${API_BASE}/api/meal-plan${query ? `?${query}` : ''}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const POST = withAuth(async (request, session) => {
const body = await request.text();
const res = await fetch(`${API_BASE}/api/meal-plan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body,
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const DELETE = withAuth(async (request, session) => {
const date = new URL(request.url).searchParams.get('date');
const res = await fetch(`${API_BASE}/api/meal-plan/${date}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
return new NextResponse(null, { status: res.status });
});
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const { searchParams } = new URL(request.url);
const from = searchParams.get('from');
const to = searchParams.get('to');
const res = await fetch(`${API_BASE}/api/meal-plan/shopping-list?from=${from}&to=${to}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_request, session) => {
const res = await fetch(`${API_BASE}/api/pantry`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (request, session) => {
const body = await request.text();
const res = await fetch(`${API_BASE}/api/recipes/parse-markdown`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body,
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,41 @@
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (req, session) => {
try {
const body = await req.json();
const { name } = body;
if (!name || typeof name !== 'string') {
return Response.json({ error: 'Name is required' }, { status: 400 });
}
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
return Response.json(
{ error: e.message ?? `HTTP ${res.status}` },
{ status: res.status },
);
}
const product = await res.json();
return Response.json({
id: product.id,
name: product.name,
canonicalName: product.canonicalName ?? null,
});
} catch (err) {
console.error('[products-create] Error:', err);
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,44 @@
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const PATCH = withAuth(async (req, session, context) => {
try {
const { id } = await context.params;
const productId = parseInt(id, 10);
const body = await req.json();
const { categoryId } = body;
if (!categoryId || typeof categoryId !== 'number') {
return Response.json({ error: 'categoryId is required' }, { status: 400 });
}
const res = await fetch(`${API_BASE}/api/products/${productId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ categoryId }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
return Response.json(
{ error: e.message ?? `HTTP ${res.status}` },
{ status: res.status },
);
}
const product = await res.json();
return Response.json({
id: product.id,
name: product.name,
canonicalName: product.canonicalName ?? null,
categoryId: product.categoryId ?? null,
});
} catch (err) {
console.error('[products-update] Error:', err);
return Response.json(
{ error: err instanceof Error ? err.message : 'Unknown error' },
{ status: 500 },
);
}
});
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const PATCH = withAuth(async (req, session, context) => {
const { id } = await context.params;
const body = await req.json();
const res = await fetch(`${API_BASE}/api/products/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return NextResponse.json(data, { status: res.status });
});
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (req, session) => {
const body = await req.json();
const res = await fetch(`${API_BASE}/api/products/pending`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
});
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const query = url.searchParams.toString();
const res = await fetch(`${API_BASE}/api/products${query ? `?${query}` : ''}`, {
cache: 'no-store',
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
export const POST = withAuth(async (req, session) => {
const body = await req.json();
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
});
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_req, session) => {
const res = await fetch(`${API_BASE}/api/users/me`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const PATCH = withAuth(async (request, session) => {
const body = await request.json();
const res = await fetch(`${API_BASE}/api/users/me`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,46 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
export const POST = withAuth(async (request, session) => {
try {
const contentType = request.headers.get('content-type') ?? '';
const isMultipart = contentType.includes('multipart/form-data');
const backendUrl = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
const response = await fetch(`${backendUrl}/api/quick-import`, {
method: 'POST',
body: isMultipart
? await request.formData()
: JSON.stringify(await request.json()),
headers: isMultipart
? { Authorization: `Bearer ${session.accessToken}` }
: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await response.text();
try {
const parsed = JSON.parse(text);
// eslint-disable-next-line no-console
console.log('[QuickImportProxy] backend response', {
status: response.status,
hasMarkdown: Boolean(parsed?.markdown),
imageUrl: parsed?.imageUrl ?? null,
imageWarning: parsed?.imageWarning ?? null,
});
} catch {
// eslint-disable-next-line no-console
console.log('[QuickImportProxy] backend non-json response', {
status: response.status,
contentType: response.headers.get('content-type'),
});
}
return new NextResponse(text, {
status: response.status,
headers: { 'Content-Type': response.headers.get('content-type') ?? 'application/json' },
});
} catch (error) {
console.error('[QuickImportProxy] EXCEPTION:', error);
return NextResponse.json({ message: 'Kunde inte nå importtjänsten.' }, { status: 503 });
}
});
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_request, session) => {
const res = await fetch(`${API_BASE}/api/receipt-aliases`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const POST = withAuth(async (request, session) => {
const body = await request.json();
const res = await fetch(`${API_BASE}/api/receipt-aliases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const DELETE = withAuth(async (request, session) => {
const id = new URL(request.url).searchParams.get('id');
const res = await fetch(`${API_BASE}/api/receipt-aliases/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
return new NextResponse(null, { status: res.status });
});
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE =
process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (request, session) => {
const formData = await request.formData();
const res = await fetch(`${API_BASE}/api/receipt-import`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: formData,
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session) => {
const id = new URL(request.url).searchParams.get('id');
if (!id) {
return NextResponse.json({ error: 'Missing id parameter' }, { status: 400 });
}
const res = await fetch(`${API_BASE}/api/recipes/${id}/inventory-preview`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const POST = withAuth(async (request, session, context) => {
const { id } = await context.params;
const body = await request.text();
const res = await fetch(`${API_BASE}/api/recipes/${id}/image`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body,
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (request, session, context) => {
const { id } = await context.params;
const res = await fetch(`${API_BASE}/api/recipes/${id}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const PATCH = withAuth(async (request, session, context) => {
const { id } = await context.params;
const body = await request.json();
const res = await fetch(`${API_BASE}/api/recipes/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const DELETE = withAuth(async (_request, session, context) => {
const { id } = await context.params;
const res = await fetch(`${API_BASE}/api/recipes/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
return new NextResponse(null, { status: res.status });
});
@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_request, session) => {
const res = await fetch(`${API_BASE}/api/recipes`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
});
export const POST = withAuth(async (request, session) => {
const body = await request.json();
const res = await fetch(`${API_BASE}/api/recipes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
});
});
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const DELETE = withAuth(async (_request, session, context) => {
const { productId } = await context.params;
const res = await fetch(`${API_BASE}/api/user-products/${productId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${session.accessToken}` },
});
return new NextResponse(null, { status: res.status });
});
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth } from '../../../lib/with-auth';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export const GET = withAuth(async (_request, session) => {
const res = await fetch(`${API_BASE}/api/user-products`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
cache: 'no-store',
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
export const POST = withAuth(async (request, session) => {
const body = await request.json();
const res = await fetch(`${API_BASE}/api/user-products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify(body),
});
const text = await res.text();
return new NextResponse(text, { status: res.status, headers: { 'Content-Type': 'application/json' } });
});
@@ -0,0 +1,87 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Product } from '../../features/inventory/types';
type Props = {
products: Product[];
pantryProductIds: Set<number>;
onCreated?: () => void;
};
export default function AddToPantryForm({ products, pantryProductIds, onCreated }: Props) {
const [selectedId, setSelectedId] = useState('');
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const available = products.filter((p) => !pantryProductIds.has(p.id));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedId) return;
setError(null);
setIsPending(true);
try {
const res = await fetch('/api/admin/pantry-item', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: Number(selectedId) }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Kunde inte lägga till');
}
setSelectedId('');
if (onCreated) onCreated();
else router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<select
value={selectedId}
onChange={(e) => setSelectedId(e.target.value)}
required
style={{
flex: '1 1 220px',
padding: '0.6rem 0.75rem',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '1rem',
}}
>
<option value="">Välj produkt</option>
{available.map((p) => (
<option key={p.id} value={p.id}>
{p.canonicalName || p.name}
</option>
))}
</select>
<button
type="submit"
disabled={isPending || !selectedId}
style={{
padding: '0.6rem 1.25rem',
background: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontWeight: 600,
cursor: isPending || !selectedId ? 'not-allowed' : 'pointer',
opacity: isPending || !selectedId ? 0.6 : 1,
fontSize: '1rem',
}}
>
{isPending ? 'Lägger till…' : 'Lägg till'}
</button>
{error && <span style={{ color: 'crimson', fontSize: '0.9rem' }}>{error}</span>}
</form>
);
}
@@ -0,0 +1,100 @@
'use client';
import { useRouter } from 'next/navigation';
type PantryItem = {
id: number;
product: { id: number; name: string; canonicalName: string | null; category: string | null };
};
type Props = {
items: PantryItem[];
onDeleted?: () => void;
};
export default function PantryList({ items, onDeleted }: Props) {
const router = useRouter();
async function handleRemove(id: number, name: string) {
if (!confirm(`Ta bort "${name}" från baslagret?`)) return;
const res = await fetch(`/api/admin/pantry-item/${id}`, { method: 'DELETE' });
if (res.ok) {
if (onDeleted) onDeleted();
else router.refresh();
}
}
if (items.length === 0) {
return (
<p style={{ color: '#888', fontStyle: 'italic' }}>
Baslagret är tomt. Lägg till produkter ovan.
</p>
);
}
// Gruppera per kategori
const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
const cat = item.product.category || 'Övrigt';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const sortedCategories = Object.keys(grouped).sort((a, b) => {
if (a === 'Övrigt') return 1;
if (b === 'Övrigt') return -1;
return a.localeCompare(b, 'sv');
});
return (
<div style={{ display: 'grid', gap: '1.5rem' }}>
{sortedCategories.map((category) => (
<section key={category}>
<h3 style={{ margin: '0 0 0.5rem', fontSize: '1rem', color: '#555', fontWeight: 600 }}>
{category}
</h3>
<div style={{ display: 'grid', gap: '0.4rem' }}>
{grouped[category].map((item) => {
const displayName = item.product.canonicalName || item.product.name;
return (
<div
key={item.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.6rem 0.75rem',
border: '1px solid #eee',
borderRadius: '6px',
background: '#fafafa',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{displayName}
</span>
<button
type="button"
onClick={() => handleRemove(item.id, displayName)}
style={{
background: 'none',
border: 'none',
color: '#c00',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '0.2rem 0.5rem',
lineHeight: 1,
flexShrink: 0,
}}
title="Ta bort från baslagret"
>
×
</button>
</div>
);
})}
</div>
</section>
))}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
'use server';
import { revalidatePath } from 'next/cache';
import { API_BASE } from '../../lib/api';
import { getAuthHeaders } from '../../lib/auth-headers';
export async function addPantryItem(productId: number) {
const res = await fetch(`${API_BASE}/api/pantry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ productId }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte lägga till i baslagret: ${text}`);
}
revalidatePath('/baslager');
}
export async function removePantryItem(id: number) {
const res = await fetch(`${API_BASE}/api/pantry/${id}`, {
method: 'DELETE',
headers: { ...(await getAuthHeaders()) },
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte ta bort från baslagret: ${text}`);
}
revalidatePath('/baslager');
}
+45
View File
@@ -0,0 +1,45 @@
import { fetchJson } from '../../lib/api';
import type { Product } from '../../features/inventory/types';
import Navigation from '../Navigation';
import AddToPantryForm from './AddToPantryForm';
import PantryList from './PantryList';
type PantryItem = {
id: number;
productId: number;
createdAt: string;
updatedAt: string;
product: Product;
};
export default async function BaslagerPage() {
const [pantryItems, products] = await Promise.all([
fetchJson<PantryItem[]>('/api/pantry'),
fetchJson<Product[]>('/api/products'),
]);
const pantryProductIds = new Set(pantryItems.map((i) => i.productId));
return (
<main style={{ padding: '1rem', maxWidth: '700px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.5rem' }}>Baslager</h1>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Produkter du alltid räknar med att ha hemma.
</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>Lägg till produkt</h2>
<AddToPantryForm products={products} pantryProductIds={pantryProductIds} />
</section>
<section>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>
{pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret
</h2>
<PantryList items={pantryItems} />
</section>
</main>
);
}
@@ -0,0 +1,232 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRef, useState, useEffect } from 'react';
import ReceiptImportClient from '../kvitto/ReceiptImportClient';
import { parseErrorResponse } from '../../lib/error-handler';
type Tab = 'kvitto' | 'recept';
type Product = { id: number; name: string; canonicalName: string | null };
export default function ImportTabsClient({ activeTab, isAdmin }: { activeTab: Tab; isAdmin: boolean }) {
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1rem' }}>Importera</h1>
{/* Flikar */}
<div style={{ display: 'flex', gap: '0', marginBottom: '1.5rem', borderBottom: '2px solid #e5e7eb' }}>
<Link
href="/import?tab=kvitto"
style={{
padding: '0.6rem 1.25rem',
fontWeight: 600,
fontSize: '0.95rem',
textDecoration: 'none',
borderBottom: activeTab === 'kvitto' ? '2px solid #0070f3' : '2px solid transparent',
marginBottom: '-2px',
color: activeTab === 'kvitto' ? '#0070f3' : '#666',
background: 'transparent',
}}
>
🧾 Kvitto
</Link>
<Link
href="/import?tab=recept"
style={{
padding: '0.6rem 1.25rem',
fontWeight: 600,
fontSize: '0.95rem',
textDecoration: 'none',
borderBottom: activeTab === 'recept' ? '2px solid #0070f3' : '2px solid transparent',
marginBottom: '-2px',
color: activeTab === 'recept' ? '#0070f3' : '#666',
background: 'transparent',
}}
>
📋 Recept
</Link>
</div>
{/* Innehåll */}
{activeTab === 'kvitto' && (
<div>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Fotografera eller ladda upp ett kvitto varorna läggs till i ditt inventarie.
</p>
<ReceiptImportClient isAdmin={isAdmin} />
</div>
)}
{activeTab === 'recept' && <ReceptImport />}
</main>
);
}
function ReceptImport() {
const router = useRouter();
const [selectedMethod, setSelectedMethod] = useState<'file' | 'url'>('file');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [url, setUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleFileSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!selectedFile) { setError('Välj en PDF eller bildfil först.'); return; }
setError(null);
setIsLoading(true);
try {
const formData = new FormData();
formData.append('file', selectedFile);
const res = await fetch('/api/quick-import-proxy', { method: 'POST', body: formData });
if (!res.ok) throw new Error(await parseErrorResponse(res));
const data = await res.json();
// eslint-disable-next-line no-console
console.log('[ImportTabsClient:file] quick-import response', {
imageUrl: data.imageUrl ?? null,
imageWarning: data.imageWarning ?? null,
markdownLength: (data.markdown ?? '').length,
});
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
if (data.imageUrl) {
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
} else {
sessionStorage.removeItem('prefilled_image_url');
}
// eslint-disable-next-line no-console
console.log('[ImportTabsClient:file] sessionStorage snapshot', {
prefilled_markdown: sessionStorage.getItem('prefilled_markdown')?.length ?? 0,
prefilled_image_url: sessionStorage.getItem('prefilled_image_url'),
});
router.push('/recipes/write');
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
} finally {
setIsLoading(false);
}
};
const handleUrlSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!url.trim()) { setError('Vänligen ange en URL.'); return; }
setError(null);
setIsLoading(true);
try {
const res = await fetch('/api/quick-import-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: url.trim() }),
});
if (!res.ok) throw new Error(await parseErrorResponse(res));
const data = await res.json();
// eslint-disable-next-line no-console
console.log('[ImportTabsClient:url] quick-import response', {
imageUrl: data.imageUrl ?? null,
imageWarning: data.imageWarning ?? null,
markdownLength: (data.markdown ?? '').length,
});
sessionStorage.setItem('prefilled_markdown', data.markdown ?? '');
if (data.imageUrl) {
sessionStorage.setItem('prefilled_image_url', data.imageUrl);
} else {
sessionStorage.removeItem('prefilled_image_url');
}
// eslint-disable-next-line no-console
console.log('[ImportTabsClient:url] sessionStorage snapshot', {
prefilled_markdown: sessionStorage.getItem('prefilled_markdown')?.length ?? 0,
prefilled_image_url: sessionStorage.getItem('prefilled_image_url'),
});
router.push('/recipes/write');
} catch (err) {
setError(err instanceof Error ? err.message : 'Importen misslyckades.');
} finally {
setIsLoading(false);
}
};
return (
<div>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Ladda upp en PDF eller bild för OCR, eller ange en receptlänk.
</p>
{error && (
<div style={{ background: '#fef2f2', border: '1px solid #fca5a5', borderRadius: '6px', padding: '1rem', marginBottom: '1.5rem', color: '#dc2626', fontSize: '0.95rem' }}>
{error}
</div>
)}
{/* Välj metod */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
{(['file', 'url'] as const).map((m) => (
<button
key={m}
onClick={() => setSelectedMethod(m)}
style={{
padding: '0.5rem 1rem',
border: '1px solid',
borderColor: selectedMethod === m ? '#0070f3' : '#d1d5db',
borderRadius: '6px',
background: selectedMethod === m ? '#eff6ff' : '#fff',
color: selectedMethod === m ? '#0070f3' : '#555',
fontWeight: selectedMethod === m ? 600 : 400,
cursor: 'pointer',
fontSize: '0.9rem',
}}
>
{m === 'file' ? '📄 Fil / PDF' : '🔗 Länk'}
</button>
))}
</div>
{selectedMethod === 'file' && (
<form onSubmit={handleFileSubmit} style={{ display: 'grid', gap: '0.75rem', maxWidth: '500px' }}>
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg,.webp,.bmp"
onChange={(e) => setSelectedFile(e.target.files?.[0] ?? null)}
style={{ padding: '0.75rem', background: 'white', border: '1px solid #cbd5e1', borderRadius: '6px' }}
/>
<button
type="submit"
disabled={!selectedFile || isLoading}
style={{ padding: '0.75rem', background: '#0070f3', color: 'white', border: 'none', borderRadius: '4px', cursor: !selectedFile || isLoading ? 'not-allowed' : 'pointer', opacity: !selectedFile || isLoading ? 0.6 : 1, fontWeight: 600 }}
>
{isLoading ? 'Importerar...' : 'Importera fil'}
</button>
</form>
)}
{selectedMethod === 'url' && (
<form onSubmit={handleUrlSubmit} style={{ display: 'grid', gap: '0.75rem', maxWidth: '500px' }}>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://exempel.se/recept/..."
style={{ padding: '0.75rem', border: '1px solid #d1d5db', borderRadius: '6px', fontSize: '0.9rem' }}
/>
<button
type="submit"
disabled={!url.trim() || isLoading}
style={{ padding: '0.75rem', background: '#10b981', color: 'white', border: 'none', borderRadius: '4px', cursor: !url.trim() || isLoading ? 'not-allowed' : 'pointer', opacity: !url.trim() || isLoading ? 0.6 : 1, fontWeight: 600 }}
>
{isLoading ? 'Importerar...' : 'Importera från länk'}
</button>
</form>
)}
<div style={{ background: '#f0fdf4', border: '1px solid #86efac', borderRadius: '6px', padding: '1rem', marginTop: '1.5rem', color: '#166534', fontSize: '0.9rem' }}>
Efter import öppnas receptet automatiskt i redigeringsläget.
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1.5rem' }}>
<Link href="/recipes/write" style={{ padding: '0.75rem 1.5rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '4px', textDecoration: 'none', color: '#333', fontWeight: 500 }}>
Skriv in recept istället
</Link>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Metadata } from 'next';
import Navigation from '../Navigation';
import ImportTabsClient from './ImportTabsClient';
import { auth } from '../../auth';
type Props = {
searchParams: Promise<{ tab?: string }>;
};
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const { tab } = await searchParams;
if (tab === 'recept') return { title: 'Importera recept' };
return { title: 'Importera kvitto' };
}
export default async function ImportPage({ searchParams }: Props) {
const { tab } = await searchParams;
const activeTab = tab === 'recept' ? 'recept' : 'kvitto';
const session = await auth();
const isAdmin = (session?.user as any)?.role === 'admin';
return (
<>
<Navigation />
<ImportTabsClient activeTab={activeTab} isAdmin={isAdmin} />
</>
);
}
@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
type Props = {
id: number;
unit: string;
};
export default function InventoryConsumeForm({ id, unit }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
if (!isOpen) {
return (
<button
type="button"
onClick={() => setIsOpen(true)}
style={{ padding: '0.5rem 0.75rem' }}
>
Använt
</button>
);
}
return (
<div
style={{
width: '100%',
display: 'grid',
gap: '0.75rem',
marginTop: '0.5rem',
}}
>
<form
onSubmit={async (e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
const raw = formData.get('amountUsed') as string;
const { quantity, unit: parsedUnit } = parseQuantityInput(raw, unit);
formData.set('amountUsed', String(quantity));
formData.set('unit', parsedUnit);
const comment = String(formData.get('comment') || '').trim();
const payload: Record<string, unknown> = { amountUsed: quantity };
if (comment) payload.comment = comment;
setIsPending(true);
try {
const res = await fetch(`/api/admin/inventory-item/${id}/consume`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Kunde inte förbruka');
}
setIsOpen(false);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}}
style={{
display: 'grid',
gap: '0.75rem',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.75rem',
background: '#fafafa',
}}
>
<input type="hidden" name="id" value={id} />
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Hur mycket använde du? ({unit})</span>
<input
name="amountUsed"
type="text"
required
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Kommentar</span>
<input
name="comment"
type="text"
placeholder="t.ex. lagade middag"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="submit"
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 ? 'Sparar...' : 'Spara användning'}
</button>
<button
type="button"
onClick={() => setIsOpen(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>
</form>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
function parseQuantityInput(input: string, defaultUnit: string) {
const match = input.trim().match(/^([\d.,]+)\s*([a-zA-Z]*)$/);
if (!match) return { quantity: NaN, unit: defaultUnit };
let [, num, unit] = match;
num = num.replace(',', '.');
unit = unit.toLowerCase() || defaultUnit;
const value = parseFloat(num);
// Konvertera alltid till defaultUnit
if (defaultUnit === 'kg') {
if (unit === 'g' || unit === 'gram') return { quantity: value / 1000, unit: 'kg' };
if (unit === 'hg' || unit === 'hektogram') return { quantity: value / 10, unit: 'kg' };
if (unit === 'kg' || unit === 'kilogram' || unit === '') return { quantity: value, unit: 'kg' };
}
if (defaultUnit === 'g') {
if (unit === 'kg' || unit === 'kilogram') return { quantity: value * 1000, unit: 'g' };
if (unit === 'hg' || unit === 'hektogram') return { quantity: value * 100, unit: 'g' };
if (unit === 'g' || unit === 'gram' || unit === '') return { quantity: value, unit: 'g' };
}
// Lägg till fler konverteringar vid behov
return { quantity: value, unit: defaultUnit };
}
@@ -0,0 +1,127 @@
'use client';
import { useState, useTransition } from 'react';
import { parseErrorResponse } from '../../lib/error-handler';
import type { InventoryConsumption } from '../../features/inventory/types';
type Props = {
id: number;
};
function formatDateTime(value: string) {
return new Date(value).toLocaleString('sv-SE');
}
export default function InventoryConsumptionHistory({ id }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [history, setHistory] = useState<InventoryConsumption[] | null>(null);
const loadHistory = () => {
setError(null);
startTransition(async () => {
try {
const res = await fetch(`/api/inventory-history-proxy?id=${id}`, {
method: 'GET',
cache: 'no-store',
});
if (!res.ok) {
const errorMessage = await parseErrorResponse(res);
throw new Error(errorMessage);
}
const data: InventoryConsumption[] = await res.json();
setHistory(data);
setIsOpen(true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
setError(message);
}
});
};
if (!isOpen) {
return (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<button
type="button"
onClick={loadHistory}
disabled={isPending}
style={{ padding: '0.5rem 0.75rem' }}
>
{isPending ? 'Hämtar historik...' : 'Visa historik'}
</button>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
return (
<div
style={{
width: '100%',
display: 'grid',
gap: '0.75rem',
marginTop: '0.5rem',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.75rem',
background: '#fafafa',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.75rem',
flexWrap: 'wrap',
}}
>
<strong>Förbrukningshistorik</strong>
<button
type="button"
onClick={() => setIsOpen(false)}
style={{ padding: '0.45rem 0.75rem' }}
>
Dölj
</button>
</div>
{history && history.length > 0 ? (
<div style={{ display: 'grid', gap: '0.5rem' }}>
{history.map((entry) => (
<article
key={entry.id}
style={{
border: '1px solid #e5e5e5',
borderRadius: '6px',
padding: '0.6rem',
background: '#fff',
}}
>
<div>
<strong>Använt:</strong> {entry.amountUsed}{entry.inventoryItem?.unit ? ` ${entry.inventoryItem.unit}` : ''}
</div>
<div>
<strong>Tid:</strong> {formatDateTime(entry.createdAt)}
</div>
{entry.comment ? (
<div>
<strong>Kommentar:</strong> {entry.comment}
</div>
) : null}
</article>
))}
</div>
) : (
<p style={{ margin: 0 }}>Ingen förbrukningshistorik ännu.</p>
)}
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
@@ -0,0 +1,323 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { InventoryItem } from '../../features/inventory/types';
import { UNIT_OPTIONS } from '../../lib/units';
type Props = {
item: InventoryItem;
onUpdated?: () => void;
};
function toDateInputValue(value: string | null) {
if (!value) return '';
return value.slice(0, 10);
}
function parseQuantityInput(input: string, defaultUnit: string) {
const match = input.trim().match(/^([\d.,]+)\s*([a-zA-Z]*)$/);
if (!match) return { quantity: NaN, unit: defaultUnit };
let [, num, unit] = match;
num = num.replace(',', '.');
unit = unit.toLowerCase() || defaultUnit;
const value = parseFloat(num);
// Konvertera alltid till defaultUnit
if (defaultUnit === 'kg') {
if (unit === 'g' || unit === 'gram') return { quantity: value / 1000, unit: 'kg' };
if (unit === 'hg' || unit === 'hektogram') return { quantity: value / 10, unit: 'kg' };
if (unit === 'kg' || unit === 'kilogram' || unit === '') return { quantity: value, unit: 'kg' };
}
if (defaultUnit === 'g') {
if (unit === 'kg' || unit === 'kilogram') return { quantity: value * 1000, unit: 'g' };
if (unit === 'hg' || unit === 'hektogram') return { quantity: value * 100, unit: 'g' };
if (unit === 'g' || unit === 'gram' || unit === '') return { quantity: value, unit: 'g' };
}
// Lägg till fler konverteringar vid behov
return { quantity: value, unit: defaultUnit };
}
const LOCATION_OPTIONS = [
{ value: '', label: 'Välj plats' },
{ value: 'Kyl', label: 'Kyl' },
{ value: 'Frys', label: 'Frys' },
{ value: 'Skafferi', label: 'Skafferi' },
{ value: 'Annat', label: 'Annat' },
];
export default function InventoryEditForm({ item, onUpdated }: Props) {
const [isEditing, setIsEditing] = useState(false);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
if (!isEditing) {
return (
<button
type="button"
onClick={() => setIsEditing(true)}
style={{ padding: '0.5rem 0.75rem' }}
>
Redigera
</button>
);
}
return (
<div
style={{
width: '100%',
display: 'grid',
gap: '0.75rem',
marginTop: '0.5rem',
}}
>
<form
onSubmit={async (e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const formData = new FormData(form);
const raw = formData.get('quantity') as string;
const unit = formData.get('unit') as string;
const { quantity, unit: parsedUnit } = parseQuantityInput(raw, unit);
const payload: Record<string, unknown> = { opened: formData.get('opened') === 'on' };
if (raw) payload.quantity = quantity;
if (parsedUnit) payload.unit = parsedUnit;
payload.location = String(formData.get('location') || '').trim();
payload.brand = String(formData.get('brand') || '').trim();
payload.suitableFor = String(formData.get('suitableFor') || '').trim();
payload.comment = String(formData.get('comment') || '').trim();
const bestBeforeDate = String(formData.get('bestBeforeDate') || '').trim();
payload.bestBeforeDate = bestBeforeDate || null;
setIsPending(true);
try {
const res = await fetch(`/api/admin/inventory-item/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Kunde inte uppdatera');
}
setIsEditing(false);
if (onUpdated) onUpdated();
else router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}}
style={{
display: 'grid',
gap: '0.75rem',
border: '1px solid #eee',
borderRadius: '8px',
padding: '0.75rem',
background: '#fafafa',
}}
>
<input type="hidden" name="id" value={item.id} />
<div
style={{
display: 'grid',
gap: '0.75rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
}}
>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Mängd</span>
<input
name="quantity"
type="text"
required
defaultValue={item.quantity}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Enhet</span>
<select
name="unit"
defaultValue={item.unit}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
>
{UNIT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Plats</span>
<select
name="location"
defaultValue={item.location || ''}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
>
{LOCATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Varumärke</span>
<input
name="brand"
type="text"
defaultValue={item.brand || ''}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Bäst före</span>
<input
name="bestBeforeDate"
type="date"
defaultValue={toDateInputValue(item.bestBeforeDate)}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
</div>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Passar till</span>
<input
name="suitableFor"
type="text"
defaultValue={item.suitableFor || ''}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
<label style={{ display: 'grid', gap: '0.3rem' }}>
<span style={{ fontWeight: 500, fontSize: '0.9rem' }}>Kommentar</span>
<input
name="comment"
type="text"
defaultValue={item.comment || ''}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
name="opened"
type="checkbox"
defaultChecked={item.opened ?? false}
/>
Öppnad
</label>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
type="submit"
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 ? 'Sparar...' : 'Spara ändringar'}
</button>
<button
type="button"
onClick={() => setIsEditing(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>
</form>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
);
}
@@ -0,0 +1,234 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Product } from '../../features/inventory/types';
import { UNIT_OPTIONS } from '../../lib/units';
type Props = {
products: Product[];
onCreated?: () => void;
};
export default function InventoryForm({ products, onCreated }: Props) {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const LOCATION_OPTIONS = [
{ value: '', label: 'Välj plats' },
{ value: 'Kyl', label: 'Kyl' },
{ value: 'Frys', label: 'Frys' },
{ value: 'Skafferi', label: 'Skafferi' },
{ value: 'Annat', label: 'Annat' },
];
function parseQuantityInput(input: string, defaultUnit: string) {
const match = input.trim().match(/^([\d.,]+)\s*([a-zA-Z]*)$/);
if (!match) return { quantity: NaN, unit: defaultUnit };
let [, num, unit] = match;
num = num.replace(',', '.');
unit = unit.toLowerCase() || defaultUnit;
const value = parseFloat(num);
// Konvertera alltid till defaultUnit
if (defaultUnit === 'kg') {
if (unit === 'g' || unit === 'gram') return { quantity: value / 1000, unit: 'kg' };
if (unit === 'hg' || unit === 'hektogram') return { quantity: value / 10, unit: 'kg' };
if (unit === 'kg' || unit === 'kilogram' || unit === '') return { quantity: value, unit: 'kg' };
}
if (defaultUnit === 'g') {
if (unit === 'kg' || unit === 'kilogram') return { quantity: value * 1000, unit: 'g' };
if (unit === 'hg' || unit === 'hektogram') return { quantity: value * 100, unit: 'g' };
if (unit === 'g' || unit === 'gram' || unit === '') return { quantity: value, unit: 'g' };
}
// Lägg till fler konverteringar vid behov
return { quantity: value, unit: defaultUnit };
}
return (
<div style={{ marginBottom: '1.5rem' }}>
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
style={{
padding: '0.6rem 1rem',
border: '1px solid #ddd',
borderRadius: '6px',
background: '#fff',
cursor: 'pointer',
fontWeight: 500,
fontSize: '1rem',
width: '100%',
textAlign: 'left',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Lägg till hemmavara</span>
<span>{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<form
onSubmit={async (e) => {
e.preventDefault();
setError(null);
setIsPending(true);
const form = e.currentTarget;
const formData = new FormData(form);
const raw = formData.get('quantity') as string;
const unit = formData.get('unit') as string;
const { quantity, unit: parsedUnit } = parseQuantityInput(raw, unit);
formData.set('quantity', String(quantity));
formData.set('unit', parsedUnit);
try {
const payload: Record<string, unknown> = {
productId: Number(formData.get('productId')),
quantity,
unit: parsedUnit,
};
const location = String(formData.get('location') || '').trim();
if (location) payload.location = location;
payload.opened = formData.get('opened') === 'on';
const brand = String(formData.get('brand') || '').trim();
if (brand) payload.brand = brand;
const suitableFor = String(formData.get('suitableFor') || '').trim();
if (suitableFor) payload.suitableFor = suitableFor;
const bestBeforeDate = String(formData.get('bestBeforeDate') || '').trim();
if (bestBeforeDate) payload.bestBeforeDate = bestBeforeDate;
const res = await fetch('/api/admin/inventory-item', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Kunde inte spara');
}
form.reset();
if (onCreated) onCreated();
else router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}}
style={{
display: 'grid',
gap: '0.75rem',
padding: '1rem',
border: '1px solid #ddd',
borderTop: 'none',
borderRadius: '0 0 8px 8px',
marginBottom: '0',
}}
>
<h2 style={{ margin: 0, display: 'none' }}>Lägg till hemmavara</h2>
<label>
Produkt
<br />
<select name="productId" required style={{ width: '100%', padding: '0.5rem' }}>
<option value="">Välj produkt</option>
{products.map((product) => (
<option key={product.id} value={product.id}>
{product.canonicalName || product.name}
</option>
))}
</select>
</label>
<label>
Mängd
<br />
<input
name="quantity"
type="text"
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Enhet
<br />
<select
name="unit"
required
style={{ width: '100%', padding: '0.5rem' }}
>
{UNIT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label>
Plats
<br />
<select
name="location"
required
style={{ width: '100%', padding: '0.5rem' }}
>
{LOCATION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
<label>
Varumärke
<br />
<input
name="brand"
type="text"
placeholder="t.ex. Eldorado, Kronfågel, Garant, ICA Basic, Motti"
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Passar till
<br />
<input
name="suitableFor"
type="text"
placeholder="Wok, Gryta..."
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label>
Bäst före
<br />
<input
name="bestBeforeDate"
type="date"
style={{ width: '100%', padding: '0.5rem' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input name="opened" type="checkbox" />
Öppnad
</label>
<button type="submit" disabled={isPending} style={{ padding: '0.75rem' }}>
{isPending ? 'Sparar...' : 'Lägg till'}
</button>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</form>
)}
</div>
);
}
@@ -0,0 +1,204 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { InventoryItem } from '../../features/inventory/types';
import InventoryEditForm from './InventoryEditForm';
import InventoryConsumeForm from './InventoryConsumeForm';
import InventoryConsumptionHistory from './InventoryConsumptionHistory';
function formatDate(value: string | null) {
if (!value) return null;
return new Date(value).toLocaleDateString('sv-SE');
}
function getBestBeforeStatus(bestBeforeDate: string | null) {
if (!bestBeforeDate) {
return { label: 'Ingen bäst före angiven', color: '#666', background: '#f5f5f5', border: '#ddd' };
}
const today = new Date();
const bestBefore = new Date(bestBeforeDate);
today.setHours(0, 0, 0, 0);
bestBefore.setHours(0, 0, 0, 0);
const diffDays = Math.round((bestBefore.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { label: 'Utgången', color: '#8b0000', background: '#ffeaea', border: '#f1b5b5' };
if (diffDays <= 3) return { label: 'Snart utgången', color: '#8a4b00', background: '#fff4e5', border: '#f0cf9b' };
return { label: 'OK', color: '#1f5f2c', background: '#ecf8ee', border: '#b9e0bf' };
}
type Props = {
inventory: InventoryItem[];
onDeleted?: () => void;
};
export default function InventoryList({ inventory, onDeleted }: Props) {
const [search, setSearch] = useState('');
const router = useRouter();
// Unika produktnamn för autocomplete
const autocompleteNames = Array.from(
new Set(
inventory.map((item) => item.product.canonicalName || item.product.name)
)
).sort();
// Filtrera baserat på söktext
const filtered = search.trim()
? inventory.filter((item) => {
const q = search.trim().toLowerCase();
const name = (item.product.canonicalName || item.product.name).toLowerCase();
const brand = (item.brand || '').toLowerCase();
const loc = (item.location || '').toLowerCase();
const comment = (item.comment || '').toLowerCase();
const suitable = (item.suitableFor || '').toLowerCase();
return (
name.includes(q) ||
brand.includes(q) ||
loc.includes(q) ||
comment.includes(q) ||
suitable.includes(q)
);
})
: inventory;
return (
<section>
<h2>Aktuella hemmavaror (inventory)</h2>
{/* Sökfält */}
<div style={{ marginBottom: '1rem' }}>
<input
type="search"
list="inventory-autocomplete"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök vara, varumärke, plats..."
style={{
width: '100%',
padding: '0.6rem 0.75rem',
border: '1px solid #ccc',
borderRadius: '6px',
fontSize: '1rem',
}}
/>
<datalist id="inventory-autocomplete">
{autocompleteNames.map((name) => (
<option key={name} value={name} />
))}
</datalist>
{search && (
<div style={{ marginTop: '0.4rem', fontSize: '0.9rem', color: '#555' }}>
{filtered.length} av {inventory.length} varor visas
</div>
)}
</div>
{filtered.length === 0 ? (
<p>Inga hemmavaror matchar sökningen.</p>
) : (
<div style={{ display: 'grid', gap: '0.75rem' }}>
{filtered.map((item) => {
const bestBeforeStatus = getBestBeforeStatus(item.bestBeforeDate);
return (
<article
key={item.id}
style={{
border: `1px solid ${bestBeforeStatus.border}`,
borderRadius: '10px',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.6rem',
background: '#fff',
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div>
<strong style={{ fontSize: '1rem' }}>
{item.product.canonicalName || item.product.name}
</strong>
<div style={{ marginTop: '0.2rem', color: '#444' }}>
{item.quantity} {item.unit}
</div>
</div>
<div
style={{
padding: '0.3rem 0.6rem',
borderRadius: '999px',
background: bestBeforeStatus.background,
color: bestBeforeStatus.color,
border: `1px solid ${bestBeforeStatus.border}`,
fontSize: '0.85rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{bestBeforeStatus.label}
</div>
</div>
<div style={{ display: 'grid', gap: '0.35rem', color: '#333' }}>
{item.location ? <div>Plats: {item.location}</div> : null}
{item.brand ? <div>Varumärke: {item.brand}</div> : null}
<div>Öppnad: {item.opened ? 'Ja' : 'Nej'}</div>
{item.suitableFor ? <div>Passar till: {item.suitableFor}</div> : null}
{item.bestBeforeDate ? <div>Bäst före: {formatDate(item.bestBeforeDate)}</div> : null}
{item.comment ? <div>Kommentar: {item.comment}</div> : null}
</div>
<div
style={{
marginTop: '0.75rem',
paddingTop: '0.75rem',
borderTop: '1px solid #eee',
display: 'flex',
gap: '0.75rem',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<InventoryEditForm item={item} onUpdated={onDeleted ?? (() => router.refresh())} />
<InventoryConsumeForm id={item.id} unit={item.unit} />
<InventoryConsumptionHistory id={item.id} />
<button
type="button"
onClick={async () => {
if (!confirm(`Ta bort "${item.product.canonicalName || item.product.name}" från inventariet?`)) return;
const res = await fetch(`/api/admin/inventory-item/${item.id}`, { method: 'DELETE' });
if (res.ok) {
if (onDeleted) onDeleted();
else router.refresh();
}
}}
style={{
padding: '0.5rem 0.75rem',
background: '#fff0f0',
border: '1px solid #f5b8b8',
borderRadius: '6px',
color: '#c00',
cursor: 'pointer',
fontWeight: 500,
}}
>
Ta bort
</button>
</div>
</article>
);
})}
</div>
)}
</section>
);
}
@@ -0,0 +1,88 @@
'use client';
import { useState } from 'react';
export default function ProductForm() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
setError(null);
const form = e.currentTarget;
const name = String((new FormData(form)).get('name') || '').trim();
setIsPending(true);
try {
const res = await fetch('/api/admin/create-product', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || 'Kunde inte skapa produkt');
}
form.reset();
window.dispatchEvent(new CustomEvent('product-created'));
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
} finally {
setIsPending(false);
}
}}
style={{
display: 'grid',
gap: '0.75rem',
padding: '1rem',
border: '1px solid #ddd',
borderRadius: '8px',
marginBottom: '1.5rem',
}}
>
<h2 style={{ margin: 0 }}>Skapa produkt</h2>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
Produktnamn
</label>
<input
name="name"
type="text"
required
placeholder="Till exempel Rödkål"
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ddd',
borderRadius: '4px',
fontSize: '1rem',
boxSizing: 'border-box',
minHeight: '44px',
}}
/>
<button
type="submit"
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 ? 'Sparar...' : 'Skapa produkt'}
</button>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</form>
);
}
+188
View File
@@ -0,0 +1,188 @@
'use server';
import { revalidatePath } from 'next/cache';
import { API_BASE } from '../../lib/api';
import { getAuthHeaders } from '../../lib/auth-headers';
export async function createProduct(formData: FormData) {
const name = String(formData.get('name') || '').trim();
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders,
},
body: JSON.stringify({ name }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte skapa produkt: ${text}`);
}
revalidatePath('/inventory');
revalidatePath('/admin/products');
revalidatePath('/baslager');
}
export async function createInventoryItem(formData: FormData) {
const productId = Number(formData.get('productId'));
const quantity = Number(formData.get('quantity'));
const unit = String(formData.get('unit') || '').trim();
const location = String(formData.get('location') || '').trim();
const opened = formData.get('opened') === 'on';
const suitableFor = String(formData.get('suitableFor') || '').trim();
const bestBeforeDateRaw = String(formData.get('bestBeforeDate') || '').trim();
const brand = String(formData.get('brand') || '').trim();
const payload: Record<string, unknown> = {
productId,
quantity,
unit,
};
if (location) payload.location = location;
payload.opened = opened;
if (brand) payload.brand = brand;
if (suitableFor) payload.suitableFor = suitableFor;
if (bestBeforeDateRaw) payload.bestBeforeDate = bestBeforeDateRaw;
const res = await fetch(`${API_BASE}/api/inventory`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await getAuthHeaders()),
},
body: JSON.stringify(payload),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte skapa inventory-rad: ${text}`);
}
revalidatePath('/inventory');
}
export async function updateInventoryItem(formData: FormData) {
const id = Number(formData.get('id'));
const quantityRaw = String(formData.get('quantity') || '').trim();
const unit = String(formData.get('unit') || '').trim();
const location = String(formData.get('location') || '').trim();
const brand = String(formData.get('brand') || '').trim();
const suitableFor = String(formData.get('suitableFor') || '').trim();
const comment = String(formData.get('comment') || '').trim();
const bestBeforeDateRaw = String(formData.get('bestBeforeDate') || '').trim();
const opened = formData.get('opened') === 'on';
const payload: Record<string, unknown> = {
opened,
};
if (quantityRaw) payload.quantity = Number(quantityRaw);
if (unit) payload.unit = unit;
payload.location = location;
payload.brand = brand;
payload.suitableFor = suitableFor;
payload.comment = comment;
payload.bestBeforeDate = bestBeforeDateRaw || null;
const res = await fetch(`${API_BASE}/api/inventory/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(await getAuthHeaders()),
},
body: JSON.stringify(payload),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera inventory-rad: ${text}`);
}
revalidatePath('/inventory');
}
export async function updateCanonicalName(formData: FormData) {
const id = Number(formData.get('id'));
const canonicalName = String(formData.get('canonicalName') || '').trim();
const res = await fetch(`${API_BASE}/api/products/${id}/canonical-name`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(await getAuthHeaders()),
},
body: JSON.stringify({ canonicalName }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera canonicalName: ${text}`);
}
revalidatePath('/admin/products');
}
export async function mergeProducts(formData: FormData) {
const sourceProductId = Number(formData.get('sourceProductId'));
const targetProductId = Number(formData.get('targetProductId'));
const res = await fetch(`${API_BASE}/api/products/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await getAuthHeaders()),
},
body: JSON.stringify({
sourceProductId,
targetProductId,
}),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte slå ihop produkter: ${text}`);
}
revalidatePath('/admin/products');
}
export async function consumeInventoryItem(formData: FormData) {
const id = Number(formData.get('id'));
const amountUsed = Number(formData.get('amountUsed'));
const comment = String(formData.get('comment') || '').trim();
const payload: Record<string, unknown> = {
amountUsed,
};
if (comment) {
payload.comment = comment;
}
const res = await fetch(`${API_BASE}/api/inventory/${id}/consume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(await getAuthHeaders()),
},
body: JSON.stringify(payload),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte förbruka inventory-rad: ${text}`);
}
revalidatePath('/inventory');
}
+186
View File
@@ -0,0 +1,186 @@
import InventoryForm from './InventoryForm';
import Link from 'next/link';
import { fetchJson } from '../../lib/api';
import type { InventoryItem, Product } from '../../features/inventory/types';
import InventoryList from './InventoryList';
import Navigation from '../Navigation';
function formatDate(value: string | null) {
if (!value) return null;
return new Date(value).toLocaleDateString('sv-SE');
}
function getBestBeforeStatus(bestBeforeDate: string | null) {
if (!bestBeforeDate) {
return {
label: 'Ingen bäst före angiven',
color: '#666',
background: '#f5f5f5',
border: '#ddd',
};
}
const today = new Date();
const bestBefore = new Date(bestBeforeDate);
today.setHours(0, 0, 0, 0);
bestBefore.setHours(0, 0, 0, 0);
const diffMs = bestBefore.getTime() - today.getTime();
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return {
label: 'Utgången',
color: '#8b0000',
background: '#ffeaea',
border: '#f1b5b5',
};
}
if (diffDays <= 3) {
return {
label: 'Snart utgången',
color: '#8a4b00',
background: '#fff4e5',
border: '#f0cf9b',
};
}
return {
label: 'OK',
color: '#1f5f2c',
background: '#ecf8ee',
border: '#b9e0bf',
};
}
type InventoryPageProps = {
searchParams?: Promise<{
location?: string;
sort?: string;
}>;
};
function buildInventoryUrl(location?: string, sort?: string) {
const params = new URLSearchParams();
if (location) {
params.set('location', location);
}
if (sort) {
params.set('sort', sort);
}
const query = params.toString();
return query ? `/inventory?${query}` : '/inventory';
}
export default async function InventoryPage({ searchParams }: InventoryPageProps) {
const resolvedSearchParams = searchParams ? await searchParams : {};
const location = resolvedSearchParams.location || '';
const sort = resolvedSearchParams.sort || '';
const inventoryPath = (() => {
const params = new URLSearchParams();
if (location) params.set('location', location);
if (sort) params.set('sort', sort);
const query = params.toString();
return query ? `/api/inventory?${query}` : '/api/inventory';
})();
const [inventory, products] = await Promise.all([
fetchJson<InventoryItem[]>(inventoryPath),
fetchJson<Product[]>('/api/products'),
]);
const locationOptions = ['', 'Kyl', 'Frys', 'Skafferi'];
const sortOptions = [
{ value: '', label: 'Senast tillagda' },
{ value: 'nameAsc', label: 'Namn A\u2013\u00d6' },
{ value: 'bestBeforeAsc', label: 'B\u00e4st f\u00f6re Stigande' },
{ value: 'bestBeforeDesc', label: 'B\u00e4st f\u00f6re Fallande' },
];
return (
<main style={{ padding: '1rem', maxWidth: '1000px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '1.5rem' }}>Varor hemma</h1>
<InventoryForm products={products} />
<section style={{ marginBottom: '1.5rem' }}>
<h2>Filter och sortering</h2>
<div
style={{
display: 'grid',
gap: '1rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
alignItems: 'start',
}}
>
<div>
<div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>Plats</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{locationOptions.map((option) => {
const isActive = location === option;
const label = option === '' ? 'Alla' : option;
return (
<Link
key={option || 'alla'}
href={buildInventoryUrl(option || undefined, sort || undefined)}
scroll={false}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #ddd',
textDecoration: 'none',
color: '#111',
background: isActive ? '#efefef' : '#fff',
fontWeight: isActive ? 600 : 400,
}}
>
{label}
</Link>
);
})}
</div>
</div>
<div>
<div style={{ fontWeight: 600, marginBottom: '0.5rem' }}>Sortering</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{sortOptions.map((option) => {
const isActive = sort === option.value;
return (
<Link
key={option.value || 'default'}
href={buildInventoryUrl(location || undefined, option.value || undefined)}
scroll={false}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #ddd',
textDecoration: 'none',
color: '#111',
background: isActive ? '#efefef' : '#fff',
fontWeight: isActive ? 600 : 400,
}}
>
{option.label}
</Link>
);
})}
</div>
</div>
</div>
</section>
<InventoryList inventory={inventory} />
</main>
);
}
@@ -0,0 +1,608 @@
'use client';
import { useRef, useState, useEffect } from 'react';
type CategorySuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
type ParsedItem = {
rawName: string;
quantity: number;
unit: string;
price?: number | null;
brand?: string | null;
origin?: string | null;
matchedProductId?: number;
matchedProductName?: string;
suggestedProductId?: number;
suggestedProductName?: string;
categorySuggestion?: CategorySuggestion;
};
type Product = { id: number; name: string; canonicalName: string | null };
type Category = { id: number; name: string; parentId: number | null };
type RowState = {
productSearch: string;
selectedCategoryId: number | ''; // för manuellt val vid none utan AI
rawName: string;
quantity: number;
unit: string;
price?: number | null;
selectedProductId: number | '';
selectedProductName: string;
checked: boolean;
saveAlias: boolean;
editQty: string;
editUnit: string;
editBrand: string;
editOrigin: string;
editComment: string;
matchSource: 'alias' | 'suggestion' | 'manual' | 'none';
categorySuggestion?: CategorySuggestion;
};
const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska'];
export default function ReceiptImportClient({ isAdmin }: { isAdmin: boolean }) {
const fileRef = useRef<HTMLInputElement>(null);
// Debug: log role on mount
useEffect(() => {
// eslint-disable-next-line no-console
console.log('ReceiptImportClient: isAdmin =', isAdmin);
}, [isAdmin]);
const [preview, setPreview] = useState<string | null>(null);
const [parsing, setParsing] = useState(false);
const [saving, setSaving] = useState(false);
const [rows, setRows] = useState<RowState[]>([]);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [allCategories, setAllCategories] = useState<Category[]>([]);
const [productsLoading, setProductsLoading] = useState(true);
const [productsError, setProductsError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedCount, setSavedCount] = useState<number | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showReceiptModal, setShowReceiptModal] = useState(false);
const [creatingProduct, setCreatingProduct] = useState<number | null>(null);
useEffect(() => {
fetch('/api/products')
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data) => {
if (Array.isArray(data)) {
setAllProducts(data);
} else {
setProductsError('Oväntat format från produktlistan');
}
})
.catch((e) => setProductsError(`Kunde inte ladda produktlistan: ${e.message}`))
.finally(() => setProductsLoading(false));
fetch('/api/categories')
.then((r) => r.json())
.then((data) => { if (Array.isArray(data)) setAllCategories(data); })
.catch(() => {});
}, []);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFile(file);
setPreview(file.type === 'application/pdf' ? 'pdf' : URL.createObjectURL(file));
setRows([]);
setError(null);
setSavedCount(null);
};
const handleParse = async () => {
if (!selectedFile) return;
setParsing(true);
setError(null);
try {
const fd = new FormData();
fd.append('file', selectedFile);
const res = await fetch('/api/receipt-import-proxy', { method: 'POST', body: fd });
if (!res.ok) {
const e = await res.json().catch(() => ({ message: 'Okänt fel' }));
throw new Error(e.message ?? 'Servern svarade med fel');
}
const items: ParsedItem[] = await res.json();
setRows(
items.map((item): RowState => {
if (item.matchedProductId) {
return {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
price: item.price,
selectedProductId: item.matchedProductId,
selectedProductName: item.matchedProductName ?? '',
checked: true,
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
editBrand: item.brand ?? '',
editOrigin: item.origin ?? '',
editComment: '',
matchSource: 'alias',
productSearch: item.matchedProductName ?? '',
selectedCategoryId: '',
};
}
if (item.suggestedProductId) {
return {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
price: item.price,
selectedProductId: item.suggestedProductId,
selectedProductName: item.suggestedProductName ?? '',
checked: false,
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
editBrand: item.brand ?? '',
editOrigin: item.origin ?? '',
editComment: '',
matchSource: 'suggestion',
productSearch: item.suggestedProductName ?? '',
selectedCategoryId: '',
};
}
return {
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
price: item.price,
selectedProductId: '',
selectedProductName: '',
checked: false,
saveAlias: false,
editQty: String(item.quantity),
editUnit: item.unit,
editBrand: item.brand ?? '',
editOrigin: item.origin ?? '',
editComment: '',
matchSource: 'none',
categorySuggestion: item.categorySuggestion,
productSearch: '',
selectedCategoryId: item.categorySuggestion?.categoryId ?? '',
};
}),
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Kunde inte tolka kvittot');
} finally {
setParsing(false);
}
};
const updateRow = (i: number, patch: Partial<RowState>) => {
setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
};
const handleCreateProduct = async (i: number) => {
const row = rows[i];
setCreatingProduct(i);
setError(null);
// eslint-disable-next-line no-console
console.log('handleCreateProduct: isAdmin =', isAdmin, 'endpoint = /api/products-create');
try {
// Admin skapar aktiv produkt via API route
const res = await fetch('/api/admin/create-product', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: row.rawName }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.error ?? `HTTP ${res.status}`);
}
const product = await res.json();
// Sätt kategori: manuellt val har prioritet, annars AI-förslag
const categoryId = row.selectedCategoryId !== '' ? row.selectedCategoryId : row.categorySuggestion?.categoryId ?? null;
if (categoryId) {
const patchRes = await fetch(`/api/admin/update-product/${product.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categoryId }),
});
if (!patchRes.ok) {
const e = await patchRes.json().catch(() => ({}));
throw new Error(e.error ?? `HTTP ${patchRes.status}`);
}
}
// Uppdatera produktlistan lokalt
const newProduct = { id: product.id, name: product.name, canonicalName: product.canonicalName };
setAllProducts((prev) => [...prev, newProduct].sort((a, b) => (a.canonicalName ?? a.name).localeCompare(b.canonicalName ?? b.name, 'sv')));
// Markera raden som matchad
updateRow(i, {
selectedProductId: product.id,
selectedProductName: product.canonicalName ?? product.name,
productSearch: product.canonicalName ?? product.name,
checked: true,
matchSource: 'manual',
saveAlias: false,
});
} catch (err) {
setError(`Kunde inte skapa produkt: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setCreatingProduct(null);
}
};
const handleSuggestProduct = async (i: number) => {
const row = rows[i];
setCreatingProduct(i);
setError(null);
// eslint-disable-next-line no-console
console.log('handleSuggestProduct: isAdmin =', isAdmin, 'endpoint = /api/products/pending');
try {
// Användare skapar ett pending-förslag
const createRes = await fetch('/api/products/pending', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: row.rawName }),
});
if (!createRes.ok) {
const e = await createRes.json().catch(() => ({}));
throw new Error(e.message ?? `HTTP ${createRes.status}`);
}
const product = await createRes.json() as { id: number; name: string; canonicalName: string | null };
// Sätt kategori om vald/föreslagen
const categoryId = row.selectedCategoryId !== '' ? row.selectedCategoryId : row.categorySuggestion?.categoryId ?? null;
if (categoryId) {
await fetch(`/api/products/${product.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categoryId }),
});
}
// Lägg till i lokal lista (men markera som pending)
const newProduct = { id: product.id, name: product.name, canonicalName: product.canonicalName };
setAllProducts((prev) => [...prev, newProduct].sort((a, b) => (a.canonicalName ?? a.name).localeCompare(b.canonicalName ?? b.name, 'sv')));
// Markera raden — pending = kan läggas till i inventariet men väntar på admin-godkännande
updateRow(i, {
selectedProductId: product.id,
selectedProductName: product.canonicalName ?? product.name,
productSearch: product.canonicalName ?? product.name,
checked: true,
matchSource: 'manual',
saveAlias: false,
});
} catch (err) {
setError(`Kunde inte föreslå produkt: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setCreatingProduct(null);
}
};
const handleSave = async () => {
const toSave = rows.filter((r) => r.checked && r.selectedProductId !== '');
if (toSave.length === 0) return;
setSaving(true);
setError(null);
try {
const inventoryResults = await Promise.all(
toSave.map((r) =>
fetch('/api/inventory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId: r.selectedProductId,
quantity: parseFloat(r.editQty) || r.quantity,
unit: r.editUnit,
receiptName: r.rawName,
brand: r.editBrand.trim() || undefined,
origin: r.editOrigin.trim() || undefined,
comment: r.editComment.trim() || undefined,
}),
}),
),
);
const failedInventory = inventoryResults.find((r) => !r.ok);
if (failedInventory) {
const e = await failedInventory.json().catch(() => ({}));
throw new Error(e.message ?? `Inventory HTTP ${failedInventory.status}`);
}
await Promise.all(
toSave
.filter((r) => r.saveAlias && r.selectedProductId !== '')
.map((r) =>
fetch('/api/receipt-alias-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
receiptName: r.rawName,
productId: r.selectedProductId,
}),
}),
),
);
setSavedCount(toSave.length);
setRows([]);
setPreview(null);
setSelectedFile(null);
if (fileRef.current) fileRef.current.value = '';
} catch (err) {
setError(`Sparning misslyckades: ${err instanceof Error ? err.message : 'Okänt fel'}. Försök igen.`);
} finally {
setSaving(false);
}
};
const checkedCount = rows.filter((r) => r.checked && r.selectedProductId !== '').length;
// Bygg flat lista av alla kategorier med hierarki: förälder → indragna barn
const flatCategoryOptions = (() => {
const roots = allCategories.filter((c) => c.parentId === null).sort((a, b) => a.name.localeCompare(b.name, 'sv'));
const result: { id: number; label: string }[] = [];
for (const root of roots) {
result.push({ id: root.id, label: root.name });
const children = allCategories.filter((c) => c.parentId === root.id).sort((a, b) => a.name.localeCompare(b.name, 'sv'));
for (const child of children) {
result.push({ id: child.id, label: `\u00a0\u00a0↳ ${child.name}` });
}
}
return result;
})();
const sourceLabel = (src: RowState['matchSource']) => {
if (src === 'alias') return { text: 'Känd vara', color: '#27ae60' };
if (src === 'suggestion') return { text: 'Förslag', color: '#e67e22' };
if (src === 'manual') return { text: 'Manuellt vald', color: '#0070f3' };
return null;
};
return (
<div>
{showReceiptModal && preview && preview !== 'pdf' && (
<div
onClick={() => setShowReceiptModal(false)}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.75)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Kvitto"
onClick={(e) => e.stopPropagation()}
style={{ maxWidth: '100%', maxHeight: '90vh', borderRadius: '8px', boxShadow: '0 4px 32px rgba(0,0,0,0.5)', objectFit: 'contain' }}
/>
<button
onClick={() => setShowReceiptModal(false)}
style={{ position: 'absolute', top: '1rem', right: '1rem', background: 'rgba(255,255,255,0.9)', border: 'none', borderRadius: '50%', width: '2.5rem', height: '2.5rem', fontSize: '1.25rem', cursor: 'pointer', lineHeight: 1 }}
></button>
</div>
)}
<div
style={{ border: '2px dashed #ced4da', borderRadius: '10px', padding: '1.5rem', textAlign: 'center', background: '#fafafa', marginBottom: '1rem', cursor: preview ? 'default' : 'pointer' }}
onClick={() => { if (!preview) fileRef.current?.click(); }}
>
<input ref={fileRef} type="file" accept="image/*,application/pdf" capture="environment" onChange={handleFileChange} style={{ display: 'none' }} />
{preview === 'pdf' ? (
<div style={{ padding: '1rem' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📄</div>
<div style={{ fontWeight: 600 }}>{selectedFile?.name}</div>
<div style={{ fontSize: '0.85rem', color: '#888', marginTop: '0.25rem' }}>PDF-kvitto valt</div>
</div>
) : preview ? (
<div style={{ padding: '1rem' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={preview}
alt="Kvittoförhandsgranskning"
onClick={(e) => { e.stopPropagation(); setShowReceiptModal(true); }}
style={{ maxHeight: '200px', maxWidth: '100%', borderRadius: '6px', marginBottom: '0.75rem', cursor: 'zoom-in', boxShadow: '0 1px 4px rgba(0,0,0,0.15)' }}
/>
<div style={{ fontWeight: 600, fontSize: '0.95rem' }}>{selectedFile?.name}</div>
<div style={{ fontSize: '0.85rem', color: '#888', marginTop: '0.25rem' }}>Klicka bilden för att förstora <span style={{ color: '#2563eb', cursor: 'pointer', textDecoration: 'underline' }} onClick={(e) => { e.stopPropagation(); fileRef.current?.click(); }}>Byt fil</span></div>
</div>
) : (
<div style={{ color: '#888' }}>
<div style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>📷</div>
<div style={{ fontWeight: 600 }}>Fotografera eller välj kvitto</div>
<div style={{ fontSize: '0.85rem', marginTop: '0.25rem' }}>Klicka för att välja bild (JPEG, PNG, WebP) eller PDF</div>
</div>
)}
</div>
{productsError && (
<p style={{ color: '#c0392b', background: '#fdf0ef', padding: '0.75rem 1rem', borderRadius: '6px', marginTop: '0.75rem', fontSize: '0.9rem' }}>
{productsError}
</p>
)}
{preview && rows.length === 0 && (
<button onClick={handleParse} disabled={parsing} style={primaryBtn(parsing)}>
{parsing ? '⏳ Läser kvitto...' : '🔍 Läs kvitto'}
</button>
)}
{error && (
<p style={{ color: '#c0392b', background: '#fdf0ef', padding: '0.75rem 1rem', borderRadius: '6px', marginTop: '0.75rem' }}>{error}</p>
)}
{savedCount !== null && (
<p style={{ color: '#27ae60', background: '#edfdf4', padding: '0.75rem 1rem', borderRadius: '6px', marginTop: '0.75rem', fontWeight: 600 }}>
{savedCount} {savedCount === 1 ? 'vara lades till' : 'varor lades till'} i inventariet.
</p>
)}
{rows.length > 0 && (
<div style={{ marginTop: '1.25rem' }}>
{!isAdmin && (
<div style={{ fontSize: '0.82rem', color: '#92400e', background: '#fffbeb', border: '1px solid #fde68a', borderRadius: '6px', padding: '0.6rem 0.9rem', marginBottom: '0.75rem' }}>
<strong>Tips:</strong> Om en vara saknas kan du klicka <em>Föreslå ny vara</em> varan läggs till i inventariet och skickas för granskning av en administratör.
</div>
)}
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '1rem', alignItems: 'baseline', flexWrap: 'wrap' }}>
<h2 style={{ margin: 0, fontSize: '1.05rem' }}>Identifierade varor ({rows.length})</h2>
<span style={{ fontSize: '0.8rem', color: '#888' }}>
🟢 Känd = automatiskt markerad · 🟠 Förslag = markera för att inkludera · {isAdmin ? 'Sök eller skapa ny produkt' : 'Sök eller föreslå ny vara'}
</span>
</div>
<div style={{ display: 'grid', gap: '0.5rem', marginBottom: '1rem' }}>
{rows.map((row, i) => {
const label = sourceLabel(row.matchSource);
return (
<div key={i} style={{ padding: '0.75rem 1rem', border: `1px solid ${row.matchSource === 'alias' ? '#a8d5b5' : row.matchSource === 'suggestion' ? '#f5cba7' : '#dee2e6'}`, borderRadius: '8px', background: row.matchSource === 'alias' ? '#f0faf4' : row.matchSource === 'suggestion' ? '#fef9f5' : '#fff' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
<input type="checkbox" checked={row.checked} disabled={row.selectedProductId === ''} onChange={(e) => updateRow(i, { checked: e.target.checked })} style={{ width: '18px', height: '18px', cursor: row.selectedProductId !== '' ? 'pointer' : 'not-allowed' }} />
<span style={{ fontWeight: 500 }}>{row.rawName}</span>
{label && (
<span style={{ fontSize: '0.75rem', color: label.color, border: `1px solid ${label.color}`, borderRadius: '4px', padding: '1px 6px' }}>{label.text}</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 120px 80px 90px', gap: '0.5rem', alignItems: 'center' }}>
<div style={{ position: 'relative' }}>
<input
list={`products-${i}`}
value={row.productSearch}
onChange={(e) => {
const val = e.target.value;
updateRow(i, { productSearch: val });
// Hitta exakt match
const match = allProducts.find(
(p) => (p.canonicalName ?? p.name) === val
);
if (match) {
updateRow(i, {
productSearch: val,
selectedProductId: match.id,
selectedProductName: match.canonicalName ?? match.name,
checked: true,
matchSource: row.matchSource === 'alias' ? 'alias' : 'manual',
saveAlias: row.matchSource !== 'alias',
});
} else {
updateRow(i, {
productSearch: val,
selectedProductId: '',
selectedProductName: '',
checked: false,
});
}
}}
placeholder={productsLoading ? 'Laddar produkter...' : 'Sök produkt...'}
disabled={productsLoading}
style={{ width: '100%', padding: '0.35rem 0.5rem', border: `1px solid ${row.selectedProductId !== '' ? '#22c55e' : '#ced4da'}`, borderRadius: '6px', fontSize: '0.9rem', boxSizing: 'border-box' }}
/>
<datalist id={`products-${i}`}>
{allProducts.map((p) => (
<option key={p.id} value={p.canonicalName ?? p.name} />
))}
</datalist>
</div>
<input type="number" min="0" step="0.01" value={row.editQty} onChange={(e) => updateRow(i, { editQty: e.target.value })} style={{ padding: '0.35rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.9rem' }} />
<select value={row.editUnit} onChange={(e) => updateRow(i, { editUnit: e.target.value })} style={{ padding: '0.35rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.9rem' }}>
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
</select>
</div>
<div style={{ marginTop: '0.4rem', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.4rem' }}>
<input
type="text"
value={row.editBrand}
onChange={(e) => updateRow(i, { editBrand: e.target.value })}
placeholder="Märke / leverantör (valfritt)"
style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555' }}
/>
<input
type="text"
value={row.editOrigin}
onChange={(e) => updateRow(i, { editOrigin: e.target.value })}
placeholder="Ursprungsland (valfritt)"
style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555' }}
/>
</div>
<div style={{ marginTop: '0.4rem' }}>
<input
type="text"
value={row.editComment}
onChange={(e) => updateRow(i, { editComment: e.target.value })}
placeholder="Kommentar, t.ex. styckning, kvalitet... (valfritt)"
style={{ width: '100%', padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '6px', fontSize: '0.82rem', color: '#555', boxSizing: 'border-box' }}
/>
</div>
{row.matchSource === 'none' && (
<div style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<select
value={row.selectedCategoryId}
onChange={(e) => updateRow(i, { selectedCategoryId: e.target.value === '' ? '' : Number(e.target.value) })}
style={{ fontSize: '0.8rem', padding: '3px 6px', border: '1px solid #d1d5db', borderRadius: '5px', color: '#374151', maxWidth: '260px' }}
>
<option value=""> Välj kategori </option>
{flatCategoryOptions.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
{row.categorySuggestion && (
<span style={{ fontSize: '0.75rem', color: '#7c3aed', background: '#f5f3ff', border: '1px solid #ddd6fe', borderRadius: '5px', padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: '0.3rem' }}>
AI: {row.categorySuggestion.path}{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}> (osäker)</span>}
</span>
)}
{isAdmin ? (
<button
onClick={() => handleCreateProduct(i)}
disabled={creatingProduct === i}
style={{ fontSize: '0.8rem', padding: '3px 10px', background: creatingProduct === i ? '#e5e7eb' : '#f0fdf4', color: creatingProduct === i ? '#9ca3af' : '#166534', border: '1px solid #bbf7d0', borderRadius: '5px', cursor: creatingProduct === i ? 'not-allowed' : 'pointer', fontWeight: 500 }}
>
{creatingProduct === i ? '⏳ Skapar...' : '+ Skapa ny produkt'}
</button>
) : (
<button
onClick={() => handleSuggestProduct(i)}
disabled={creatingProduct === i}
style={{ fontSize: '0.8rem', padding: '3px 10px', background: creatingProduct === i ? '#e5e7eb' : '#fefce8', color: creatingProduct === i ? '#9ca3af' : '#854d0e', border: '1px solid #fde68a', borderRadius: '5px', cursor: creatingProduct === i ? 'not-allowed' : 'pointer', fontWeight: 500 }}
>
{creatingProduct === i ? '⏳ Skickar...' : '+ Föreslå ny vara'}
</button>
)}
</div>
)}
{row.selectedProductId !== '' && row.matchSource !== 'alias' && (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.5rem', fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}>
<input type="checkbox" checked={row.saveAlias} onChange={(e) => updateRow(i, { saveAlias: e.target.checked })} />
Kom ihåg kopplingen nästa gång matchas &quot;{row.rawName}&quot; automatiskt
</label>
)}
</div>
);
})}
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<button onClick={handleSave} disabled={saving || checkedCount === 0} style={primaryBtn(saving || checkedCount === 0)}>
{saving ? 'Sparar...' : `Lägg till ${checkedCount} ${checkedCount === 1 ? 'vara' : 'varor'} i inventariet`}
</button>
<button onClick={() => { setRows([]); setPreview(null); setSelectedFile(null); if (fileRef.current) fileRef.current.value = ''; }} style={{ padding: '0.5rem 1rem', background: '#f0f0f0', border: '1px solid #ccc', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem' }}>
Börja om
</button>
</div>
</div>
)}
</div>
);
}
function primaryBtn(disabled: boolean): React.CSSProperties {
return { padding: '0.6rem 1.25rem', background: disabled ? '#aaa' : '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 600, fontSize: '0.95rem' };
}
+72
View File
@@ -0,0 +1,72 @@
'use server';
import { getAuthHeaders } from '../../lib/auth-headers';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export async function createProductAction(name: string) {
try {
const authHeaders = await getAuthHeaders();
console.log('[createProductAction] Creating product with name:', name);
console.log('[createProductAction] Auth headers:', authHeaders ? 'YES' : 'NO');
const res = await fetch(`${API_BASE}/api/products`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.message ?? `HTTP ${res.status}`);
}
const product = await res.json();
console.log('[createProductAction] Response:', product);
// Explicitly convert to plain object to ensure serializability
const result = JSON.parse(JSON.stringify({
id: product.id,
name: product.name,
canonicalName: product.canonicalName ?? null,
}));
console.log('[createProductAction] Returning:', result);
return result;
} catch (err) {
console.error('[createProductAction] Error:', err);
throw err;
}
}
export async function updateProductCategoryAction(productId: number, categoryId: number) {
try {
const authHeaders = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products/${productId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...authHeaders },
body: JSON.stringify({ categoryId }),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.message ?? `HTTP ${res.status}`);
}
const product = await res.json();
// Explicitly convert to plain object to ensure serializability
const result = JSON.parse(JSON.stringify({
id: product.id,
name: product.name,
canonicalName: product.canonicalName ?? null,
categoryId: product.categoryId ?? null,
}));
return result;
} catch (err) {
console.error('[updateProductCategoryAction] Error:', err);
throw err;
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
import Providers from './Providers';
export const metadata: Metadata = {
title: 'Recipe App',
description: 'Din receptapp',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="sv">
<body style={{ fontFamily: 'Arial, sans-serif', margin: 0 }}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+98
View File
@@ -0,0 +1,98 @@
'use client';
import { useState, FormEvent, Suspense } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter, useSearchParams } from 'next/navigation';
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams?.get('callbackUrl') ?? '/';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
const result = await signIn('credentials', {
username,
password,
redirect: false,
});
setLoading(false);
if (result?.error) {
setError('Felaktigt användarnamn eller lösenord');
} else {
router.push(callbackUrl);
router.refresh();
}
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label htmlFor="username" style={{ display: 'block', marginBottom: 4 }}>
Användarnamn
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid #ccc', fontSize: '1rem' }}
/>
</div>
<div>
<label htmlFor="password" style={{ display: 'block', marginBottom: 4 }}>
Lösenord
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid #ccc', fontSize: '1rem' }}
/>
</div>
{error && <p style={{ color: 'red', margin: 0 }}>{error}</p>}
<button
type="submit"
disabled={loading}
style={{
padding: '10px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: '1rem',
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Loggar in...' : 'Logga in'}
</button>
<p style={{ textAlign: 'center', fontSize: '0.9rem' }}>
Inget konto? <a href="/register">Skapa konto</a>
</p>
</form>
);
}
export default function LoginPage() {
return (
<main style={{ maxWidth: 400, margin: '80px auto', padding: '0 1rem' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Logga in</h1>
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</main>
);
}
@@ -0,0 +1,352 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import type { Recipe } from '../../features/inventory/types';
const DAYS_SV = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag', 'Söndag'];
type MealPlanEntry = {
id: number;
date: string;
servings: number | null;
recipe: Pick<Recipe, 'id' | 'name' | 'imageUrl'> & {
servings: number | null;
ingredients: { quantity: string; unit: string; note: string | null; product: { id: number; name: string; canonicalName: string | null } }[];
};
};
type ShoppingItem = { productId: number; name: string; quantity: number; unit: string };
type InventoryCompareItem = {
productId: number;
name: string;
required: number;
unit: string;
available: number;
missing: number;
status: 'enough' | 'missing' | 'pantry';
};
function getWeekDates(offset = 0): string[] {
const now = new Date();
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1) + offset * 7);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return d.toISOString().slice(0, 10);
});
}
export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
const [weekOffset, setWeekOffset] = useState(0);
const [entries, setEntries] = useState<MealPlanEntry[]>([]);
const [shopping, setShopping] = useState<ShoppingItem[]>([]);
const [inventoryCompare, setInventoryCompare] = useState<InventoryCompareItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null); // date being saved
const weekDates = getWeekDates(weekOffset);
const from = weekDates[0];
const to = weekDates[6];
const weekLabel = (() => {
const f = new Date(from);
const t = new Date(to);
return `${f.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })} ${t.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short', year: 'numeric' })}`;
})();
const load = useCallback(async () => {
setLoading(true);
try {
const [entriesRes, shoppingRes, compareRes] = await Promise.all([
fetch(`/api/meal-plan-proxy?from=${from}&to=${to}`),
fetch(`/api/meal-plan-proxy/shopping?from=${from}&to=${to}`),
fetch(`/api/meal-plan-proxy/inventory-compare?from=${from}&to=${to}`),
]);
const entriesData = await entriesRes.json();
setEntries(Array.isArray(entriesData) ? entriesData : []);
if (shoppingRes.ok) setShopping(await shoppingRes.json());
else setShopping([]);
if (compareRes.ok) setInventoryCompare(await compareRes.json());
else setInventoryCompare([]);
} catch {
setEntries([]);
setShopping([]);
setInventoryCompare([]);
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => { load(); }, [load]);
const entryForDate = (date: string) => entries.find((e) => e.date.slice(0, 10) === date);
const handleSelect = async (date: string, recipeId: string) => {
setSaving(date);
try {
if (!recipeId) {
await fetch(`/api/meal-plan-proxy?date=${date}`, { method: 'DELETE' });
} else {
const existing = entryForDate(date);
await fetch('/api/meal-plan-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, recipeId: Number(recipeId), servings: existing?.servings ?? null }),
});
}
await load();
} finally {
setSaving(null);
}
};
const plannedCount = weekDates.filter((d) => entryForDate(d)).length;
const handleServingsChange = async (date: string, servings: number | null) => {
const entry = entryForDate(date);
if (!entry) return;
await fetch('/api/meal-plan-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, recipeId: entry.recipe.id, servings }),
});
await load();
};
return (
<div>
{/* Veckonavigering */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
<button onClick={() => setWeekOffset((w) => w - 1)} style={btnStyle()}> Förra veckan</button>
<span style={{ fontWeight: 600 }}>{weekLabel}</span>
<button onClick={() => setWeekOffset((w) => w + 1)} style={btnStyle()}>Nästa vecka </button>
{weekOffset !== 0 && (
<button onClick={() => setWeekOffset(0)} style={btnStyle('#0070f3')}>Denna vecka</button>
)}
</div>
{loading ? (
<p style={{ color: '#888' }}>Laddar...</p>
) : (
<>
{/* Veckovy */}
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '2rem' }}>
{weekDates.map((date, i) => {
const entry = entryForDate(date);
const isSaving = saving === date;
const isToday = date === new Date().toISOString().slice(0, 10);
return (
<div
key={date}
style={{
display: 'grid',
gridTemplateColumns: '100px 1fr',
gap: '1rem',
alignItems: 'center',
padding: '0.75rem 1rem',
border: `1px solid ${isToday ? '#0070f3' : '#dee2e6'}`,
borderRadius: '8px',
background: isToday ? '#f0f7ff' : '#fff',
}}
>
<div>
<div style={{ fontWeight: 700, fontSize: '0.9rem' }}>{DAYS_SV[i]}</div>
<div style={{ fontSize: '0.78rem', color: '#888' }}>
{new Date(date).toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<select
value={entry?.recipe.id ?? ''}
onChange={(e) => handleSelect(date, e.target.value)}
disabled={isSaving}
style={{
flex: '1 1 200px',
padding: '0.5rem 0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '0.9rem',
background: '#fff',
color: entry ? '#111' : '#888',
}}
>
<option value=""> Inget planerat </option>
{recipes.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
{entry && (
<Link
href={`/recipes/${entry.recipe.id}`}
style={{ fontSize: '0.8rem', color: '#0070f3', whiteSpace: 'nowrap' }}
>
Visa recept
</Link>
)}
{entry && entry.recipe.servings && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.82rem', color: '#555' }}>
<span>Port.:</span>
<input
type="number"
min={1}
step={1}
value={entry.servings ?? entry.recipe.servings}
onChange={(e) => handleServingsChange(date, e.target.value ? Number(e.target.value) : null)}
style={{ width: '52px', padding: '0.25rem 0.4rem', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '0.82rem' }}
/>
{entry.servings && entry.servings !== entry.recipe.servings && (
<button
type="button"
onClick={() => handleServingsChange(date, null)}
title={`Återställ till ${entry.recipe.servings} portioner`}
style={{ fontSize: '0.75rem', color: '#888', background: 'none', border: 'none', cursor: 'pointer', padding: '0 0.2rem' }}
>
{entry.recipe.servings}
</button>
)}
</div>
)}
{isSaving && <span style={{ fontSize: '0.8rem', color: '#888' }}>Sparar...</span>}
</div>
</div>
);
})}
</div>
{/* Inköpslista med lagerstatus */}
<section style={{ border: '1px solid #dee2e6', borderRadius: '8px', padding: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>
Inköpslista ({plannedCount} {plannedCount === 1 ? 'recept' : 'recept'} planerade)
</h2>
{plannedCount === 0 ? (
<p style={{ color: '#888', margin: 0 }}>Välj recept ovan för att se en samlad ingredienslista.</p>
) : shopping.length === 0 ? (
<p style={{ color: '#888', margin: 0 }}>Laddar ingredienser...</p>
) : (() => {
// Berika varje rad med inventariestatus
type DisplayStatus = 'enough' | 'partial' | 'missing' | 'pantry';
const enriched = shopping.map((item) => {
const cmp = inventoryCompare.find(
(c) => c.productId === item.productId && c.unit === item.unit,
);
let displayStatus: DisplayStatus = 'missing';
let buyQty = item.quantity;
if (cmp) {
if (cmp.status === 'pantry') {
displayStatus = 'pantry';
buyQty = 0;
} else if (cmp.available >= cmp.required) {
displayStatus = 'enough';
buyQty = 0;
} else if (cmp.available > 0) {
displayStatus = 'partial';
buyQty = cmp.missing;
}
}
return { ...item, cmp, displayStatus, buyQty };
});
const order: Record<DisplayStatus, number> = { missing: 0, partial: 1, enough: 2, pantry: 3 };
enriched.sort((a, b) => order[a.displayStatus] - order[b.displayStatus] || a.name.localeCompare(b.name, 'sv'));
const missingCount = enriched.filter((e) => e.displayStatus === 'missing').length;
const partialCount = enriched.filter((e) => e.displayStatus === 'partial').length;
const enoughCount = enriched.filter((e) => e.displayStatus === 'enough').length;
const pantryCount = enriched.filter((e) => e.displayStatus === 'pantry').length;
const hasCompare = inventoryCompare.length > 0;
const fmtQty = (n: number) => (n % 1 === 0 ? String(n) : n.toFixed(1));
return (
<>
{/* Sammanfattning */}
{hasCompare && (
<div style={{ display: 'flex', gap: '1rem', marginBottom: '0.75rem', flexWrap: 'wrap', fontSize: '0.85rem' }}>
{missingCount > 0 && <span style={{ color: '#8b0000', fontWeight: 600 }}> {missingCount} saknas</span>}
{partialCount > 0 && <span style={{ color: '#7a5000', fontWeight: 600 }}> {partialCount} delvis hemma</span>}
{enoughCount > 0 && <span style={{ color: '#1f5f2c', fontWeight: 600 }}> {enoughCount} hemma</span>}
{pantryCount > 0 && <span style={{ color: '#555', fontWeight: 600 }}>📦 {pantryCount} baslager</span>}
{missingCount === 0 && partialCount === 0 && (
<span style={{ color: '#1f5f2c', fontWeight: 600 }}> Du har allt hemma!</span>
)}
</div>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.4rem' }}>
{enriched.map((item) => {
const isMissing = item.displayStatus === 'missing';
const isPartial = item.displayStatus === 'partial';
const isEnough = item.displayStatus === 'enough';
const isPantry = item.displayStatus === 'pantry';
const bg = isMissing ? '#ffeaea' : isPartial ? '#fff8e6' : isPantry ? '#f5f5f5' : '#ecf8ee';
const icon = isMissing ? '❌' : isPartial ? '⚠️' : isPantry ? '📦' : '✅';
return (
<li
key={`${item.productId}-${item.unit}`}
style={{
display: 'grid',
gridTemplateColumns: hasCompare ? '1.5rem 1fr auto' : '1fr auto',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '6px',
background: hasCompare ? bg : 'transparent',
fontSize: '0.88rem',
}}
>
{hasCompare && <span title={isEnough ? 'Finns hemma' : isPartial ? 'Delvis hemma' : isPantry ? 'Baslager — alltid hemma' : 'Saknas'}>{icon}</span>}
<span style={{ color: (isEnough || isPantry) ? '#555' : '#111' }}>
<strong>{item.name}</strong>
{isPartial && item.cmp && (
<span style={{ color: '#7a5000', fontSize: '0.8rem', marginLeft: '0.4rem' }}>
({fmtQty(item.cmp.available)} av {fmtQty(item.cmp.required)} {item.unit} hemma)
</span>
)}
{isEnough && (
<span style={{ color: '#888', fontSize: '0.8rem', marginLeft: '0.4rem' }}>
(finns hemma)
</span>
)}
{isPantry && (
<span style={{ color: '#888', fontSize: '0.8rem', marginLeft: '0.4rem' }}>
(baslager)
</span>
)}
</span>
<span style={{ fontWeight: 600, whiteSpace: 'nowrap', color: (isEnough || isPantry) ? '#888' : '#111' }}>
{(isEnough || isPantry)
? '—'
: `${fmtQty(item.buyQty)} ${item.unit}`}
</span>
</li>
);
})}
</ul>
</>
);
})()
}
</section>
</>
)}
</div>
);
}
function btnStyle(bg?: string): React.CSSProperties {
return {
padding: '0.45rem 0.9rem',
background: bg || '#f0f0f0',
color: bg ? '#fff' : '#333',
border: '1px solid ' + (bg || '#ccc'),
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: 500,
};
}
+18
View File
@@ -0,0 +1,18 @@
import { fetchJson } from '../../lib/api';
import type { Recipe } from '../../features/inventory/types';
import Navigation from '../Navigation';
import MealPlanClient from './MealPlanClient';
export default async function MealPlanPage() {
const recipes = await fetchJson<Recipe[]>('/api/recipes').catch(() => [] as Recipe[]);
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.25rem' }}>Matsedel</h1>
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
Välj ett recept per dag se en samlad ingredienslista i slutet.
</p>
<MealPlanClient recipes={recipes} />
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import Link from 'next/link';
import Navigation from './Navigation';
export default function HomePage() {
return (
<main style={{ padding: '1rem', maxWidth: '700px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '1.5rem' }}>Recipe App</h1>
<div style={{ display: 'grid', gap: '1rem' }}>
<Link href="/inventory" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
till varor som finns hemma
</Link>
<Link href="/recipes" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
till recept
</Link>
<Link href="/recipes/import" style={{ padding: '0.5rem', background: '#eee', borderRadius: '4px', textDecoration: 'none', color: '#222' }}>
Importera recept från PDF eller bild
</Link>
</div>
</main>
);
}
@@ -0,0 +1,188 @@
'use client';
import { useState, useEffect, FormEvent } from 'react';
type Profile = {
id: number;
username: string;
email: string;
firstName: string | null;
lastName: string | null;
};
export default function ProfileClient() {
const [profile, setProfile] = useState<Profile | null>(null);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/profile')
.then((r) => r.json())
.then((data: Profile) => {
setProfile(data);
setForm({
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
email: data.email,
});
})
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
setSaving(true);
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: form.firstName || null,
lastName: form.lastName || null,
email: form.email,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte spara ändringar');
} else {
setSuccess(true);
}
} catch {
setError('Något gick fel');
} finally {
setSaving(false);
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
borderRadius: 6,
border: '1px solid #ddd',
fontSize: '1rem',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 6,
fontWeight: 500,
fontSize: '0.9rem',
color: '#444',
};
if (loading) {
return <p style={{ color: '#666' }}>Laddar profil...</p>;
}
return (
<div style={{ maxWidth: 480 }}>
{/* Initialer/avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: '#2563eb',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{profile?.username?.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>{profile?.username}</div>
<div style={{ color: '#666', fontSize: '0.9rem' }}>{profile?.email}</div>
</div>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label htmlFor="firstName" style={labelStyle}>Förnamn</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="given-name"
/>
</div>
<div>
<label htmlFor="lastName" style={labelStyle}>Efternamn</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="family-name"
/>
</div>
</div>
<div>
<label htmlFor="email" style={labelStyle}>E-post</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
style={inputStyle}
autoComplete="email"
/>
</div>
<div>
<label style={labelStyle}>Användarnamn</label>
<input
type="text"
value={profile?.username ?? ''}
disabled
style={{ ...inputStyle, background: '#f5f5f5', color: '#888', cursor: 'not-allowed' }}
/>
<p style={{ fontSize: '0.8rem', color: '#999', margin: '4px 0 0' }}>
Användarnamn kan inte ändras
</p>
</div>
{error && <p style={{ color: '#dc2626', margin: 0 }}>{error}</p>}
{success && <p style={{ color: '#16a34a', margin: 0 }}>Ändringarna sparades!</p>}
<button
type="submit"
disabled={saving}
style={{
padding: '10px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: '1rem',
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
fontWeight: 500,
}}
>
{saving ? 'Sparar...' : 'Spara ändringar'}
</button>
</form>
</div>
);
}
@@ -0,0 +1,61 @@
'use client';
import Link from 'next/link';
type Tab = { id: string; label: string };
const USER_TABS: Tab[] = [
{ id: 'profil', label: 'Min profil' },
{ id: 'databas', label: '🗄️ Databas' },
];
const ADMIN_TABS: Tab[] = [
{ id: 'profil', label: 'Min profil' },
{ id: 'anvandare', label: '👥 Användare' },
{ id: 'databas', label: '🗄️ Databas' },
{ id: 'forslag', label: '⏳ Förslag' },
{ id: 'ai', label: '🤖 AI' },
];
type Props = {
activeTab: string;
isAdmin: boolean;
};
export default function ProfileTabs({ activeTab, isAdmin }: Props) {
const tabs = isAdmin ? ADMIN_TABS : USER_TABS;
return (
<div
style={{
display: 'flex',
gap: '0.25rem',
borderBottom: '2px solid #e5e7eb',
marginBottom: '2rem',
}}
>
{tabs.map((tab) => {
const active = tab.id === activeTab;
return (
<Link
key={tab.id}
href={`/profil?tab=${tab.id}`}
style={{
padding: '0.6rem 1.2rem',
textDecoration: 'none',
fontWeight: active ? 600 : 400,
fontSize: '0.95rem',
color: active ? '#2563eb' : '#555',
borderBottom: active ? '2px solid #2563eb' : '2px solid transparent',
marginBottom: '-2px',
borderRadius: '4px 4px 0 0',
background: active ? '#eff6ff' : 'transparent',
transition: 'background 0.15s',
}}
>
{tab.label}
</Link>
);
})}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { auth } from '../../auth';
import Navigation from '../Navigation';
import ProfileTabs from './ProfileTabs';
import MinProfilTab from './tabs/MinProfilTab';
export const metadata = { title: 'Min profil' };
type Props = {
searchParams: Promise<{ tab?: string }>;
};
export default async function ProfilPage({ searchParams }: Props) {
const { tab = 'profil' } = await searchParams;
const session = await auth();
const isAdmin = (session?.user as any)?.role === 'admin';
// DatabsTab och AnvandareTab laddas dynamiskt för att hålla page.tsx tunn
let TabContent: React.ComponentType;
if (tab === 'databas') {
const { default: DatabsTab } = await import('./tabs/DatabsTab');
TabContent = DatabsTab;
} else if (tab === 'anvandare' && isAdmin) {
const { default: AnvandareTab } = await import('./tabs/AnvandareTab');
TabContent = AnvandareTab;
} else if (tab === 'forslag' && isAdmin) {
const { default: ForslagTab } = await import('./tabs/ForslagTab');
TabContent = ForslagTab;
} else if (tab === 'ai' && isAdmin) {
const { default: AiTab } = await import('./tabs/AiTab');
TabContent = AiTab;
} else {
TabContent = MinProfilTab;
}
const adminTabs = ['anvandare', 'forslag', 'ai'];
const userTabs = ['databas'];
const activeTab =
(isAdmin && (adminTabs.includes(tab) || userTabs.includes(tab))) ||
userTabs.includes(tab)
? tab
: 'profil';
return (
<>
<Navigation />
<main style={{ padding: '1rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Min profil</h1>
<ProfileTabs activeTab={activeTab} isAdmin={isAdmin} />
<TabContent />
</main>
</>
);
}
@@ -0,0 +1,28 @@
import AiAdminClient from '../../admin/ai/AiAdminClient';
import type { AiModelInfo } from '../../admin/ai/AiAdminClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
export default async function AiTab() {
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
}
return (
<div>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.25rem' }}>🤖 AI-konfiguration</h2>
<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} />
</div>
);
}
@@ -0,0 +1,611 @@
'use client';
import { useState } from 'react';
interface User {
id: number;
username: string;
email: string;
role: string;
isPremium: boolean;
firstName?: string;
lastName?: string;
createdAt: string;
}
interface ResetResult {
to: string;
subject: string;
body: string;
temporaryPassword: string;
}
interface Props {
users: User[];
currentUserId: number;
}
export default function AnvandareClient({ users: initial, currentUserId }: Props) {
const [users, setUsers] = useState<User[]>(initial);
const [error, setError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState({
username: '',
email: '',
password: '',
role: 'user',
});
const [createError, setCreateError] = useState('');
// Lösenordsåterställning modal
const [resetResult, setResetResult] = useState<ResetResult | null>(null);
const [copiedBody, setCopiedBody] = useState(false);
const [copiedPw, setCopiedPw] = useState(false);
// Inline e-postbyte
const [editingEmailId, setEditingEmailId] = useState<number | null>(null);
const [editingEmail, setEditingEmail] = useState('');
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setCreateError('');
try {
const res = await fetch('/api/admin-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Kunde inte skapa användare');
setUsers((prev) => [...prev, data]);
setShowCreate(false);
setCreateForm({ username: '', email: '', password: '', role: 'user' });
} catch (err: any) {
setCreateError(err.message);
} finally {
setCreating(false);
}
}
async function handleDelete(id: number) {
if (!confirm('Är du säker på att du vill ta bort användaren?')) return;
setError('');
const res = await fetch(`/api/admin-users/${id}`, { method: 'DELETE' });
if (res.ok) {
setUsers((prev) => prev.filter((u) => u.id !== id));
} else {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte ta bort användaren');
}
}
async function handleRoleChange(id: number, role: string) {
setError('');
const res = await fetch(`/api/admin-users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
if (res.ok) {
const updated = await res.json();
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, role: updated.role } : u)));
} else {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte ändra roll');
}
}
async function handleResetPassword(id: number) {
setError('');
const res = await fetch(`/api/admin-users/${id}/reset-password`, { method: 'POST' });
const data = await res.json();
if (!res.ok) {
setError(data.message ?? 'Kunde inte återställa lösenord');
return;
}
setResetResult(data);
setCopiedBody(false);
setCopiedPw(false);
}
async function handlePremiumChange(id: number, isPremium: boolean) {
setError('');
const res = await fetch(`/api/admin-users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPremium }),
});
if (res.ok) {
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, isPremium } : u)));
} else {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte ändra plan');
}
}
async function handleEmailSave(id: number) {
setError('');
const res = await fetch(`/api/admin-users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: editingEmail }),
});
if (res.ok) {
const updated = await res.json();
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, email: updated.email } : u)));
setEditingEmailId(null);
} else {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte uppdatera e-post');
}
}
return (
<div>
{/* Lägg till användare */}
<div style={{ marginBottom: '1.5rem' }}>
<button
onClick={() => setShowCreate((v) => !v)}
style={{
background: '#2563eb',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '0.5rem 1.2rem',
cursor: 'pointer',
fontWeight: 500,
}}
>
{showCreate ? ' Stäng' : '+ Lägg till användare'}
</button>
{showCreate && (
<form
onSubmit={handleCreate}
style={{
marginTop: '1rem',
background: '#f8fafc',
border: '1px solid #e2e8f0',
borderRadius: 8,
padding: '1rem 1.5rem',
maxWidth: 420,
}}
>
<h3 style={{ marginTop: 0, marginBottom: '1rem' }}>Ny användare</h3>
{createError && (
<div style={{ color: '#dc2626', marginBottom: '0.75rem', fontSize: 14 }}>
{createError}
</div>
)}
{[
{ key: 'username', label: 'Användarnamn', type: 'text' },
{ key: 'email', label: 'E-post', type: 'email' },
{ key: 'password', label: 'Lösenord', type: 'password' },
].map(({ key, label, type }) => (
<div key={key} style={{ marginBottom: '0.75rem' }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>{label}</label>
<input
type={type}
value={(createForm as any)[key]}
onChange={(e) => setCreateForm((f) => ({ ...f, [key]: e.target.value }))}
required
style={{
width: '100%',
padding: '0.4rem 0.6rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 14,
boxSizing: 'border-box',
}}
/>
</div>
))}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>Roll</label>
<select
value={createForm.role}
onChange={(e) => setCreateForm((f) => ({ ...f, role: e.target.value }))}
style={{
padding: '0.4rem 0.6rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 14,
}}
>
<option value="user">Användare</option>
<option value="admin">Admin</option>
</select>
</div>
<button
type="submit"
disabled={creating}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '0.5rem 1.2rem',
cursor: 'pointer',
}}
>
{creating ? 'Skapar...' : 'Skapa'}
</button>
</form>
)}
</div>
{error && (
<div
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 6,
padding: '0.6rem 1rem',
color: '#dc2626',
marginBottom: '1rem',
fontSize: 14,
}}
>
{error}
</div>
)}
{/* Användartabell */}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
{['Användare', 'E-post', 'Roll', 'Plan', 'Åtgärder'].map((h) => (
<th
key={h}
style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => {
const isSelf = user.id === currentUserId;
const isEditingEmail = editingEmailId === user.id;
return (
<tr
key={user.id}
style={{ borderBottom: '1px solid #e2e8f0', background: isSelf ? '#f0f9ff' : 'transparent' }}
>
{/* Namn */}
<td style={{ padding: '0.6rem 0.8rem' }}>
<div style={{ fontWeight: 500 }}>{user.username}</div>
{(user.firstName || user.lastName) && (
<div style={{ color: '#64748b', fontSize: 12 }}>
{[user.firstName, user.lastName].filter(Boolean).join(' ')}
</div>
)}
{isSelf && (
<span style={{ fontSize: 11, color: '#2563eb', fontStyle: 'italic' }}>
Du själv
</span>
)}
</td>
{/* E-post */}
<td style={{ padding: '0.6rem 0.8rem' }}>
{isEditingEmail ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
type="email"
value={editingEmail}
onChange={(e) => setEditingEmail(e.target.value)}
style={{
padding: '0.3rem 0.5rem',
border: '1px solid #93c5fd',
borderRadius: 4,
fontSize: 13,
width: 180,
}}
/>
<button
onClick={() => handleEmailSave(user.id)}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '0.2rem 0.5rem',
cursor: 'pointer',
fontSize: 12,
}}
>
Spara
</button>
<button
onClick={() => setEditingEmailId(null)}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: 4,
padding: '0.2rem 0.5rem',
cursor: 'pointer',
fontSize: 12,
}}
>
Avbryt
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#334155' }}>{user.email}</span>
{!isSelf && (
<button
onClick={() => {
setEditingEmailId(user.id);
setEditingEmail(user.email);
}}
title="Ändra e-post"
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#64748b',
fontSize: 13,
padding: 0,
}}
>
</button>
)}
</div>
)}
</td>
{/* Roll */}
<td style={{ padding: '0.6rem 0.8rem' }}>
{isSelf ? (
<RoleBadge role={user.role} />
) : (
<select
value={user.role}
onChange={(e) => handleRoleChange(user.id, e.target.value)}
style={{
padding: '0.25rem 0.4rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 13,
background: user.role === 'admin' ? '#eff6ff' : '#f8fafc',
}}
>
<option value="user">Användare</option>
<option value="admin">Admin</option>
</select>
)}
</td>
{/* Plan */}
<td style={{ padding: '0.6rem 0.8rem' }}>
<select
value={user.isPremium ? 'paid' : 'free'}
onChange={(e) => handlePremiumChange(user.id, e.target.value === 'paid')}
style={{
padding: '0.25rem 0.4rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 13,
background: user.isPremium ? '#fef9c3' : '#f8fafc',
color: user.isPremium ? '#854d0e' : '#334155',
fontWeight: user.isPremium ? 600 : 400,
}}
>
<option value="free">Free</option>
<option value="paid">Paid </option>
</select>
</td>
{/* Åtgärder */}
<td style={{ padding: '0.6rem 0.8rem' }}>
{isSelf ? (
<span style={{ color: '#94a3b8', fontSize: 12 }}></span>
) : (
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleResetPassword(user.id)}
title="Återställ lösenord"
style={{
background: '#fef3c7',
border: '1px solid #fcd34d',
borderRadius: 4,
padding: '0.3rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: '#92400e',
}}
>
Återställ lösenord
</button>
<button
onClick={() => handleDelete(user.id)}
title="Ta bort"
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: 4,
padding: '0.3rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: '#dc2626',
}}
>
Ta bort
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Lösenordsåterställning modal */}
{resetResult && (
<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 2rem',
maxWidth: 520,
width: '90%',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
}}
>
<h3 style={{ marginTop: 0 }}>Lösenordet har återställts</h3>
<p style={{ fontSize: 13, color: '#475569' }}>
Skicka nedanstående meddelande till användaren och/eller ge dem det tillfälliga lösenordet.
</p>
<div style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<label style={{ fontSize: 13, fontWeight: 500 }}>Meddelande att skicka till användaren</label>
<button
onClick={() => {
navigator.clipboard.writeText(resetResult.body);
setCopiedBody(true);
}}
style={{
background: copiedBody ? '#dcfce7' : '#f1f5f9',
border: '1px solid #cbd5e1',
borderRadius: 4,
padding: '0.2rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: copiedBody ? '#16a34a' : '#334155',
}}
>
{copiedBody ? '✓ Kopierat' : 'Kopiera meddelande'}
</button>
</div>
<textarea
readOnly
value={resetResult.body}
rows={6}
style={{
width: '100%',
padding: '0.5rem',
fontSize: 13,
fontFamily: 'monospace',
border: '1px solid #e2e8f0',
borderRadius: 6,
background: '#f8fafc',
resize: 'vertical',
boxSizing: 'border-box',
}}
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<label style={{ fontSize: 13, fontWeight: 500 }}>Tillfälligt lösenord</label>
<button
onClick={() => {
navigator.clipboard.writeText(resetResult.temporaryPassword);
setCopiedPw(true);
}}
style={{
background: copiedPw ? '#dcfce7' : '#f1f5f9',
border: '1px solid #cbd5e1',
borderRadius: 4,
padding: '0.2rem 0.6rem',
cursor: 'pointer',
fontSize: 12,
color: copiedPw ? '#16a34a' : '#334155',
}}
>
{copiedPw ? '✓ Kopierat' : 'Kopiera lösenord'}
</button>
</div>
<div
style={{
fontFamily: 'monospace',
fontSize: 18,
fontWeight: 700,
letterSpacing: 2,
padding: '0.5rem 1rem',
background: '#fff7ed',
border: '1px solid #fed7aa',
borderRadius: 6,
color: '#9a3412',
userSelect: 'all',
}}
>
{resetResult.temporaryPassword}
</div>
</div>
{!copiedBody && !copiedPw && (
<div
style={{
background: '#fefce8',
border: '1px solid #fde68a',
borderRadius: 6,
padding: '0.5rem 0.75rem',
fontSize: 12,
color: '#92400e',
marginBottom: '1rem',
}}
>
Kopiera lösenordet eller meddelandet innan du stänger det visas inte igen.
</div>
)}
<button
onClick={() => setResetResult(null)}
style={{
background: '#e2e8f0',
border: 'none',
borderRadius: 6,
padding: '0.5rem 1.2rem',
cursor: 'pointer',
fontWeight: 500,
}}
>
Stäng
</button>
</div>
</div>
)}
</div>
);
}
function RoleBadge({ role }: { role: string }) {
return (
<span
style={{
display: 'inline-block',
padding: '0.2rem 0.6rem',
borderRadius: 12,
fontSize: 12,
fontWeight: 600,
background: role === 'admin' ? '#eff6ff' : '#f0fdf4',
color: role === 'admin' ? '#1d4ed8' : '#15803d',
border: `1px solid ${role === 'admin' ? '#bfdbfe' : '#bbf7d0'}`,
}}
>
{role === 'admin' ? 'Admin' : 'Användare'}
</span>
);
}
@@ -0,0 +1,23 @@
import { auth } from '../../../auth';
import { getAuthHeaders } from '../../../lib/auth-headers';
import AnvandareClient from './AnvandareClient';
export default async function AnvandareTab() {
const session = await auth();
const userId = Number(session?.user?.id ?? 0);
const headers = await getAuthHeaders();
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'}/api/users`,
{ headers, cache: 'no-store' },
);
if (!res.ok) {
const text = await res.text().catch(() => '');
console.error(`[AnvandareTab] GET /api/users misslyckades: ${res.status}`, text);
}
const users = res.ok ? await res.json() : [];
return <AnvandareClient users={users} currentUserId={userId} />;
}
@@ -0,0 +1,77 @@
'use client';
import { useState } from 'react';
import { useSession } from 'next-auth/react';
import ProductsView from './views/ProductsView';
import InventoryView from './views/InventoryView';
import PantryView from './views/PantryView';
type TableId = 'inventory' | 'pantry' | 'products';
type TableDef = {
id: TableId;
label: string;
adminOnly: boolean;
};
const TABLES: TableDef[] = [
{ id: 'inventory', label: '🏠 Inventarie', adminOnly: false },
{ id: 'pantry', label: '📦 Baslager', adminOnly: false },
{ id: 'products', label: '🗄️ Produkter', adminOnly: true },
];
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '0.5rem 1.1rem',
border: 'none',
borderBottom: active ? '2.5px solid #2563eb' : '2.5px solid transparent',
background: active ? '#eff6ff' : 'none',
cursor: 'pointer',
fontWeight: active ? 600 : 400,
color: active ? '#2563eb' : '#555',
fontSize: '0.95rem',
borderRadius: '4px 4px 0 0',
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
});
export default function DatabsTab() {
const { data: session } = useSession();
const isAdmin = (session?.user as any)?.role === 'admin';
const visibleTables = TABLES.filter((t) => !t.adminOnly || isAdmin);
const [activeTable, setActiveTable] = useState<TableId>('inventory');
// Om vald tabell inte är tillgänglig för rollen, fall tillbaka på första
const safeActive = visibleTables.find((t) => t.id === activeTable)
? activeTable
: visibleTables[0]?.id ?? 'inventory';
return (
<div>
{/* Tabellväljare */}
<div
style={{
display: 'flex',
gap: '0.25rem',
borderBottom: '1px solid #ddd',
marginBottom: '1.75rem',
}}
>
{visibleTables.map((table) => (
<button
key={table.id}
style={tabStyle(safeActive === table.id)}
onClick={() => setActiveTable(table.id)}
>
{table.label}
</button>
))}
</div>
{safeActive === 'inventory' && <InventoryView />}
{safeActive === 'pantry' && <PantryView />}
{safeActive === 'products' && isAdmin && <ProductsView />}
</div>
);
}
@@ -0,0 +1,25 @@
import { getAuthHeaders } from '../../../lib/auth-headers';
import PendingProductsClient from '../../admin/products/pending/PendingProductsClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
export default async function ForslagTab() {
const headers = await getAuthHeaders();
let products: any[] = [];
try {
const res = await fetch(`${API_BASE}/api/products/pending`, { headers, cache: 'no-store' });
if (res.ok) products = await res.json();
} catch {
// backend ej nåbart
}
return (
<div>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.5rem' }}>Väntande produktförslag</h2>
<p style={{ color: '#64748b', marginBottom: '1.5rem', fontSize: '0.9rem' }}>
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} />
</div>
);
}
@@ -0,0 +1,188 @@
'use client';
import { useState, useEffect, FormEvent } from 'react';
type Profile = {
id: number;
username: string;
email: string;
firstName: string | null;
lastName: string | null;
};
export default function ProfileClient() {
const [profile, setProfile] = useState<Profile | null>(null);
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/profile')
.then((r) => r.json())
.then((data: Profile) => {
setProfile(data);
setForm({
firstName: data.firstName ?? '',
lastName: data.lastName ?? '',
email: data.email,
});
})
.finally(() => setLoading(false));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setSuccess(false);
setSaving(true);
try {
const res = await fetch('/api/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName: form.firstName || null,
lastName: form.lastName || null,
email: form.email,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte spara ändringar');
} else {
setSuccess(true);
}
} catch {
setError('Något gick fel');
} finally {
setSaving(false);
}
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 12px',
borderRadius: 6,
border: '1px solid #ddd',
fontSize: '1rem',
boxSizing: 'border-box',
};
const labelStyle: React.CSSProperties = {
display: 'block',
marginBottom: 6,
fontWeight: 500,
fontSize: '0.9rem',
color: '#444',
};
if (loading) {
return <p style={{ color: '#666' }}>Laddar profil...</p>;
}
return (
<div style={{ maxWidth: 480 }}>
{/* Initialer/avatar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: '#2563eb',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '1.5rem',
fontWeight: 700,
flexShrink: 0,
}}
>
{profile?.username?.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>{profile?.username}</div>
<div style={{ color: '#666', fontSize: '0.9rem' }}>{profile?.email}</div>
</div>
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div>
<label htmlFor="firstName" style={labelStyle}>Förnamn</label>
<input
id="firstName"
type="text"
value={form.firstName}
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="given-name"
/>
</div>
<div>
<label htmlFor="lastName" style={labelStyle}>Efternamn</label>
<input
id="lastName"
type="text"
value={form.lastName}
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
style={inputStyle}
maxLength={100}
autoComplete="family-name"
/>
</div>
</div>
<div>
<label htmlFor="email" style={labelStyle}>E-post</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
style={inputStyle}
autoComplete="email"
/>
</div>
<div>
<label style={labelStyle}>Användarnamn</label>
<input
type="text"
value={profile?.username ?? ''}
disabled
style={{ ...inputStyle, background: '#f5f5f5', color: '#888', cursor: 'not-allowed' }}
/>
<p style={{ fontSize: '0.8rem', color: '#999', margin: '4px 0 0' }}>
Användarnamn kan inte ändras
</p>
</div>
{error && <p style={{ color: '#dc2626', margin: 0 }}>{error}</p>}
{success && <p style={{ color: '#16a34a', margin: 0 }}>Ändringarna sparades!</p>}
<button
type="submit"
disabled={saving}
style={{
padding: '10px',
background: '#2563eb',
color: 'white',
border: 'none',
borderRadius: 6,
fontSize: '1rem',
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.7 : 1,
fontWeight: 500,
}}
>
{saving ? 'Sparar...' : 'Spara ändringar'}
</button>
</form>
</div>
);
}
@@ -0,0 +1,54 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { InventoryItem, Product } from '../../../../features/inventory/types';
import InventoryList from '../../../inventory/InventoryList';
import InventoryForm from '../../../inventory/InventoryForm';
import { useAuthFetch } from '../../../../lib/use-auth-fetch';
export default function InventoryView() {
const [inventory, setInventory] = useState<InventoryItem[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const authFetch = useAuthFetch();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [invRes, prodRes] = await Promise.all([
authFetch('/api/inventory'),
fetch('/api/products'),
]);
if (!invRes.ok) throw new Error('Kunde inte hämta inventarie');
if (!prodRes.ok) throw new Error('Kunde inte hämta produkter');
const [inv, prods] = await Promise.all([invRes.json(), prodRes.json()]);
setInventory(inv);
setProducts(prods);
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel');
} finally {
setLoading(false);
}
}, [authFetch]);
useEffect(() => { load(); }, [load]);
if (loading) return <p style={{ color: '#888' }}>Laddar inventarie</p>;
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
return (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Lägg till, redigera och ta bort varor i ditt inventarie.
</p>
{/* Formulär för att lägga till vara — viker ut sig vid klick */}
<InventoryForm products={products} onCreated={load} />
{/* Lista med redigera/ta bort per rad */}
<InventoryList inventory={inventory} onDeleted={load} />
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import type { Product } from '../../../../features/inventory/types';
import AddToPantryForm from '../../../baslager/AddToPantryForm';
import PantryList from '../../../baslager/PantryList';
import { useAuthFetch } from '../../../../lib/use-auth-fetch';
type PantryItem = {
id: number;
productId: number;
createdAt: string;
updatedAt: string;
product: Product;
};
export default function PantryView() {
const [pantryItems, setPantryItems] = useState<PantryItem[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const authFetch = useAuthFetch();
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [pantryRes, prodRes] = await Promise.all([
authFetch('/api/pantry'),
fetch('/api/products'),
]);
if (!pantryRes.ok) throw new Error('Kunde inte hämta baslager');
if (!prodRes.ok) throw new Error('Kunde inte hämta produkter');
const [pantry, prods] = await Promise.all([pantryRes.json(), prodRes.json()]);
setPantryItems(pantry);
setProducts(prods);
} catch (e) {
setError(e instanceof Error ? e.message : 'Okänt fel');
} finally {
setLoading(false);
}
}, [authFetch]);
useEffect(() => { load(); }, [load]);
if (loading) return <p style={{ color: '#888' }}>Laddar baslager</p>;
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
const pantryProductIds = new Set(pantryItems.map((i) => i.productId));
return (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Produkter du alltid räknar med att ha hemma. Lägg till och ta bort varor i ditt baslager.
</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>Lägg till produkt</h2>
<AddToPantryForm products={products} pantryProductIds={pantryProductIds} onCreated={load} />
</section>
<section>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>
{pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret
</h2>
<PantryList items={pantryItems} onDeleted={load} />
</section>
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useState } from 'react';
import MergePreviewForm from '../../../admin/products/MergePreviewForm';
import AdminProductList from '../../../admin/products/AdminProductList';
import ExpandableCreateProductSection from '../../../admin/products/ExpandableCreateProductSection';
import ResetProductsButton from '../../../admin/products/ResetProductsButton';
import DeletedProductsView from '../../../admin/products/DeletedProductsView';
const subTabs = [
{ id: 'varor', label: '📦 Varor' },
{ id: 'skapa-merge', label: ' Skapa / Slå ihop' },
{ id: 'papperskorg', label: '🗑️ Papperskorg' },
] as const;
type SubTabId = typeof subTabs[number]['id'];
const tabStyle = (active: boolean): React.CSSProperties => ({
padding: '0.4rem 1rem',
border: 'none',
borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent',
background: 'none',
cursor: 'pointer',
fontWeight: active ? 600 : 400,
color: active ? '#111' : '#666',
fontSize: '0.95rem',
transition: 'color 0.15s, border-color 0.15s',
});
export default function ProductsView() {
const [activeSubTab, setActiveSubTab] = useState<SubTabId>('varor');
return (
<div>
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '1px solid #ddd', marginBottom: '1.5rem' }}>
{subTabs.map((tab) => (
<button
key={tab.id}
style={tabStyle(activeSubTab === tab.id)}
onClick={() => setActiveSubTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{activeSubTab === 'varor' && (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Granska och standardisera produktnamn samt hantera kategorier.
</p>
<AdminProductList />
</div>
)}
{activeSubTab === 'skapa-merge' && (
<div>
<p style={{ color: '#555', marginBottom: '1rem' }}>
Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter.
</p>
<ExpandableCreateProductSection />
<ResetProductsButton />
<MergePreviewForm />
</div>
)}
{activeSubTab === 'papperskorg' && <DeletedProductsView />}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More